diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 9538bf4..1a9ac62 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -24,7 +24,7 @@ jobs: pull-requests: read steps: - name: Validate PR title - uses: amannn/action-semantic-pull-request@v6 + uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: @@ -48,12 +48,12 @@ jobs: contents: read steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@313600b80b104eadebb9111787d37a2e83e014ca # v1.17.0 with: cache: true run-install: | @@ -61,3 +61,9 @@ jobs: - name: Check run: vp run check + + - name: Build image + run: buildah build --http-proxy=false --platform linux/amd64 -t agent-driver:local . + + - name: Test image environment + run: vp run test:image:environment diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 008d583..55abe86 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,52 +8,81 @@ on: permissions: {} concurrency: - group: release-${{ github.ref }} - cancel-in-progress: false + group: release + queue: max jobs: - build: - name: Build + verify: + name: Verify release runs-on: ubuntu-latest permissions: contents: read outputs: - version: ${{ steps.verify.outputs.version }} + version: ${{ steps.release.outputs.version }} steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false + ref: ${{ github.sha }} - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - cache: false - run-install: | - - args: ['--frozen-lockfile'] - - - id: verify + - id: release name: Verify release tag run: | set -euo pipefail tag="${GITHUB_REF_NAME}" version="$(node -p "require('./package.json').version")" - release_commit="$(git rev-parse "${tag}^{commit}")" if [[ "${tag}" != "v${version}" ]]; then echo "::error::Tag ${tag} does not match package version ${version}." exit 1 fi - if ! git merge-base --is-ancestor "${release_commit}" origin/main; then - echo "::error::Tag ${tag} must point to a commit reachable from origin/main." + if [[ ! "${version}" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then + echo "::error::Release version must be canonical stable MAJOR.MINOR.PATCH: ${version}." + exit 1 + fi + + if ! git merge-base --is-ancestor "${GITHUB_SHA}" origin/main; then + echo "::error::Release commit ${GITHUB_SHA} must be reachable from origin/main." exit 1 fi printf 'version=%s\n' "${version}" >> "${GITHUB_OUTPUT}" + build: + name: Build + needs: verify + runs-on: ubuntu-latest + permissions: + attestations: write + contents: read + id-token: write + outputs: + artifact_attempt: ${{ github.run_attempt }} + digest: ${{ steps.image.outputs.digest }} + version: ${{ needs.verify.outputs.version }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + ref: ${{ github.sha }} + + - name: Setup Vite+ + uses: voidzero-dev/setup-vp@313600b80b104eadebb9111787d37a2e83e014ca # v1.17.0 + with: + cache: false + run-install: | + - args: ['--frozen-lockfile'] + + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24.19.0" + - name: Check run: vp run check @@ -63,24 +92,53 @@ jobs: npm pack --ignore-scripts --pack-destination package - name: Test packed driver + env: + AGENT_DRIVER_LIVE: "1" + AGENT_DRIVER_LIVE_ARTIFACT: packed/dist/driver.mjs + run: | + set -euo pipefail + + tarballs=(package/*.tgz) + if [[ "${#tarballs[@]}" -ne 1 ]]; then + echo "::error::Expected exactly one npm package." + exit 1 + fi + + mkdir packed + tar -xzf "${tarballs[0]}" -C packed --strip-components=1 + shopt -s globstar nullglob + declarations=(packed/dist/types/**/*.d.ts) + vp exec tsc --ignoreConfig --noEmit --moduleResolution Bundler --module ESNext --target ESNext "${declarations[@]}" + bun test tests/driver-artifact-mcp.test.ts + + - name: Test packed driver live matrix timeout-minutes: 180 env: AGENT_DRIVER_LIVE_ARTIFACT: packed/dist/driver.mjs OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }} run: | - mkdir packed - tar -xzf package/*.tgz -C packed --strip-components=1 + set -euo pipefail + + if [[ -z "${OPENROUTER_API_KEY:-}" ]]; then + echo "::error::OPENROUTER_API_KEY is required for release live artifact tests." + exit 1 + fi + vp run test:live:artifact - - name: Build image + - id: image + name: Build image env: - VERSION: ${{ steps.verify.outputs.version }} + VERSION: ${{ needs.verify.outputs.version }} run: | + set -euo pipefail + title="$(node -p "require('./package.json').name")" description="$(node -p "require('./package.json').description")" license="$(node -p "require('./package.json').license")" buildah build \ + --http-proxy=false \ --platform linux/amd64 \ --label "org.opencontainers.image.title=$title" \ --label "org.opencontainers.image.description=$description" \ @@ -92,70 +150,258 @@ jobs: . buildah push localhost/agent-driver oci-archive:image.oci.tar + image_digest="$(skopeo inspect --format '{{.Digest}}' oci-archive:image.oci.tar)" + if [[ ! "$image_digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::Invalid image digest: $image_digest" + exit 1 + fi + printf 'digest=%s\n' "$image_digest" >> "$GITHUB_OUTPUT" + + - name: Test image environment + run: podman run --pull=never --rm --entrypoint node localhost/agent-driver /usr/local/libexec/mosoo/environment-package-manager-check.mjs smoke + + - name: Attest image + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 + with: + subject-name: ghcr.io/${{ github.repository }} + subject-digest: ${{ steps.image.outputs.digest }} + - name: Upload package - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: npm-package + name: npm-package-${{ github.run_attempt }} path: package/*.tgz if-no-files-found: error - retention-days: 1 + retention-days: 31 - name: Upload image - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: container-image + name: container-image-${{ github.run_attempt }} path: image.oci.tar if-no-files-found: error retention-days: 1 compression-level: 0 - publish-image: - name: Publish image + publish-versioned-image: + name: Publish versioned image needs: build runs-on: ubuntu-latest env: IMAGE: ghcr.io/${{ github.repository }} VERSION: ${{ needs.build.outputs.version }} + outputs: + digest: ${{ steps.publish.outputs.digest }} + version: ${{ steps.publish.outputs.version }} permissions: + attestations: read + contents: read packages: write steps: - name: Download image - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: container-image + name: container-image-${{ needs.build.outputs.artifact_attempt }} - - name: Publish image + - id: publish + name: Publish versioned image env: + DOCKER_CONFIG: ${{ runner.temp }}/docker + EXPECTED_DIGEST: ${{ needs.build.outputs.digest }} GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REGISTRY_AUTH_FILE: ${{ runner.temp }}/docker/config.json run: | - printf '%s' "$GHCR_TOKEN" | skopeo login --username "$GITHUB_ACTOR" --password-stdin ghcr.io - skopeo copy oci-archive:image.oci.tar "docker://$IMAGE:$VERSION" - skopeo copy oci-archive:image.oci.tar "docker://$IMAGE:latest" + set -euo pipefail + + printf '%s' "$GHCR_TOKEN" | docker login --username "$GITHUB_ACTOR" --password-stdin ghcr.io + + if remote_digest="$(skopeo inspect --format '{{.Digest}}' "docker://$IMAGE:$VERSION" 2>inspect.err)"; then + echo "$IMAGE:$VERSION already exists at $remote_digest." + else + inspect_error="$(&2 + exit 1 + ;; + esac + + archive_digest="$(skopeo inspect --format '{{.Digest}}' oci-archive:image.oci.tar)" + if [[ "$archive_digest" != "$EXPECTED_DIGEST" ]]; then + echo "::error::Downloaded image digest $archive_digest does not match attested build $EXPECTED_DIGEST." + exit 1 + fi + + skopeo copy --preserve-digests oci-archive:image.oci.tar "docker://$IMAGE:$VERSION" + remote_digest="$(skopeo inspect --format '{{.Digest}}' "docker://$IMAGE:$VERSION")" + if [[ "$remote_digest" != "$EXPECTED_DIGEST" ]]; then + echo "::error::Published image digest $remote_digest does not match attested build $EXPECTED_DIGEST." + exit 1 + fi + fi + + if [[ ! "$remote_digest" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "::error::Invalid remote image digest: $remote_digest" + exit 1 + fi + + gh attestation verify "oci://$IMAGE@$remote_digest" \ + --repo "$GITHUB_REPOSITORY" \ + --cert-identity "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/.github/workflows/release.yml@$GITHUB_REF" \ + --source-digest "$GITHUB_SHA" \ + --source-ref "$GITHUB_REF" \ + --deny-self-hosted-runners + printf 'digest=%s\n' "$remote_digest" >> "$GITHUB_OUTPUT" + printf 'version=%s\n' "$VERSION" >> "$GITHUB_OUTPUT" publish-npm: name: Publish to npm - needs: build + needs: [build, publish-versioned-image] runs-on: ubuntu-latest environment: name: npm url: https://www.npmjs.com/package/@mosoo/agent-driver/v/${{ needs.build.outputs.version }} permissions: id-token: write + outputs: + promote_latest: ${{ steps.publish.outputs.promote_latest }} steps: - name: Download package - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: npm-package + name: npm-package-${{ needs.build.outputs.artifact_attempt }} path: package - name: Setup Node.js - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: "lts/*" + node-version: "24.19.0" registry-url: https://registry.npmjs.org - - name: Publish package - run: npm publish package/*.tgz --ignore-scripts - # Bootstrap only: remove this env block after configuring npm Trusted Publishing. + - id: publish + name: Publish package + # Bootstrap only: remove NODE_AUTH_TOKEN after configuring npm Trusted Publishing. env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + VERSION: ${{ needs.build.outputs.version }} + run: | + set -euo pipefail + + tarballs=(./package/*.tgz) + if [[ "${#tarballs[@]}" -ne 1 ]]; then + echo "::error::Expected exactly one npm package." + exit 1 + fi + + tarball="${tarballs[0]}" + package_name="$(tar -xOf "$tarball" package/package.json | node -p "JSON.parse(require('fs').readFileSync(0, 'utf8')).name")" + local_integrity="$(node -e "const { createHash } = require('node:crypto'); const { readFileSync } = require('node:fs'); process.stdout.write('sha512-' + createHash('sha512').update(readFileSync(process.argv[1])).digest('base64'));" "$tarball")" + + if remote_integrity="$(npm view "$package_name@$VERSION" dist.integrity 2>view.err)"; then + if [[ "$remote_integrity" != "$local_integrity" ]]; then + echo "::error::Refusing to replace $package_name@$VERSION ($remote_integrity) with $local_integrity." + exit 1 + fi + + echo "$package_name@$VERSION already contains $local_integrity." + publish_needed=false + else + view_error="$(&2 + exit 1 + ;; + esac + + publish_needed=true + fi + + promote_latest=true + if newer_versions="$(npm view "$package_name@>$VERSION" version --json 2>newer.err)"; then + if [[ "$publish_needed" == true ]]; then + echo "::error::Refusing to publish $package_name@$VERSION after newer versions: $newer_versions" + exit 1 + fi + promote_latest=false + else + newer_error="$(&2 + exit 1 + ;; + esac + fi + + if [[ "$publish_needed" == true ]]; then + npm publish "$tarball" --ignore-scripts --provenance + fi + printf 'promote_latest=%s\n' "$promote_latest" >> "$GITHUB_OUTPUT" + + publish-latest-image: + name: Publish latest image + needs: [publish-versioned-image, publish-npm] + if: needs.publish-npm.outputs.promote_latest == 'true' + runs-on: ubuntu-latest + env: + IMAGE: ghcr.io/${{ github.repository }} + DIGEST: ${{ needs.publish-versioned-image.outputs.digest }} + PACKAGE_NAME: "@mosoo/agent-driver" + VERSION: ${{ needs.publish-versioned-image.outputs.version }} + permissions: + packages: write + steps: + - name: Setup Node.js + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: "24.19.0" + + - name: Verify npm latest + run: | + set -euo pipefail + + if npm_latest="$(npm view "$PACKAGE_NAME" dist-tags.latest 2>latest.err)"; then + if [[ "$npm_latest" != "$VERSION" ]]; then + echo "::error::Refusing to move $IMAGE:latest to $VERSION while npm latest is $npm_latest." + exit 1 + fi + else + latest_error="$(&2 ;; + esac + exit 1 + fi + + if newer_versions="$(npm view "$PACKAGE_NAME@>$VERSION" version --json 2>newer.err)"; then + echo "::error::Refusing to move $IMAGE:latest to $VERSION after newer npm versions: $newer_versions" + exit 1 + else + newer_error="$(&2 + exit 1 + ;; + esac + fi + + - name: Publish latest image + env: + DOCKER_CONFIG: ${{ runner.temp }}/docker + GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REGISTRY_AUTH_FILE: ${{ runner.temp }}/docker/config.json + run: | + set -euo pipefail + + printf '%s' "$GHCR_TOKEN" | docker login --username "$GITHUB_ACTOR" --password-stdin ghcr.io + skopeo copy --preserve-digests "docker://$IMAGE@$DIGEST" "docker://$IMAGE:latest" diff --git a/Containerfile b/Containerfile index c3bfdb8..adfbbc8 100644 --- a/Containerfile +++ b/Containerfile @@ -1,10 +1,16 @@ -FROM cloudflare/sandbox:0.12.3 +ARG BUN_VERSION=1.4.0 +FROM docker.io/oven/bun:${BUN_VERSION}@sha256:5ff609364c049b54eb0ff560ec96319729a972078ef2c755d758f0c6ef89c2d6 AS bun-runtime -# Keep the default base image version in sync with apps/api/package.json -> @cloudflare/sandbox. -ARG CLAUDE_AGENT_SDK_VERSION=0.3.211 -ARG ANTHROPIC_SDK_VERSION=0.111.0 -ARG OPENAI_RUNTIME_VERSION=0.144.5 -ARG OPENCODE_VERSION=1.18.4 +FROM docker.io/cloudflare/sandbox:0.12.9@sha256:4a56a37a3cfd9b38d65bb4b5d0b341e6490a3a4c0226274ae4c1cca4948e85fe + +# Keep this pin in sync with downstream mosoo apps/api/package.json -> @cloudflare/sandbox. +ARG CLAUDE_AGENT_SDK_VERSION=0.3.257 +ARG BUN_VERSION +ARG OPENAI_RUNTIME_VERSION=0.152.0 +ARG OPENCODE_VERSION=1.18.25 + +COPY --from=bun-runtime /usr/local/bin/bun /usr/local/bin/bun +RUN test "$(bun --version)" = "$BUN_VERSION" # Install the Python runtime behind writable pip package declarations. RUN apt-get update \ @@ -12,7 +18,7 @@ RUN apt-get update \ python3 \ python3-pip \ python-is-python3 \ - && rm -rf /var/lib/apt/lists/* + && rm -rf /var/cache/apt/* /var/lib/apt/lists/* COPY environment-package-managers.json /etc/mosoo/environment-package-managers.json COPY scripts/environment-package-manager-check.mjs /usr/local/libexec/mosoo/environment-package-manager-check.mjs @@ -26,30 +32,23 @@ RUN node /usr/local/libexec/mosoo/environment-package-manager-check.mjs verify # Installed in a single npm invocation to keep the agent packages in one layer. # # Package -> binary -> runtime: -# @anthropic-ai/claude-agent-sdk -> native claude -> claude-agent-sdk +# Claude native package -> claude -> claude-agent-sdk # OpenAI app-server package -> OpenAI CLI -> openai-runtime -# opencode-ai -> opencode -> acp-fallback -# bun (base image) -> bun -> driver launcher +# OpenCode baseline package -> opencode -> acp-fallback +# bun (bun-runtime stage) -> bun -> driver launcher # -# Pick the architecture-specific `claude` binary that npm just installed under -# `@anthropic-ai/claude-agent-sdk-` so the image works -# on CF Containers (linux/amd64) and local arm64 hosts (e.g. Apple Silicon) -# without forcing platform emulation. -RUN OPENAI_RUNTIME_PACKAGE="@openai/codex@${OPENAI_RUNTIME_VERSION}" \ - && npm install -g \ - @anthropic-ai/claude-agent-sdk@${CLAUDE_AGENT_SDK_VERSION} \ - @anthropic-ai/sdk@${ANTHROPIC_SDK_VERSION} \ - opencode-ai@${OPENCODE_VERSION} \ - "$OPENAI_RUNTIME_PACKAGE" \ +RUN npm install -g --ignore-scripts \ + @anthropic-ai/claude-agent-sdk-linux-x64@${CLAUDE_AGENT_SDK_VERSION} \ + opencode-linux-x64-baseline@${OPENCODE_VERSION} \ + @openai/codex@${OPENAI_RUNTIME_VERSION} \ + && ln -s /usr/local/lib/node_modules/opencode-linux-x64-baseline/bin/opencode /usr/local/bin/opencode \ + && ln -s /usr/local/lib/node_modules/@anthropic-ai/claude-agent-sdk-linux-x64/claude /usr/local/bin/mosoo-claude-code \ && codex --version \ && codex app-server --help >/dev/null \ && opencode --version \ && opencode acp --help >/dev/null \ - && CLAUDE_ARCH_PACKAGE="$(node -p "'@anthropic-ai/claude-agent-sdk-' + (process.arch === 'arm64' ? 'linux-arm64' : 'linux-x64')")" \ - && CLAUDE_BIN="/usr/local/lib/node_modules/@anthropic-ai/claude-agent-sdk/node_modules/${CLAUDE_ARCH_PACKAGE}/claude" \ - && test -x "$CLAUDE_BIN" \ - && ln -sf "$CLAUDE_BIN" /usr/local/bin/mosoo-claude-code \ - && npm cache clean --force + && mosoo-claude-code --version \ + && rm -rf /root/.npm ENV MOSOO_CLAUDE_CODE_EXECUTABLE=/usr/local/bin/mosoo-claude-code ENV MOSOO_ACP_FALLBACK_COMMAND=opencode diff --git a/README.md b/README.md index 3f7dd3a..48a62bc 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ import { expect, test } from "bun:test"; import { createCmaMemoryStore } from "@mosoo/agent-driver"; import { createCmaHttpHandler } from "@mosoo/agent-driver/cma-http"; -import { createCmaSdkClient } from "@mosoo/agent-driver/cma-sdk"; +import { CmaSdkClient } from "@mosoo/agent-driver/cma-sdk"; test("create an agent, environment, and session over the CMA surface", async () => { // 1. An in-memory store stands in for the host's persistence port. @@ -119,7 +119,7 @@ test("create an agent, environment, and session over the CMA surface", async () // 3. The client talks to the handler directly through fetch — point // baseUrl at a server that explicitly embeds this preview. The default beta header // (anthropic-beta: managed-agents-2026-04-01) is sent automatically. - const client = createCmaSdkClient({ + const client = new CmaSdkClient({ baseUrl: "https://driver.local", fetch: async (input, init) => handler(new Request(input, init)), }); @@ -158,9 +158,9 @@ vp run check vp run clean ``` -`vp run build:image` uses Buildah to produce a local `agent-driver:local` OCI image and installs `dist/driver.mjs` on the image `PATH` as `agent-driver`. +`vp run build:image` uses Buildah to produce a local linux/amd64 `agent-driver:local` OCI image and installs `dist/driver.mjs` on the image `PATH` as `agent-driver`. -The image contract in `environment-package-managers.json` exposes `npm` and `pip` to Mosoo Environment writes. The image build verifies that each tool is executable, reports a valid version, and resolves through coherent Python/pip aliases. `vp run docker:smoke:environment` installs and executes one pinned package through each manager using the same isolated-prefix mode as Mosoo Environment artifacts. +The image contract in `environment-package-managers.json` exposes `npm` and `pip` to Mosoo Environment writes. The image build verifies that each tool is executable, reports a valid version, and resolves through coherent Python/pip aliases. `vp run test:image:environment` installs and executes one pinned package through each manager using the same isolated-prefix mode as Mosoo Environment artifacts. ## Boundaries @@ -176,11 +176,21 @@ The image contract in `environment-package-managers.json` exposes `npm` and `pip - `vp run check` - `vp run build:image` -- `vp run docker:smoke:environment` +- `vp run test:image:environment` - no `@mosoo/*` runtime dependencies in `package.json` - public entries include typed exports - live artifact tests are gated by environment credentials +## OpenAI Credential Boundary + +Each OpenAI app-server process receives a private temporary `CODEX_HOME` that is deleted only after its supervised process tree stops. + +OpenAI persistence in the session home is limited to native rollout, memory, and SQLite state. + +It must never contain `auth.json` or be used as a credential archive. + +The Driver fails closed when it finds legacy credentials there and accepts OpenAI API-key auth only from the current execution environment. + ## Artifact Live Tests Every live test launches `dist/driver.mjs` as a child process and talks to it only through the production boot payload and control protocol. @@ -215,7 +225,7 @@ Protocol-only races such as ACP load replay barriers, burst updates, and event-d - `vp run test:live:opencode` runs all configured OpenCode compatibility models plus one representative lifecycle model. - `vp run test:live:artifact` tests the artifact path supplied by `AGENT_DRIVER_LIVE_ARTIFACT` without rebuilding it. -The release workflow extracts the packed NPM archive to `packed/` and blocks image and package publication unless `packed/dist/driver.mjs` passes the complete matrix. +The release workflow extracts the packed NPM archive to `packed/`, verifies its declarations, runs the provider-free MCP artifact test, and blocks image and package publication until the same `packed/dist/driver.mjs` passes the complete OpenAI, Claude, and OpenCode live matrix. ## License diff --git a/bench/ttft-bench.ts b/bench/ttft-bench.ts index 144581e..b8ecaed 100644 --- a/bench/ttft-bench.ts +++ b/bench/ttft-bench.ts @@ -31,10 +31,8 @@ import { fileURLToPath } from "node:url"; import { AgentDriverKernelCore } from "../src/core/agent-driver-kernel"; import type { PermissionDecision } from "../src/core/driver-permission-broker"; import type { DriverEventInput } from "../src/protocol/events"; -import { createDriverHostIntegrationSnapshotFromBootExecution } from "../src/protocol/host-integration"; import type { DriverStartInput } from "../src/protocol/start"; import { AGENT_DRIVER_PROVIDER_REGISTRY } from "../src/runtimes/provider-registry"; -import { driverBootPayload } from "../tests/driver-boot-payload-fixture"; import { DRIVER_TEST_IDS, bootPayload } from "../tests/driver-runtime-boundary-fixtures"; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -176,6 +174,11 @@ function claudeStartInput(a: StartInputArgs): DriverStartInput { session: { ...bootPayload.execution.session, additionalDirectories: [], + context: { + ...bootPayload.execution.session.context, + homePath: a.homePath, + sessionOrganizationPath: a.sharedRootPath, + }, cwd: a.cwd, homePath: a.homePath, mcpServers: [], @@ -204,6 +207,11 @@ function openaiStartInput(a: StartInputArgs): DriverStartInput { session: { ...bootPayload.execution.session, additionalDirectories: [], + context: { + ...bootPayload.execution.session.context, + homePath: a.homePath, + sessionOrganizationPath: a.sharedRootPath, + }, cwd: a.cwd, homePath: a.homePath, mcpServers: [], @@ -250,6 +258,11 @@ function opencodeStartInput( session: { ...bootPayload.execution.session, additionalDirectories: [], + context: { + ...bootPayload.execution.session.context, + homePath: a.homePath, + sessionOrganizationPath: a.sharedRootPath, + }, cwd: a.cwd, homePath: a.homePath, mcpServers: [], @@ -265,34 +278,12 @@ function opencodeStartInput( }; } -function opencodeHostSnapshot(paths: { cwd: string; homePath: string; sharedRootPath: string }) { - return createDriverHostIntegrationSnapshotFromBootExecution({ - ...driverBootPayload.execution, - profilePrompt: "", - session: { - ...driverBootPayload.execution.session, - additionalDirectories: [], - context: { - ...driverBootPayload.execution.session.context, - homePath: paths.homePath, - sessionOrganizationPath: paths.sharedRootPath, - }, - cwd: paths.cwd, - mcpServers: [], - nativeResumeRef: null, - }, - skillCatalog: [], - skills: [], - }); -} - async function runTrial(input: { runtime: RuntimeId; scenario: Scenario; startInput: (paths: StartInputArgs) => DriverStartInput; apiKey: string; model: string; - isOpenCode: boolean; }): Promise { const paths = await makePaths(`ttft-${input.runtime}-`); const args: StartInputArgs = { @@ -303,13 +294,11 @@ async function runTrial(input: { model: input.model, systemPrompt: input.scenario.systemPrompt, }; - const hostSnapshot = input.isOpenCode ? opencodeHostSnapshot(paths) : null; const kernel = new AgentDriverKernelCore({ backendFactory: (i) => AGENT_DRIVER_PROVIDER_REGISTRY.createBackend(i), hostPorts: { permission: { request: async () => input.scenario.permission }, skill: { materialize: async () => [] }, - ...(hostSnapshot === null ? {} : { hostIntegration: { snapshot: async () => hostSnapshot } }), }, }); const events = kernel.events(); @@ -500,15 +489,12 @@ async function main(): Promise { model: string, build: (paths: StartInputArgs) => DriverStartInput, apiKey: string, - isOpenCode: boolean, ): Promise => { process.stdout.write(`\n[${runtime}/${scenario.id}] model=${model} warmup...`); - await runTrial({ runtime, scenario, startInput: build, apiKey, model, isOpenCode }).catch( - () => undefined, - ); + await runTrial({ runtime, scenario, startInput: build, apiKey, model }).catch(() => undefined); const results: TrialMetrics[] = []; for (let i = 0; i < trials; i += 1) { - const m = await runTrial({ runtime, scenario, startInput: build, apiKey, model, isOpenCode }); + const m = await runTrial({ runtime, scenario, startInput: build, apiKey, model }); results.push(m); process.stdout.write( ` t${i + 1}=${m.ok ? "ok" : "FAIL"}(ttft=${m.ttftMs ?? "-"},total=${m.totalMs ?? "-"})`, @@ -519,17 +505,10 @@ async function main(): Promise { for (const scenario of SCENARIOS.filter((s) => scenarioFilter.has(s.id))) { if (requested.includes("claude") && anthropicKey) { - await runCell( - "claude", - scenario, - claudeModel, - (p) => claudeStartInput(p), - anthropicKey, - false, - ); + await runCell("claude", scenario, claudeModel, (p) => claudeStartInput(p), anthropicKey); } if (requested.includes("openai") && openaiKey) { - await runCell("openai", scenario, openaiModel, (p) => openaiStartInput(p), openaiKey, false); + await runCell("openai", scenario, openaiModel, (p) => openaiStartInput(p), openaiKey); } if (requested.includes("opencode")) { const key = opencodeProvider === "anthropic" ? anthropicKey : openaiKey; @@ -546,7 +525,6 @@ async function main(): Promise { model, (p) => opencodeStartInput(p, opencodeProvider, apiKeyEnv), key, - true, ); } } diff --git a/bun.lock b/bun.lock index 035bb32..d646d9d 100644 --- a/bun.lock +++ b/bun.lock @@ -1,51 +1,51 @@ { - "lockfileVersion": 1, + "lockfileVersion": 2, "configVersion": 1, "workspaces": { "": { "name": "@mosoo/agent-driver", "dependencies": { - "@agentclientprotocol/sdk": "1.2.1", - "@anthropic-ai/claude-agent-sdk": "0.3.211", - "@anthropic-ai/sdk": "0.111.0", - "@modelcontextprotocol/client": "^2.0.0-alpha.2", - "@orpc/client": "^1.14.3", + "@agentclientprotocol/sdk": "1.4.0", + "@anthropic-ai/claude-agent-sdk": "0.3.257", + "@anthropic-ai/sdk": "0.123.0", + "@modelcontextprotocol/client": "^2.0.0", + "@orpc/client": "^1.15.0", "fflate": "^0.8.3", - "vestig": "^0.23.0", - "zod": "^4.4.3", + "vestig": "^0.24.1", + "zod": "4.5.4", }, "devDependencies": { - "@openai/codex-sdk": "0.144.5", - "@types/bun": "1.3.14", - "@types/node": "^25.8.0", - "opencode-ai": "1.18.4", - "typescript": "^6.0.3", - "vite-plus": "0.2.5", + "@openai/codex": "0.152.0", + "@types/bun": "1.4.0", + "@types/node": "^26.3.0", + "opencode-ai": "1.18.25", + "typescript": "^7.0.2", + "vite-plus": "0.3.0", }, }, }, "packages": { - "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@1.2.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-jwYUdOQR7tc+Zfch53VL4JJyUNK/46q03uUTYb+PjECsmnNl94XFXOfYLJ8RBpMNidXd1rpOAVgb0vqD98xImA=="], + "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@1.4.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-/eufudw+aFY1LKLolT6yFE6UMmYRl7fMJ/DEONSIyR6wI3slHWITBsANRGqXEY8FRzqUxwh7QEaGiZHcJPVThg=="], - "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.211", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.211", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.211", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.211", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.211", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.211", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.211", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.211", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.211" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-JhbLu6o1v2g9fjqkO+LDNPWrE0bgd9UeRQQ41JBGouAgows3KyPPYgU2WU0q7M2onuwQxR5plGDpas01F+oaUA=="], + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.257", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.257", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.257", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.257", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.257", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.257", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.257", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.257", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.257" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-Se55zXv48IYLg/WzoXzpbPLcq86suwDSbRUoNb69l4dkovorqS/47Xuy7MUo/gPNwwcPB4a+aqbXbshU33dcdQ=="], - "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.211", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Iwhm4kfcs20LdXffZ2RGRjj+BFdUOrT/JjhGtICjlGlBPlrLkkkAiHtGzqO9K36v5B/kSIHwOw9CM836kYYPHQ=="], + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.257", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ITjFPYB8riu9tbxbrWArokiZ/90w/NDrYbtEvyr3ScilVtu1iupkComxkhvbUxoyT4JRDpKsdw/ZOfwSl/pkpA=="], - "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.211", "", { "os": "darwin", "cpu": "x64" }, "sha512-sMBW1CLe2Hq4PwwvEbz9r8LxF84UErgB45TSf+iEa8M/EjYZCsXSoTRT0vKnN4TvrAN5mWoKgi39dkUxS9ybsA=="], + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.257", "", { "os": "darwin", "cpu": "x64" }, "sha512-0s7QoLRnopbMvCqVpgCnvBWg4UNxPUybMZTknkn3HLBMvUjUdHs1QXb77c/yrT1WAPqDTw1Ju+H0esXwWa8/Kg=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.211", "", { "os": "linux", "cpu": "arm64" }, "sha512-orZm8p+BzVRZ7I8c5yD43hEZ5TvBZ+UbKTZTlvID00Y8HSn3M3rNX3sW4RUvNGF2e0eNMaXKysPyEHPEj3kqpg=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.257", "", { "os": "linux", "cpu": "arm64" }, "sha512-38tv1s1CaIE6CyEBmJpfKtvwPzrasxPiRT079BEs5aaiLPQ+pmSGF0+Lwy7ot9i6xqr7v5/91TVo5JXHI8Pkpg=="], - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.211", "", { "os": "linux", "cpu": "arm64" }, "sha512-X1eg+lCwNH2VXyqLQR722dsDDtWPfUnz7OtnmPVoNMxcMexDlSLMMuvm5fNNi94kYT0pxmBdhIvudew145JuHg=="], + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.257", "", { "os": "linux", "cpu": "arm64" }, "sha512-t2WHfKjY4Jjgzfk0lbBUt6EVPQe+wXvXvRmYnK7ICfzZ1jS8CrpDajwBQErKFSMiUqGAutZy3vwDwGziW32cpw=="], - "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.211", "", { "os": "linux", "cpu": "x64" }, "sha512-ohDS5EGKQvKiUUMtDNPjyWUDvaeIa+DlzUVjrZ8Y4hPtoWFpvOBtOFIYChJGpllwZ4YULS4H3gywVnLGB4do6Q=="], + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.257", "", { "os": "linux", "cpu": "x64" }, "sha512-0FRyIwV4jEJErdDDoYMC0v9lzuFNz5y0lK2340H5fP02eNXsP3U0htKW/bfRP8Ppei+xc4QUZgdCI6rVzkhXGg=="], - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.211", "", { "os": "linux", "cpu": "x64" }, "sha512-cR12YFMVGSj38074OYkkjgwhYeblgVRK3Uw7ZXw3ZTOevjQLASPajVruFuDRW07ixxTa1ZWxqI3kSsIl71RXYA=="], + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.257", "", { "os": "linux", "cpu": "x64" }, "sha512-qSlgAUEpj2JAA+YqMD222iv2W3x8xBDSYwdoaOx20VbaJRjFq8feViI7kcr1C6wVN+ApX4Rxl7YX6gekiBkA2g=="], - "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.211", "", { "os": "win32", "cpu": "arm64" }, "sha512-AOIQRFO0YMDUCrG8W0NUWitBQQUB6fYdNW3SMcPPq3mXpYC/pCNURFyvRdrFm729xXkxDAP5OMLk9x2/IFrTmA=="], + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.257", "", { "os": "win32", "cpu": "arm64" }, "sha512-7iEwy14lSqaAWvNR3KucBusQs5+hG6uoliYWZ2M2FyaCk5PHYymVf9mrRPtWhaYfNwum/YY13acYIIvnAbz0kA=="], - "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.211", "", { "os": "win32", "cpu": "x64" }, "sha512-pwzNuJg2xRBsv3kSSVVhgLdGAFxd5DqPkQX5ZLrT4uBZDJ+QM4xWKZyN8ZoFLLzNl+u0/4U83Q1ZQ0NdG/9JsQ=="], + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.257", "", { "os": "win32", "cpu": "x64" }, "sha512-NW0zMjHXFBdu2TcjT9Zo5o/1tJaDy6A7v4Gt/vLVJgaipV/RzAbyRGXZzH37hswyiKrSLGaSoYYP1vUfncbkUQ=="], - "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.111.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-1hUqKi+uJQoS5X90+InwHbFAXMvgq0DnsC5hVLEeSRaODiU5WvmqDAcVCmGS2wC0pN9Z8jtWCbWw7JLzeDdm/Q=="], + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.123.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-Y9oX9mPNGZClHQOFqrWRk43Srcu/UHuPq3rfxxOq7JgW0gi+lJA2MAOK4Ul3k/+AUrwRWFJvd0tK3oC0Pw25dw=="], "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], @@ -55,175 +55,165 @@ "@blazediff/core": ["@blazediff/core@1.9.1", "", {}, "sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA=="], - "@cfworker/json-schema": ["@cfworker/json-schema@4.1.1", "", {}, "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og=="], - - "@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="], - - "@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="], - - "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], - - "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + "@hono/node-server": ["@hono/node-server@2.1.1", "", { "peerDependencies": { "hono": "^4" } }, "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg=="], "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - "@modelcontextprotocol/client": ["@modelcontextprotocol/client@2.0.0-alpha.2", "", { "dependencies": { "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "jose": "^6.1.3", "pkce-challenge": "^5.0.0", "zod": "^4.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-FxlR5QyBPeCDEDPH2Kx20uygmuy9k2jh6ahUeEYtmVfUxboZZlUEUhn6w0XxnbxpkELcT1qyzTXC8Bqh3c8QUA=="], + "@modelcontextprotocol/client": ["@modelcontextprotocol/client@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "jose": "^6.1.3", "pkce-challenge": "^5.0.0", "zod": "^4.2.0" } }, "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.0.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA=="], - "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.6", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="], - "@openai/codex": ["@openai/codex@0.144.5", "", { "optionalDependencies": { "@openai/codex-darwin-arm64": "npm:@openai/codex@0.144.5-darwin-arm64", "@openai/codex-darwin-x64": "npm:@openai/codex@0.144.5-darwin-x64", "@openai/codex-linux-arm64": "npm:@openai/codex@0.144.5-linux-arm64", "@openai/codex-linux-x64": "npm:@openai/codex@0.144.5-linux-x64", "@openai/codex-win32-arm64": "npm:@openai/codex@0.144.5-win32-arm64", "@openai/codex-win32-x64": "npm:@openai/codex@0.144.5-win32-x64" }, "bin": { "codex": "bin/codex.js" } }, "sha512-jjB+K+OMv572mKhS+2QuLxWXDJNdpwbPenf+V+8bdq7wg4Scqt3cn6WEekD8wPqDVZqck0HSX17K9rD9kbDJQA=="], + "@openai/codex": ["@openai/codex@0.152.0", "", { "optionalDependencies": { "@openai/codex-darwin-arm64": "npm:@openai/codex@0.152.0-darwin-arm64", "@openai/codex-darwin-x64": "npm:@openai/codex@0.152.0-darwin-x64", "@openai/codex-linux-arm64": "npm:@openai/codex@0.152.0-linux-arm64", "@openai/codex-linux-x64": "npm:@openai/codex@0.152.0-linux-x64", "@openai/codex-win32-arm64": "npm:@openai/codex@0.152.0-win32-arm64", "@openai/codex-win32-x64": "npm:@openai/codex@0.152.0-win32-x64" }, "bin": { "codex": "bin/codex.js" } }, "sha512-Vx0tg/J5SbxYYGJazTtL/XySK9Dlqc5KW1MZM71NMwVci/4F1ap+FfSKPFTlrICEtOTuq3KNcWSdv9oMGdPuRw=="], - "@openai/codex-darwin-arm64": ["@openai/codex@0.144.5-darwin-arm64", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zcT6NfBCqLFt+BReNSETTZW6v6PdbH0dzNtm9j7l7mDGqwPbKZDGJdnpkBao2389I0ZacyIKgSZoI0vez1d4Dw=="], + "@openai/codex-darwin-arm64": ["@openai/codex@0.152.0-darwin-arm64", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DOnDA6EKOs+aRytYH4ffIuUutok9ovpvCNax8aaTZvBB2bKUPjUleLrP1pk+hGarddqU1ySuvXMOfp6ufXyOVQ=="], - "@openai/codex-darwin-x64": ["@openai/codex@0.144.5-darwin-x64", "", { "os": "darwin", "cpu": "x64" }, "sha512-//Mo0m1MwaoT6psu5xsmofXpKx4/0irIkeq10xJvk59+886EG355ibjA+ZmlRcKhE3bLjsKD7p81nTbAdRL/bw=="], + "@openai/codex-darwin-x64": ["@openai/codex@0.152.0-darwin-x64", "", { "os": "darwin", "cpu": "x64" }, "sha512-p2XLFWU+Lflke89zNK67ad2/yaFndUIZkyk+gjl35NCDz+LncvAUOeiTFB2MtmC2kxvudcYo6/yezQqwM29Gnw=="], - "@openai/codex-linux-arm64": ["@openai/codex@0.144.5-linux-arm64", "", { "os": "linux", "cpu": "arm64" }, "sha512-zAHggxVwR2TBxKmybXY7ZMiB0G8DMonY2YPdwNNjwXcf+LOIqNGgswwNCDMbP/HEe6r8j+R9ZX/yYoo8f+n/RQ=="], + "@openai/codex-linux-arm64": ["@openai/codex@0.152.0-linux-arm64", "", { "os": "linux", "cpu": "arm64" }, "sha512-OTmO6y5gCpcjzybwzgK/nSM8VI68Pw+T2ohBK+8cwVM+7PCRqSqtNo7qTLG0bgv9ff6QtwiZ9sl0KtY42H0Wqg=="], - "@openai/codex-linux-x64": ["@openai/codex@0.144.5-linux-x64", "", { "os": "linux", "cpu": "x64" }, "sha512-FalLJlBQGFdK8Gc3kj9sa/ekNdgkHhUawLaKkvy5CtB18JaP2YxtTP/Pe1pD2iBiq8mMUliRnafpF6AdBdQMbg=="], + "@openai/codex-linux-x64": ["@openai/codex@0.152.0-linux-x64", "", { "os": "linux", "cpu": "x64" }, "sha512-Isn/g5EZTaNbwZtaIuz67U1FNDEVVeMpS4xiR+c1dpOPL2xxrGNtdAothOW2YKsb96OsS+QfClziFb4qxesWDA=="], - "@openai/codex-sdk": ["@openai/codex-sdk@0.144.5", "", { "dependencies": { "@openai/codex": "0.144.5" } }, "sha512-90wHPEGyk74On6gwQPNtw+wzuDJ2zYpiVADDxw43S1cWn3QbBh/21zFS2xlAWWCrdY0gE90yXfmmrzwdUDlBGw=="], + "@openai/codex-win32-arm64": ["@openai/codex@0.152.0-win32-arm64", "", { "os": "win32", "cpu": "arm64" }, "sha512-1aMFMFvhSru5IT3hRstBjTmLPkhi+FKlu05bKKsKY+Pmx3a0sZBiK8WYFkROUynsM6ZVgNApfDv8zVUbX5crLA=="], - "@openai/codex-win32-arm64": ["@openai/codex@0.144.5-win32-arm64", "", { "os": "win32", "cpu": "arm64" }, "sha512-0Pj7iqjEOEvPQPO3kFfCy9vGX4BTu76ChFFZHr2eNNIfVc3FOENAv/X98u4L+iIUtDOK9DbqmfUudW3DPapshg=="], + "@openai/codex-win32-x64": ["@openai/codex@0.152.0-win32-x64", "", { "os": "win32", "cpu": "x64" }, "sha512-vy67ua+QeOHKqT5ovLOkINncuaknldUkOfwcGdxQwPbSxgnCoiHnAO/rieWQt3ncM2pRzygtPVoLOFiovcRlaQ=="], - "@openai/codex-win32-x64": ["@openai/codex@0.144.5-win32-x64", "", { "os": "win32", "cpu": "x64" }, "sha512-DnsSTlnnzleTxvLwIGnBitKInscxn2I7qASqosS8Fv+qysBygd+ZiBn/SQsRCgQ28PAlsNzmd3Gf3ZTecolAmg=="], + "@orpc/client": ["@orpc/client@1.15.0", "", { "dependencies": { "@orpc/shared": "1.15.0", "@orpc/standard-server": "1.15.0", "@orpc/standard-server-fetch": "1.15.0", "@orpc/standard-server-peer": "1.15.0" } }, "sha512-Qt0FdPSGySdQwy83iUWOq+Iqhw2gM9qHtyxfBbcw1mwOz1s4O2BwUFA3ymVTLIRRNYRgPrQLaBZhp8CacqeePg=="], - "@orpc/client": ["@orpc/client@1.14.6", "", { "dependencies": { "@orpc/shared": "1.14.6", "@orpc/standard-server": "1.14.6", "@orpc/standard-server-fetch": "1.14.6", "@orpc/standard-server-peer": "1.14.6" } }, "sha512-Y03NcTtmEJdxcqkKBkdGxqe1IHVpD9IorshG4PaTnz9dQIW+RYI8anRo7o0IlbBlzBICN+Ubo1rnw6bpkhagCQ=="], + "@orpc/shared": ["@orpc/shared@1.15.0", "", { "dependencies": { "radash": "^12.1.1", "type-fest": "^5.4.4" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-A3/JE7pQYSrrRm6/WYJxV3GBhpMdJRPM3h47slQtWBUZe9Sao5En5WBc4tISGQNP2emcez6qIvDziSgD2T+img=="], - "@orpc/shared": ["@orpc/shared@1.14.6", "", { "dependencies": { "radash": "^12.1.1", "type-fest": "^5.4.4" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-P2W+DdrUq18kUiF7nIw5wDOA0SR41mM/NsVKDVRsdyhdHk9V9KDuW1JRymyMl+7Wo5SDeSr1Rm/VjA5v08+PHw=="], + "@orpc/standard-server": ["@orpc/standard-server@1.15.0", "", { "dependencies": { "@orpc/shared": "1.15.0" } }, "sha512-bje/xn6thDqJY/JQ7xoOjmD1KWE4FsyZOPgnPxwqTZLx/r00stRhLuFvk1hDEGj9UlG7NLZEijDVx0wqvcyDzA=="], - "@orpc/standard-server": ["@orpc/standard-server@1.14.6", "", { "dependencies": { "@orpc/shared": "1.14.6" } }, "sha512-75Oh4rAZb8K7P46d6v7R2JhjqjLLEo7Qs4+ABdF+f0m0uKM1oaykJWy7leqTJ+WaYm+uDbsZl/3nyWie9aeJTg=="], + "@orpc/standard-server-fetch": ["@orpc/standard-server-fetch@1.15.0", "", { "dependencies": { "@orpc/shared": "1.15.0", "@orpc/standard-server": "1.15.0" } }, "sha512-XYVfgmIt71YrPJSI7RKRiNWjcyktPYHpevkjeohGNBP5aSMAUL95quZArAWr+XOPp4U56XRao54I6wFMZa4How=="], - "@orpc/standard-server-fetch": ["@orpc/standard-server-fetch@1.14.6", "", { "dependencies": { "@orpc/shared": "1.14.6", "@orpc/standard-server": "1.14.6" } }, "sha512-XnwEnHaKMMBoilK0QVwgMsUvDbCXyz6CDoLgMN51v+p3VSnwjIkrMdOwbc/sj43z6T4irQDu9Zm8T1pxxh1L8w=="], + "@orpc/standard-server-peer": ["@orpc/standard-server-peer@1.15.0", "", { "dependencies": { "@orpc/shared": "1.15.0", "@orpc/standard-server": "1.15.0" } }, "sha512-fsTN+FrdPkseVx99yRenGJYSp0xOrEk9fODk8oh3zzricNYzlZ8GGgCYtDNgt9vFIR/J05dc+Nk76KhcPBQpLw=="], - "@orpc/standard-server-peer": ["@orpc/standard-server-peer@1.14.6", "", { "dependencies": { "@orpc/shared": "1.14.6", "@orpc/standard-server": "1.14.6" } }, "sha512-jwVGc5yRA5hk1F/W4yw45P2H4fbu4Tfsk62XijJBXRvtlSNKvvPAuwAd6jFv/fxnq2SH/uskgi91Ja9wgfnYDQ=="], + "@oxc-project/runtime": ["@oxc-project/runtime@0.146.0", "", {}, "sha512-lbXHIpZ1MmK6zuw5txlMdIZ2waLVUIU5Gnm3sEuwJOiqDfQfbtjeHscatmeBoxbv8+If9LFM6PGh/3DcDWYIYw=="], - "@oxc-project/runtime": ["@oxc-project/runtime@0.139.0", "", {}, "sha512-WnuGdceWtRdqD7f3alOIDXN6bnGuGtFjtQf/dHjzgn2im7eKaYRJTEl2T1kFEWPhBWCDk+UDYgsTLUE5L6jc0w=="], + "@oxc-project/types": ["@oxc-project/types@0.146.0", "", {}, "sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA=="], - "@oxc-project/types": ["@oxc-project/types@0.139.0", "", {}, "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.64.0", "", { "os": "android", "cpu": "arm" }, "sha512-o6uzh/jTOQeAY5TdkAeXdqv7MBRcPxiRA08zrcBtkKj5cSu/FMu0Hl7Q6Fi1KCKyCWZ6lJVjBzdsJvsKltUsGQ=="], - "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.58.0", "", { "os": "android", "cpu": "arm" }, "sha512-Uz62sHduGGPftXtILGyxdSW4PX82rUg+rfdNqhsgxe881g4rIoXlIqmZQ6HVKcF4f+F8qMhdD03Bx5u7gmeTdg=="], + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.64.0", "", { "os": "android", "cpu": "arm64" }, "sha512-jRGSUeeP7p3Gynw2YaCVtjBIA6ZxY6bEB/ES5i54OhqmRTyuVg7ZgstEtzgq6GOAJd+2QZ5pvf+bFfmW5Mp9cw=="], - "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.58.0", "", { "os": "android", "cpu": "arm64" }, "sha512-rD0lRaJp1b+9vw6X4A2dJWKukd6X8yxiicN4JxXcXayolmUypRZxk+lKR+fVOu5q/iYc0fh5fR4bgmfOfVlbaA=="], + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.64.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JINwtU2lW7nOFSqi+H2qplipNUqah9Gc1jgGmB82kTD4UnZrZIVxCJ9qEmFiKfjNq27gYLFhrUb0to86aCwMjw=="], - "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.58.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-uzbPPk7O6M+w2K65vcQ1woga3wgP8zghjL1KOG5b6qJ8dvYHZJ1VShaslg2KOK6yQIwCQtcMCXqLBM6sqXUNTg=="], + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.64.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-gCmuswrgrOSajV4HCRFkVCGIruPq8bjYuPYgSE2WQB3mD6XrdyZ3JMSRZCkQ8zCxOyGWriBo6QoZ5nmMHQ1BfA=="], - "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.58.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-L0nKYDxU32oxeQqJj21W9SlIMnf81VZEhyah6iDvFhf5q0oynq498Fopth7blErUJVBpVtxQ98RMCfMPqpJX6w=="], + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.64.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Ab8g7a38pT0MMImjh7anRSTve6buWBIlcXIFBYa5xl4s6UxEgKSc2xOOhbGtLwvXnEi2PsEDGoJh3oUU7xkehQ=="], - "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.58.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-woNwfD58dC5PGS9LSLSD5JYfo/EFK5iG9vhDWkcCg3q78ag7KC8bpDqgvPHrMoXpx83OLXxoSOhu6z8FsVTHlg=="], + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.64.0", "", { "os": "linux", "cpu": "arm" }, "sha512-BgvS3CoQ+Xy2deoZqEN8JVKabcCZi2RxA3yant8G9OAv9KuPJ9TCjHkqigzdHUVwErZxEP5d2bzLIEyKYyBDLg=="], - "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.58.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Sqs8nMLxuQpY21NKJ1u4stPDmO5hskBCNNh2E3AdCfI1QqWtf4m+Qn4mGEIUO4KGmuq3SWc/SZ80uy5IiwTCDw=="], + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.64.0", "", { "os": "linux", "cpu": "arm" }, "sha512-QXpNxwoMj0YvnceCNZadNSden3bIcnvjn/sDp/rwZhRoZoZYGpHvtPyhGsdJz9uvT9GkaMW7SsLddurU56dt8w=="], - "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.58.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Vd4exzBI5B5hB9m22JiTQzIL23WvHo/Pe+sNXPNeBLXSP9swCBPKCEBRwKpmpQzYhlgYaCgfPcGXPKAJBRIiZQ=="], + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.64.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-BBgH3I1ppDsI5pZ4Pdhw0ceYxwVCfbU/bZEBCeZ6caRS9x0ZabErxubP7riGUn11PXZBhe8DYdjkDKP1FlVQ5w=="], - "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.58.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-bUWi5mHV+4Vi56RLHE1h6q/HHfwAIT3XoB9vJAVeRzfu5NriXM8y6eeJu0vlKa0C9kq2rq1sOWRClhdLHPocrg=="], + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.64.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-v19HSjC/BGXdt26qEvKZtwAHgGmQ2Agcap2kQP+KIqoRZqivVzYth3ui2dJA1i+6/fjpjga85lIOaJJjQ/bOOw=="], - "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.58.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-2ZHxemzgHcjtktAuVUwSoyXmGo/t+aF5tS1ciPpPei4rhSyrz3JOqDosXXrmhN/yLUSzJjtuW7ToTWqfQpCj2w=="], + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.64.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-PElLnOo4xFTBZrxPhgTIj0eHqZXwEBQoNWtb7facUV170T0B0FRET0iNbb3LUeLWTybkUW+vsdyv4ihOdyXGyw=="], - "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.58.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-AwKkVwjVmFQ3bcO7j0McGYAqCKH2a326fswfofng/E8VewCT/raeeGQr4huVhY704deK8AWASSTlxzMj0eZc6Q=="], + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.64.0", "", { "os": "linux", "cpu": "none" }, "sha512-Qzsg15n4F5CH+MorcRW4MkAEMiLzXmeG+DiDSbP/bBTqCmWOH3K9DHryNrve+JHlV0txS+B6Z9P5Xz+cmWeL+g=="], - "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.58.0", "", { "os": "linux", "cpu": "none" }, "sha512-xsRpTxfUnJF8D3AUKko/qyWdjw4GZVHlCVFuGlzSCTeewLmykKINW8em1+wx+axsDVtJJcMtvsiaXggXxrlHgw=="], + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.64.0", "", { "os": "linux", "cpu": "none" }, "sha512-/GZ358wnQ/Ez4UVnCcZIi56JkY0sOdZ+B108pqXKqZz3jLS59F4KEAB1Qv3fRlObrFEk+3L2vUQ/xoPx+3vjXw=="], - "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.58.0", "", { "os": "linux", "cpu": "none" }, "sha512-Z4AYOTcy7nYEIiXwD62PlerimyYRcfJOgUbQAEBjXz098kxKuERBlRntofGy69HHhe9E0TLVNMl1yspVNu+efw=="], + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.64.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-/C9We3DXegowfLXtVCYHeNiU9azwCDr5cQkEtCVlc74vyn+lLQSPApJ1CZmxAduqeq/Oi3gQ+IVptyhCaTMtkQ=="], - "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.58.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-A3nhhtZPC/TKVWOPj9q/H3p2znJDCcHWYlJBhWL8hGq/bFmBaNBHC8Np6E581yVq1w9Mi3rMDNzDalWvtUfJtQ=="], + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.64.0", "", { "os": "linux", "cpu": "x64" }, "sha512-91KM2CeRWscIEHlj1NsW2WSnzGeq1Ehq+39bfDowTdkn+fcvK/x4Y1RcyqT7glyBjZio0ldkeCG6Usj3v7ASog=="], - "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.58.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2g+tVkgwqphw8R4hgo+kF4oz8+P5RwVOtr9+irsC7uwEp0e9j7Crw8kDGKL20uYlLPD7g02DqA61mC/UNYx98A=="], + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.64.0", "", { "os": "linux", "cpu": "x64" }, "sha512-gw7uEk9I+7zoT1EYLra1eWArIzNcz8e3jkv+Noo2+o2T7wPvsNSQbfoa4DSfZlvn1i6mJ05RiZ4/omaXPDNhQg=="], - "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.58.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rc15P6AbyyB7426aN8AakLd02Trb3a6ML/mmfAQeVHJEfVofWLcWIrBdy6zDEY+DIaL/s8E4GGPboVw+oP3+EA=="], + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.64.0", "", { "os": "none", "cpu": "arm64" }, "sha512-HYHFf616FHSPSO07c09mjmXBfQ73wIVM3m0txOiooa5XZkGoxFd6B14PVj0LB0DXIqJ6wAO/dDR/NX/5UUaqnw=="], - "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.58.0", "", { "os": "none", "cpu": "arm64" }, "sha512-ZWoTM27/HYPOh9iq86DAbhPu9nXb8qKvvGU/h8OfliyVUFAMMNTLDkGsWDKKnDqIkqvZ9+dXlgUOsH1LYO3O7g=="], + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.64.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-uQjFp081IZSWD6VAofX2iO2z01awAdHmfC+NrieWIPKrT2hZKQDyq/U18M7ifC0sm0Wz8aHY/p6+FDYIzs/CrQ=="], - "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.58.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-LHZnqFXe2dEfkRI4XdZS/57nEOT/I4UCRX5IyM9v4GYW9XwQCjGe1IUK59SuKw3POwvcgWQ4pme2cYXmNqTNPg=="], + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.64.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-lNM6byTAQ881jugzFu8juJTbNRgsUTlswMA6pJmwi1XDvmIqnnb49lcUAs5gz94fCJLrVN+/X3s3jOKqx23WIQ=="], - "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.58.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-mZKpg20TpheCJym1rarcZCUJeW1sSruw8zAAaCYWvuVfwIUDN1CXdrPU/JgCWReXTCTrEfCB8Wyo3hh9jSZ2EA=="], + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.64.0", "", { "os": "win32", "cpu": "x64" }, "sha512-BtmbtL/QjMtF1a6C3CqoDluH2IfB6fJt62E+B9RFfUPtFk4Iz9PFS6+y/SzzOvSxc7aUk2Kphwg7Dh8lMbwu6g=="], - "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.58.0", "", { "os": "win32", "cpu": "x64" }, "sha512-N/wUU4N5PZ2orBtI+Ko7MnMfYLfE7K91UrGMY/c/pYyHR3lA9kwst1XugkZx+92YcRh/Eo+iv2eTESSWXfiZPA=="], + "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@7.0.2001", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CUJEdbSZ54+Xy9OXqOhWLTKZKV0BBiV7C2i/ygyVmXtkUNXx5YCzN8DpSSshTAKktoL7S+tnQ/ftFG/i7X896w=="], - "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@0.24.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-C2uMmwK5Bc4ri4ysZ6sA8Rcu+A5zBQTp6ml2u0CLLbRZp4kMFPV3yWk8B5DK9Aw7y9bbjogIm75tUwGLFzlsYQ=="], + "@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@7.0.2001", "", { "os": "darwin", "cpu": "x64" }, "sha512-pXfBb5BqONCcgrXQNUZWXgiYmRSWJzd97S8i41VVOh6ut0tyo+cJ5FKFpczDHxiVNfj/3e7c9B4MtztNdpIVCw=="], - "@oxlint-tsgolint/darwin-x64": ["@oxlint-tsgolint/darwin-x64@0.24.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Wgvt/1lRbDxmoNqWQKKcL+UIiqLmdJ+EWLpQa1qzoNVAfNB0PJpa82/8dH1twT/3rSs4zrP5TXPWl4juB71WuQ=="], + "@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@7.0.2001", "", { "os": "linux", "cpu": "arm64" }, "sha512-roP7zujb/QDPzDwEKsFFpzNHHy91/Y7oX9vQXk78ekyZtcQj1QXDIMH33gjDdHBfRl4K9pZ36xhRgrP4Zr+R8A=="], - "@oxlint-tsgolint/linux-arm64": ["@oxlint-tsgolint/linux-arm64@0.24.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-PB1rxII7KV83+ASY4sSkXtqvpij6ME66+QCRL49uksi/ofs2Rf/UVboYr095n0Rkbl2wgvlsHGl6DHC361jQUQ=="], + "@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@7.0.2001", "", { "os": "linux", "cpu": "x64" }, "sha512-UDezNqdECVmngu2TPnjaS1YoAmcTaBoI5lV9vk3VahBxoi+I5r9k3iJTT7qZoYWOXTD/7T7bNcwRgrocR6BscQ=="], - "@oxlint-tsgolint/linux-x64": ["@oxlint-tsgolint/linux-x64@0.24.0", "", { "os": "linux", "cpu": "x64" }, "sha512-xcz3CxKmjTQLREtE/UShh+ruWmm9nAb7UM9zKcD65BStiuYgOakAKkPHl4YS5DztpVcDrE0+HqbOolTlRKYWmw=="], + "@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@7.0.2001", "", { "os": "win32", "cpu": "arm64" }, "sha512-uJZhqB6pdXLuN+AD1F5082byyQti/NPmJA77GtcFlmT2HzRelqbNls3SaIqxpjdFgvSBF9g0yOKGBkGFg7kX8Q=="], - "@oxlint-tsgolint/win32-arm64": ["@oxlint-tsgolint/win32-arm64@0.24.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-A2i6ZGBec3i20S7RaxkgHc6r3HYtD5Mn7j/mb22NkTz14u0JuudvTu6JggAnbGMcv8+dBKQI//EasxSPJLD8pw=="], + "@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@7.0.2001", "", { "os": "win32", "cpu": "x64" }, "sha512-FkDRm8hx9OwzGQqyWG1tO5QrTLRApff9DzSgpz9QZau37BR8d1VYKOxMLGf6shPZntJFoTwIIJYT68VndYDCog=="], - "@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@0.24.0", "", { "os": "win32", "cpu": "x64" }, "sha512-0ZbGd9qRB6zs82moekaKdEvncRANq49EAwfNX62JpTS46feXUhKAuoyVDvZMj6Rywejylrmmu79Wo6faYCo4Ew=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.79.0", "", { "os": "android", "cpu": "arm" }, "sha512-TebFaaMklO/RXzTv7PucaCq9l3X6D1gA+C8H6K4njtjFOV+zWE9MKLpulcJZN9bzytbUbQIY0mZuz12nQ5Kv4Q=="], - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.73.0", "", { "os": "android", "cpu": "arm" }, "sha512-HZQRN/UMBu+Ut+/9MiAChkbP4qZqrNOWBcNI45vOT40GVhbGR0JgHB87L48D4iAqFQIdVmeQYtV9RF89AjTKkg=="], + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.79.0", "", { "os": "android", "cpu": "arm64" }, "sha512-KqqnOtAVgNsPPF0YSodkFZA1O80jcKoCZCTu3bgsszxA+MrMP9TLzfXitKjEj1FmrPprKDMdRDMmY3weESO9sg=="], - "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.73.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Gp+KJRylv2aW7thRpG5p1KTxZq4ZJFbWowrKzufNq9d3ssl3r3JviYV45/+p+7CN1Nv0zDd1e8Ex0b/HUDq4TQ=="], + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.79.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BVC2nsMzqQzRDPc5RhixkZ+m1p7iH4bxRRvqkbwDXX0PlQKm1BPy8J8cRjnAFafOq2QzI+BfO3vE8w2GZ3CBag=="], - "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.73.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-3de96NdtXhxERMjIz7wsp2HYMY6pMQycGxFWac2mFecAx6VeARF/IqFb1QIaqiCRIdfzBwzTed+pCTCoiS+CYA=="], + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.79.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-p6Lm+snmhGuLKL1+CpCV8L6ijkE/qJzK2H2jG9+eKJT0n31RbY4FLsdhexekgP3bLpw4Kgde+9DZuDZQ4yIInA=="], - "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.73.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-5zx/uPW32TiaOeVY1dQ/H5iOf0K1HOdFKOJhLqGl4o63+i1fpzoqqu/mKtd7OFgFjNCdhlyTGgjVkQTZm1ELcg=="], + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.79.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-qDMm0dXZnoHyRqSL4N4xUq82T4sqK5cbKSjvd/dF/YbMUXc2R1wEPf+vmA5S0qUmi0nwXfNbjXBtZaIqzQLIMg=="], - "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.73.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-qNe4gKHaGnLuZJ8toUg90JAa0S2vTVvDw+0bRi3q1avXZXDT4u5mMeECf3nD4HYrbdn1O7dXqWut4onY/yx/Xg=="], + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.79.0", "", { "os": "linux", "cpu": "arm" }, "sha512-2od7s0nuKPzqyUZAWk9KkCyGg7eI9dwFPZg+20lB15fKFkVZ0c9ZFxqPfiBAyDTlTkh9stPI0t+JlPCqMbItVA=="], - "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.73.0", "", { "os": "linux", "cpu": "arm" }, "sha512-cCehYh5hTbfShm/fxTD6wwrGUWIpvX+N5OxmAMhFhDeTGXvw+BeNj889tpxsFQ9ZLatQ6wImuY8tsKLZ+FMz7w=="], + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.79.0", "", { "os": "linux", "cpu": "arm" }, "sha512-ZOQUjkzDnvlhSE3+tWC3YXx94MMl+sYMlwH+u1+YGApGHOJP/YAc8ZBRFOXZ6eOBmxtXAWuS/fBcdZr8qqNO1A=="], - "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.73.0", "", { "os": "linux", "cpu": "arm" }, "sha512-d5j5GDU/2dMgjVhw7TQT9ITrsIr1Y02KEXKyVGIXUkD+KiaxE9TP65FS2ZdgTBemQvoRL+gSBdbrIm3cQIeacg=="], + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.79.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-lu158FR4nGqGeRS3BQvtG85wRgU/Fy4MD5Cxp1hzJXizGiLo6u2742wJSCDKh8cFcZntvX7fcxlq4mMmfryH1g=="], - "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.73.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Eyf1SrP3+yR1DI3OJgOY2Pvrr9dWP9TK37xPaDYycwTtlGlI45erJAVIfH5/m/xosDt6BupJYEFi47bvbTuuyw=="], + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.79.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-mbpKQeE2aflTjddaHK7MP8KP/OFbUM++lt5M635ENM8IyIdK0jm2t9pb+2v9mVVIvhF6TqA4l7F79Pll1mi+uw=="], - "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.73.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-IlT/OJApEDKaMmCooHuncgJZbbCe7T5QIWmTZBEtYscWvzPQuuEinVcid6kwQRVQOUdb7PUCz4jQHnaYXdfJXw=="], + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.79.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WpGNua7gaxaHnpSDeog2ji8IDHn/QLPl9LPzwkR/FvVv58vT5BcXjRXnU+wbu3N75cpeha8CdC7ho/U2OIsB4g=="], - "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.73.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-L+JYcb/vdg5fmcH08V6o0YYLU28cTH1SPNulwJdvK9NK49aXSkYy6oNpKBmddArVOXYqNepriDGiZ04G54kh1Q=="], + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.79.0", "", { "os": "linux", "cpu": "none" }, "sha512-tK1E93A5LVzISg4ngpKJnfTs7EqtIUceGI7MQ4GyDjJiLi8wPCkEyKlj2xkyKWZ1yzkDJyLHTBJ5/iFWRdnJvg=="], - "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.73.0", "", { "os": "linux", "cpu": "none" }, "sha512-Qtk0g3bKV6OwWjIm7R8kQN1uOZRKQt/MODK2a8QfkwhTpXBD53ozx5XLVWLGDQAVyp2otLW4D2wB98XfAfMPGA=="], + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.79.0", "", { "os": "linux", "cpu": "none" }, "sha512-qhQvUIrngXivA2A9pQ+xPCychztn/5qUv7yS3gDwXv3w7Rag+eTeeXWmRyx+t7XsW5x6LuY/8AsTq36UgFIblg=="], - "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.73.0", "", { "os": "linux", "cpu": "none" }, "sha512-wX0NQKZVxltkAOVmzFcpOaMpdaUvsq1Eqpx9tkAfl71UdkTlSo1R4AdAnGccR1Fm2+TzFgZ22CyyGuZ41RDr/A=="], + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.79.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-sv6AaVgU/eE6u+6WFiQVDcPPwTxP6IJMSB9k701W2r/r6Tx465e8vPvVyRxquNH4Vy6KwRNu90mVbxXJN8+5gg=="], - "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.73.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-vPe7UGBMWyiLTtnqS4xxgMQFSFGmtQwhwCxuiw6lXygaO6bVt0D8dFVg8Xv05eaiN3ybC0HXXHUAohFMFvqoCQ=="], + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.79.0", "", { "os": "linux", "cpu": "x64" }, "sha512-iFZL02deziHslb3jEX9KdqlAkYoo4fGyotchKDzdfK1f5mxlIBeiQeHhvK3iFpuEJSB4ma/qeFn9oxPiwnhUPQ=="], - "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.73.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2CwIWr9cemFC/CbRBWZvuk5mffz6ObmfFkfcC/9rTQ7f+icNhYr2kOjf9Rt8lLvugvkdGDOmkoVoFFHh6ClCTw=="], + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.79.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3DtZR2raqObnh7wXZoFYFd0Fw7skBvcb3f7A+/lkEiDuh8hrE6vv9b/62Qxao1a9/OeHLw/FcXlXzgsW9wTRFg=="], - "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.73.0", "", { "os": "linux", "cpu": "x64" }, "sha512-nDadfJgg7NBBxG0N560wOe7LLX5QiYp6qBaI7viuk5EUORFBktU/NfV0MbTqU3gTqQDCh4VyxKdo5VADxk9w8Q=="], + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.79.0", "", { "os": "none", "cpu": "arm64" }, "sha512-Oatt4GuA1WJkqzk2ozx4HrWROOi7opV3AKDw/U8qDIqeTqzsjn5K2x3REJMNjU3/KU/Bkq96Zi3CknaiDTaC/Q=="], - "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.73.0", "", { "os": "none", "cpu": "arm64" }, "sha512-wGjJC+NLH9xP+IKGn9RDW94ojJR/wPbg5WCnQjj/oReaOtCQthr8ws1zICe77JFmo4ouUdeTHHZL/ESGiF6Pmw=="], + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.79.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-NAgZr9Qp8nIA9rpo0JEvwiabTF/2UVqBNnupBG9X4kxXcQoScJUTi+qHhvabb9s/thgj5wQ4XcIaJvb+ZMgoKw=="], - "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.73.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-I7X47GPGljw225YUQ5SbC/rb1Kkdrd0yQf0x+hYxeKS6DpfjMbo9ccQPQ6LNY6BoJQ1sHhgDUGuMn5Vg5gHT6w=="], + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.79.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-+KyXjIvcpaXmWW/j9NNY5yWjrIVxaX18VyIheQy3jwc2GSYgpCr7MGI/HxIGQ/shAL5IWEKbhsqoMpAO5Stiog=="], - "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.73.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-5lWj+3h+74Fm1jYOO9qkJA4xkAlZA099DkXppuXsk7UpnpZLttsefrZU469vChGaG6hcSqrkKXQOvMTZtbjeNg=="], + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.79.0", "", { "os": "win32", "cpu": "x64" }, "sha512-mEelcCMMBS57sIXh2veGMNy+pQwuGtcMxHxGIZWQ5Ba9pJ5jCCUFOZB9E2JhBaxGsURe+WGe0zJp4RVre52gpQ=="], - "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.73.0", "", { "os": "win32", "cpu": "x64" }, "sha512-WaNRvh4f6zY9CvUQk2YoA1O90ieWrIklI84+HXFr9Isjz9CSESrdqo/RtIYt4Dll/cAchqGDMehfaZd0vqEFZw=="], - - "@oxlint/plugins": ["@oxlint/plugins@1.73.0", "", {}, "sha512-OhgMQeMmZA0dcFcX4/priaJZWdFECxiClgq6mRX6aatZEcV9PbKC3P3/v8U1hVjviT1i5U+vR8lAtBV6m4FXAA=="], + "@oxlint/plugins": ["@oxlint/plugins@1.79.0", "", {}, "sha512-S0uyoxakDINJ4DPgqxGlEEvrdSMeQb7Z2lKVjxoY2gwsbZbfg2Xr8Klfeo5ZeraHmmdBCELFUHkSe6KEmBpMvg=="], "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], - "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.1.5", "", { "os": "android", "cpu": "arm64" }, "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ=="], + "@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.6", "", { "os": "android", "cpu": "arm" }, "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ=="], - "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.6", "", { "os": "android", "cpu": "arm64" }, "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q=="], - "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g=="], + "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA=="], - "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.1.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA=="], + "@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q=="], - "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.1.5", "", { "os": "linux", "cpu": "arm" }, "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw=="], + "@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA=="], - "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q=="], + "@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.6", "", { "os": "linux", "cpu": "arm" }, "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w=="], - "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA=="], + "@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg=="], - "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.1.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg=="], + "@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw=="], - "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.1.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA=="], + "@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.6", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ=="], - "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ=="], + "@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.6", "", { "os": "linux", "cpu": "s390x" }, "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA=="], - "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg=="], + "@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w=="], - "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.1.5", "", { "os": "none", "cpu": "arm64" }, "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw=="], + "@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ=="], - "@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.1.5", "", { "dependencies": { "@emnapi/core": "1.11.1", "@emnapi/runtime": "1.11.1", "@napi-rs/wasm-runtime": "^1.1.6" }, "cpu": "none" }, "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA=="], + "@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.6", "", { "os": "none", "cpu": "arm64" }, "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg=="], - "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.1.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw=="], + "@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A=="], - "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA=="], + "@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="], @@ -233,13 +223,11 @@ "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], - "@testing-library/user-event": ["@testing-library/user-event@14.6.1", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw=="], - - "@tybys/wasm-util": ["@tybys/wasm-util@0.10.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg=="], + "@testing-library/user-event": ["@testing-library/user-event@14.6.6", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-Jbs9FpkkIDw8FgSc6kOVsOv8JuuqGAL7J4X1oot77JxAoDlkNn2GRkd0aYRVuQ+pVQAiHWVkE4rX/dkF5fBiCw=="], "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], - "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -247,43 +235,83 @@ "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], - "@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], + "@types/node": ["@types/node@26.4.0", "", { "dependencies": { "undici-types": "~8.3.0" } }, "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ=="], + + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], - "@vitest/browser": ["@vitest/browser@4.1.10", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.1.0", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.10" } }, "sha512-UDwuWGwXj646CBx/bQHOaJSX7np0I8JL/UKQYa1e4QrVHH8VdWtx8eaOuf8sy0ShwDgR6NjJAsp5eF6vjF6qng=="], + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], - "@vitest/browser-preview": ["@vitest/browser-preview@4.1.10", "", { "dependencies": { "@testing-library/dom": "^10.4.1", "@testing-library/user-event": "^14.6.1", "@vitest/browser": "4.1.10" }, "peerDependencies": { "vitest": "4.1.10" } }, "sha512-14MJrL59ZFkqXLjwfSk6RzTDy5Czf9UG4+8q8L6Gxjs2aPjEce/cVNYV14bXAc2BvMjUNu904+ZEZA1Xc1wtvQ=="], + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], - "@vitest/expect": ["@vitest/expect@4.1.10", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA=="], + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], - "@vitest/mocker": ["@vitest/mocker@4.1.10", "", { "dependencies": { "@vitest/spy": "4.1.10", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow=="], + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], - "@vitest/pretty-format": ["@vitest/pretty-format@4.1.10", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q=="], + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], - "@vitest/runner": ["@vitest/runner@4.1.10", "", { "dependencies": { "@vitest/utils": "4.1.10", "pathe": "^2.0.3" } }, "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg=="], + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], - "@vitest/snapshot": ["@vitest/snapshot@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "@vitest/utils": "4.1.10", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw=="], + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], - "@vitest/spy": ["@vitest/spy@4.1.10", "", {}, "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw=="], + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], - "@vitest/utils": ["@vitest/utils@4.1.10", "", { "dependencies": { "@vitest/pretty-format": "4.1.10", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA=="], + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], - "@voidzero-dev/vite-plus-core": ["@voidzero-dev/vite-plus-core@0.2.5", "", { "dependencies": { "@oxc-project/runtime": "=0.139.0", "@oxc-project/types": "=0.139.0", "lightningcss": "^1.32.0", "postcss": "^8.5.6", "yuku-codegen": "^0.5.44", "yuku-parser": "^0.5.44" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "publint": "^0.3.8", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0", "unplugin-unused": "^0.5.0", "unrun": "*", "yaml": "^2.4.2" }, "optionalPeers": ["@arethetypeswrong/core", "@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "publint", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "typescript", "unplugin-unused", "unrun", "yaml"] }, "sha512-fxMGImIOyOipwCX6udOD1S9Q1xXfaimv6kcRgLWBxLsy7oryAyXqVfoYr7bmmAdSDlIutHRgvA6eiqfJjARTHA=="], + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], - "@voidzero-dev/vite-plus-darwin-arm64": ["@voidzero-dev/vite-plus-darwin-arm64@0.2.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-M62R3gmoHZbhL+UHHTevJi9a3aJyY+Eid8GAOtxEsRMkHmJ8IwOSOBERXM3C4CULvEa/ORYKiUQnqo5ewF44Fw=="], + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], - "@voidzero-dev/vite-plus-darwin-x64": ["@voidzero-dev/vite-plus-darwin-x64@0.2.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-a1h1dv/7QcnlqlN6yZBIPjgHSxbeyY/IcTxepTAOpPB7eAi1RPb1+cCkwo7c3MnDPJb3iZzwD0rm2+fOUoZp0w=="], + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], - "@voidzero-dev/vite-plus-linux-arm64-gnu": ["@voidzero-dev/vite-plus-linux-arm64-gnu@0.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-t8bS8fA2a3OSAEaHdgJFmzd0TkWh9yAxIoKAprsOleIcUEmzDxhH8drTj9TPyTrChKpv0aJTsK5ZK3RzcCUkdg=="], + "@vitest/browser": ["@vitest/browser@4.1.11", "", { "dependencies": { "@blazediff/core": "1.9.1", "@vitest/mocker": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pngjs": "^7.0.0", "sirv": "^3.0.2", "tinyrainbow": "^3.1.0", "ws": "^8.19.0" }, "peerDependencies": { "vitest": "4.1.11" } }, "sha512-bwMovvAeuTFOK5kIFevw4VEf+1gVEICv4SYK4k3knJOxl6b1zEWud8mYKD73e1B0odAn174h1MofURy2TPWf3w=="], - "@voidzero-dev/vite-plus-linux-arm64-musl": ["@voidzero-dev/vite-plus-linux-arm64-musl@0.2.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-vnsjQI3LEUYFMR3LCMqtAxaZav8BNypSAf8YzcFu9+Qtd1dcCrAUz9RrEBCIIqEiw0p0O+SrX2CcMHSSnzWbKA=="], + "@vitest/browser-preview": ["@vitest/browser-preview@4.1.11", "", { "dependencies": { "@testing-library/dom": "^10.4.1", "@testing-library/user-event": "^14.6.1", "@vitest/browser": "4.1.11" }, "peerDependencies": { "vitest": "4.1.11" } }, "sha512-iPKSE6Ibayey6HFgK1V1/aHgyhx7HSRk1YMi+lnBZGmlIiNV5Uc7xRkD9Su8RDylTxDECK23t7kTHdRKoqSYDQ=="], - "@voidzero-dev/vite-plus-linux-x64-gnu": ["@voidzero-dev/vite-plus-linux-x64-gnu@0.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-3xlXrxIz8UKGcGefifkhoMpsTIMdgqikwQuDUqgG5O7/b2tetpK9aoT4C9b2fQGkhYUpCJwdD83AtywQ4EhoWA=="], + "@vitest/expect": ["@vitest/expect@4.1.11", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" } }, "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw=="], - "@voidzero-dev/vite-plus-linux-x64-musl": ["@voidzero-dev/vite-plus-linux-x64-musl@0.2.5", "", { "os": "linux", "cpu": "x64" }, "sha512-XylGiayBoD7vt1/SKfmh5FoBNdKI5EWlIb5Sd9A1oTQW8DLi97VcEgVZ7vh/8kG1OEe+9z1lyRis6RDql2KDUw=="], + "@vitest/mocker": ["@vitest/mocker@4.1.11", "", { "dependencies": { "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ=="], - "@voidzero-dev/vite-plus-win32-arm64-msvc": ["@voidzero-dev/vite-plus-win32-arm64-msvc@0.2.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-eN1zvUAqXVXOC72WOVu9gz/jxr9tBrga/5lCsDjHeZDJ/bzhDVJ4eYZUDZzasPE1QCbAhmuIJsv0WzEUxdGAzA=="], + "@vitest/pretty-format": ["@vitest/pretty-format@4.1.11", "", { "dependencies": { "tinyrainbow": "^3.1.0" } }, "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw=="], - "@voidzero-dev/vite-plus-win32-x64-msvc": ["@voidzero-dev/vite-plus-win32-x64-msvc@0.2.5", "", { "os": "win32", "cpu": "x64" }, "sha512-jgXVYK8crlR5cQ07vX5Qw2K+boNLKMxarPgE3/AyPPRUtGC8iCpZz0RCPoJHDmTh7848Ra5Fnt4LEfbcUv1ByQ=="], + "@vitest/runner": ["@vitest/runner@4.1.11", "", { "dependencies": { "@vitest/utils": "4.1.11", "pathe": "^2.0.3" } }, "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog=="], + + "@vitest/spy": ["@vitest/spy@4.1.11", "", {}, "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA=="], + + "@vitest/utils": ["@vitest/utils@4.1.11", "", { "dependencies": { "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ=="], + + "@voidzero-dev/vite-plus-core": ["@voidzero-dev/vite-plus-core@0.3.0", "", { "dependencies": { "@oxc-project/runtime": "=0.146.0", "@oxc-project/types": "=0.146.0", "lightningcss": "^1.33.0", "postcss": "^8.5.6", "yuku-codegen": "^0.5.44", "yuku-parser": "^0.5.44" }, "optionalDependencies": { "@voidzero-dev/vite-plus-darwin-arm64": "0.3.0", "@voidzero-dev/vite-plus-darwin-x64": "0.3.0", "@voidzero-dev/vite-plus-linux-arm64-gnu": "0.3.0", "@voidzero-dev/vite-plus-linux-arm64-musl": "0.3.0", "@voidzero-dev/vite-plus-linux-x64-gnu": "0.3.0", "@voidzero-dev/vite-plus-linux-x64-musl": "0.3.0", "@voidzero-dev/vite-plus-win32-arm64-msvc": "0.3.0", "@voidzero-dev/vite-plus-win32-x64-msvc": "0.3.0", "fsevents": "~2.3.3" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "publint": "^0.3.8", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "typescript": "^5.0.0 || ^6.0.0 || ^7.0.0", "unplugin-unused": "^0.5.0", "unrun": "*", "yaml": "^2.4.2" }, "optionalPeers": ["@arethetypeswrong/core", "@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "publint", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "typescript", "unplugin-unused", "unrun", "yaml"] }, "sha512-aOqoqIWaF+Q/geDU48pC2rVFEVSvLV1GGj/NdvhUiBhCZntoFNbwI+hjUeG8BMaPG67sOV6ey+/sgkdmGmKqaw=="], + + "@voidzero-dev/vite-plus-darwin-arm64": ["@voidzero-dev/vite-plus-darwin-arm64@0.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-9ADr1egZ8T4tJOqrpQLhoDl95Y74R95+bsvjmin0gy1C0eQVhpmcNnBfb07KFNhJioJp9MMO7F7Dx4fQL5SKsw=="], + + "@voidzero-dev/vite-plus-darwin-x64": ["@voidzero-dev/vite-plus-darwin-x64@0.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-GegasVCwNeDOkNyvhLOuwU1+T2JkjY/Tq+SOvwphUpVcqQ6OOAUq9LlpoXviO2QL/Kq2NbMYjiAfPKVSTLUFQw=="], + + "@voidzero-dev/vite-plus-linux-arm64-gnu": ["@voidzero-dev/vite-plus-linux-arm64-gnu@0.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-nYI3KNYXkXjRPsSdR4Lr7J2xMxfR1+TplWlG/dV37qVXWAjbyHpoAlbULjZBAVJMyXRNlcADhBrEwXe4g6s48A=="], + + "@voidzero-dev/vite-plus-linux-arm64-musl": ["@voidzero-dev/vite-plus-linux-arm64-musl@0.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-HRlVA3AOcuGXmOdHhQ+Zv5XAaKbYF9si5rRHoOsKl0UyBo4txA3OoJfmP0WjanfLUNmu85JyO2dO1ptL4C6wgg=="], + + "@voidzero-dev/vite-plus-linux-x64-gnu": ["@voidzero-dev/vite-plus-linux-x64-gnu@0.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-9A+dFScPfwcrzF/rRR0zH8++2hOf6xtFmN/5LyzyfUywtw9MILXcC72IMcOeL6QRJwKUMsudi1rFeDE59azNvw=="], + + "@voidzero-dev/vite-plus-linux-x64-musl": ["@voidzero-dev/vite-plus-linux-x64-musl@0.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-KfIV3qaPdaOOE8JQMRHRE34FtZocl9O86XLTP6JMjDUlcx8FPgf8/fz/HFqJ8g232vM+JsgLI/YTVeXP8LkTKw=="], + + "@voidzero-dev/vite-plus-win32-arm64-msvc": ["@voidzero-dev/vite-plus-win32-arm64-msvc@0.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-KRhdy5K13AYx9KBfCVHRrK7zSZU+bMW9CL6gTai+UkJgAmDJi1kjdSNboZOjO8mrzUnTCrELgMI2tnstcxSTuA=="], + + "@voidzero-dev/vite-plus-win32-x64-msvc": ["@voidzero-dev/vite-plus-win32-x64-msvc@0.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-7+G+GxGmxdpQO0zjiGnkZFXKGqm0CrVduebRsJd6ccuOuxCQYPxLcoHq4WOaGrh56SrAGS7XjhnQCrXRkzKUVQ=="], "@yuku-codegen/binding-darwin-arm64": ["@yuku-codegen/binding-darwin-arm64@0.5.48", "", { "os": "darwin", "cpu": "arm64" }, "sha512-yo96Oef12WzqnphInfz/eexVse3+kWgfGS5g2S3rFS3dcGn1ENW9xLFDZUP9rh+yP76DOq38wBoFi1+I9+6qBg=="], @@ -347,7 +375,7 @@ "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], - "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], @@ -391,7 +419,7 @@ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], - "es-module-lexer": ["es-module-lexer@2.3.1", "", {}, "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA=="], + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], @@ -403,19 +431,19 @@ "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], - "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], + "eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="], "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], - "express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], + "express-rate-limit": ["express-rate-limit@8.6.2", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-YH4ru+eOJxQABscKFfRCy9R7x9QFGdezclVMwwgFFndzS2Xnm0uo6B0ABZsLhcpeptGv2qvuJVWlQr9gQZoC3A=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], - "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="], + "fast-uri": ["fast-uri@3.1.6", "", {}, "sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -441,15 +469,15 @@ "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], - "hono": ["hono@4.12.25", "", {}, "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ=="], + "hono": ["hono@4.13.5", "", {}, "sha512-O6+/eCYRkzzzy0rPWwKLiGBR1nFuUPZynnwjxN1MBA62NNqbT0wQEzQyK2gSO5yDIDB336sXQleAhOHrzlYyKw=="], "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], - "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], - "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], + "ip-address": ["ip-address@10.5.0", "", {}, "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -457,7 +485,7 @@ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], - "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "jose": ["jose@6.2.10", "", {}, "sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g=="], "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -467,29 +495,29 @@ "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], - "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + "lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="], - "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="], - "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="], - "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="], - "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="], - "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="], - "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="], - "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="], - "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="], - "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="], - "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="], - "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="], "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], @@ -497,7 +525,7 @@ "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], - "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + "media-typer": ["media-typer@1.1.1", "", {}, "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ=="], "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], @@ -509,51 +537,51 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], - "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - "obug": ["obug@2.1.3", "", {}, "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg=="], + "obug": ["obug@2.1.4", "", {}, "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], - "opencode-ai": ["opencode-ai@1.18.4", "", { "optionalDependencies": { "opencode-darwin-arm64": "1.18.4", "opencode-darwin-x64": "1.18.4", "opencode-darwin-x64-baseline": "1.18.4", "opencode-linux-arm64": "1.18.4", "opencode-linux-arm64-musl": "1.18.4", "opencode-linux-x64": "1.18.4", "opencode-linux-x64-baseline": "1.18.4", "opencode-linux-x64-baseline-musl": "1.18.4", "opencode-linux-x64-musl": "1.18.4", "opencode-windows-arm64": "1.18.4", "opencode-windows-x64": "1.18.4", "opencode-windows-x64-baseline": "1.18.4" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "opencode": "bin/opencode.exe" } }, "sha512-B8pFAs1g158ZU+C6eSiJz/5ZhG7Vr2mH7Z7ruYEHLElAlX9tehrRTnzTTgjSxHY2vwZ/5VplwR3odFU+Dai8jg=="], + "opencode-ai": ["opencode-ai@1.18.25", "", { "optionalDependencies": { "opencode-darwin-arm64": "1.18.25", "opencode-darwin-x64": "1.18.25", "opencode-darwin-x64-baseline": "1.18.25", "opencode-linux-arm64": "1.18.25", "opencode-linux-arm64-musl": "1.18.25", "opencode-linux-x64": "1.18.25", "opencode-linux-x64-baseline": "1.18.25", "opencode-linux-x64-baseline-musl": "1.18.25", "opencode-linux-x64-musl": "1.18.25", "opencode-windows-arm64": "1.18.25", "opencode-windows-x64": "1.18.25", "opencode-windows-x64-baseline": "1.18.25" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "opencode": "bin/opencode.exe" } }, "sha512-pS4RKJ9eKwU7Dp5G5pdj1rhMnpG5APixXzfTKNoFqv9aFVI36Rnza2jESvKifxyPZlsA65MQB03WCArY0EK6mg=="], - "opencode-darwin-arm64": ["opencode-darwin-arm64@1.18.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/GdcUv3axBFYmpdCY3yMGRCBq3fwAJayflnn9stXtIHKuckc7ZaUYZmyudIUpmzkZP5W8XsAVMJomR4Rbr2+Ig=="], + "opencode-darwin-arm64": ["opencode-darwin-arm64@1.18.25", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W4dyMFtHBglWZ1SEooh3Ke9v1M9lv945Y58atb8e1yKII8YykJ8LknOFyKipYC028oPDO4IZc3GYGKbg9PCg2w=="], - "opencode-darwin-x64": ["opencode-darwin-x64@1.18.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-0i/AmdFw3CerwIggKqtaDlWDCBfSgzXT+KPSvks/Dx6l9NfYHihLnbV+vKbr0lwzco1lsDo4JHE4j/zz7JZUKQ=="], + "opencode-darwin-x64": ["opencode-darwin-x64@1.18.25", "", { "os": "darwin", "cpu": "x64" }, "sha512-YYKrfeUSJhD7hZl+yNmayS51sDwxiE9o5XwrfgYSSie6sOyHFc9Ei13VBkVU6T+IJHhFhTahOFAwSDxggrAnGA=="], - "opencode-darwin-x64-baseline": ["opencode-darwin-x64-baseline@1.18.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1v/keJ/m+3UplGU2XdP5YfK25rTlGi7Kf5BVSV/ICObk6PiJWBAWOrB8bAQWDLhN/Tn7UfogBAgxaiRRfLPuEA=="], + "opencode-darwin-x64-baseline": ["opencode-darwin-x64-baseline@1.18.25", "", { "os": "darwin", "cpu": "x64" }, "sha512-rRgTaoTeIN2diL1e1HGZ48Zh4ynMDEB1jYjD76LaFUVzMwakEY1i7NvG8e/rbjRMDkgXIr6TwzCtIMcMOpLQMA=="], - "opencode-linux-arm64": ["opencode-linux-arm64@1.18.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-3gBw9weE76jloNPGmPvzl3GJR8b6scwSAENxUKRdJ3WCkC7qcjJm49KFyfqFjO5++1H1Q2jbmqqxAgt5upqoMQ=="], + "opencode-linux-arm64": ["opencode-linux-arm64@1.18.25", "", { "os": "linux", "cpu": "arm64" }, "sha512-PMvcpFpha3yAhaVCC0QbegHPxsEZ0FuQf+52PXvqQut1r3w1l1Pilor9tUA7TyCRa4UokACI90nTmKmtMnQBag=="], - "opencode-linux-arm64-musl": ["opencode-linux-arm64-musl@1.18.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-kno1QJz+JoY1YqGlrxcwGKqOh2/OFKdQ4UGUCN5K2evQRIhd9EfB4JvZkjzLxTzglda8lpP+PJltjCA4DHJAJw=="], + "opencode-linux-arm64-musl": ["opencode-linux-arm64-musl@1.18.25", "", { "os": "linux", "cpu": "arm64" }, "sha512-IwIPKmNwIjLshlSgjoRLKFwxxiLpZ5Y0zjv6r456RQtJKK62IbYGXkCm3AiSZ/lqGsu3XF+xn/Xza29ivgpgcg=="], - "opencode-linux-x64": ["opencode-linux-x64@1.18.4", "", { "os": "linux", "cpu": "x64" }, "sha512-mCiZuKQBqRHVhtp20YFDfNf5cUwTt6/I98MFJQDJdDTWggnkAVOnitQTmSt2cQwoPJ94VrRwDhZyY3pIY3+CQg=="], + "opencode-linux-x64": ["opencode-linux-x64@1.18.25", "", { "os": "linux", "cpu": "x64" }, "sha512-bdRSJ6gbK/EnLNWxROOQYXFXiUeqeFxGz8DIO8LCqnii99A2OWFAyZ3Da5gpvfT1Yrp9/lYL55n/tM3ale5smg=="], - "opencode-linux-x64-baseline": ["opencode-linux-x64-baseline@1.18.4", "", { "os": "linux", "cpu": "x64" }, "sha512-LlVIv54gpM8I6QJVAoW5uvcaVO4z+y5DswfUCsxgaD0CWgjEbULaiAAZIS/L+N5rRw/jHeqcHSXiKinFZt0xww=="], + "opencode-linux-x64-baseline": ["opencode-linux-x64-baseline@1.18.25", "", { "os": "linux", "cpu": "x64" }, "sha512-+b0w7XyHx0XPQWHBk2JymXbXnyZQ2PjIPuu4a4QJgSUqGuGz1L2flA3wgpZVAWFUhrEIr9DFhBk3AkKKNgMuRw=="], - "opencode-linux-x64-baseline-musl": ["opencode-linux-x64-baseline-musl@1.18.4", "", { "os": "linux", "cpu": "x64" }, "sha512-hM/uPMEuxLbwXueOmHsf3jjmfJ238/3r6FZMPumKVMR7jJHutp3ZWuOIL+ZzByxzsUK1f3RGbKaFULOMIieRSw=="], + "opencode-linux-x64-baseline-musl": ["opencode-linux-x64-baseline-musl@1.18.25", "", { "os": "linux", "cpu": "x64" }, "sha512-E2JUeOOSXPbG1cNOzxnqjqkd0a3+oFmwkbJe6bZ308CFgLWBFfVh0fF42HTCEqfK+yYbidpEkQuEkUgxq/11IA=="], - "opencode-linux-x64-musl": ["opencode-linux-x64-musl@1.18.4", "", { "os": "linux", "cpu": "x64" }, "sha512-AlojHLyv7Cgn2g5Rj5QeHckEWyA20qdbEla2d1R9mXSaqlP6sX7izNti8Tzr/vMG53Uwmppyjb37uFB2fK+YTQ=="], + "opencode-linux-x64-musl": ["opencode-linux-x64-musl@1.18.25", "", { "os": "linux", "cpu": "x64" }, "sha512-W15qTNDz1fsTzs1SkE6bB/gpIDBF3rwDbewUKdbyXD3dVs6umyugOql1T4u9n/gqWa/Z/VDURbn39VsejeSdbQ=="], - "opencode-windows-arm64": ["opencode-windows-arm64@1.18.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-B6m7N7ZPj/E6xx1ZaSjNW0gHTd0b3NTSf9mQiN3W89zji3oH9VOHLQdrP10rAd/2uZ5ZttxSkRqj/iwgAW+3TQ=="], + "opencode-windows-arm64": ["opencode-windows-arm64@1.18.25", "", { "os": "win32", "cpu": "arm64" }, "sha512-GFp74pProoPwqktHMf+9wQ8fza1RvFt0RG0iRtTQnJ4VWVY62qEeVuJkH6ki9QXS270HXyqtxvF8AuHQzuVZlA=="], - "opencode-windows-x64": ["opencode-windows-x64@1.18.4", "", { "os": "win32", "cpu": "x64" }, "sha512-TumOcMOvZ6vfs348LgGLK9eFEBLsh+wI/UXmQ8BhXW95YV1Wd26TutSzQi4L/wLftsDfl8Q6bKQ2trWtGBzBvQ=="], + "opencode-windows-x64": ["opencode-windows-x64@1.18.25", "", { "os": "win32", "cpu": "x64" }, "sha512-xW5wtSxWYbI7DcmQWMlNWIiDBdMJON1vDiEmVWo88R9tT/PaahOhWKgp7FoWDqJKf89jS3ZIzkqnkU3F2dio7A=="], - "opencode-windows-x64-baseline": ["opencode-windows-x64-baseline@1.18.4", "", { "os": "win32", "cpu": "x64" }, "sha512-4EpZ97uI50vVv9UT5sDeK3ff+sFzqmbBT4TNGkMF9uwNTKJYqoSVuOIZhIfLTLNlrEK2hd2/sf/4GQhIkM6iTw=="], + "opencode-windows-x64-baseline": ["opencode-windows-x64-baseline@1.18.25", "", { "os": "win32", "cpu": "x64" }, "sha512-/28bGRQwT+2JdGbtGaNr95tstgysiULEXtcvgNg7yLDxitqmSVgd8V8XRGS0UWDdfiWWMND9A9T5EAsbF1/xDQ=="], - "oxfmt": ["oxfmt@0.58.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.58.0", "@oxfmt/binding-android-arm64": "0.58.0", "@oxfmt/binding-darwin-arm64": "0.58.0", "@oxfmt/binding-darwin-x64": "0.58.0", "@oxfmt/binding-freebsd-x64": "0.58.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.58.0", "@oxfmt/binding-linux-arm-musleabihf": "0.58.0", "@oxfmt/binding-linux-arm64-gnu": "0.58.0", "@oxfmt/binding-linux-arm64-musl": "0.58.0", "@oxfmt/binding-linux-ppc64-gnu": "0.58.0", "@oxfmt/binding-linux-riscv64-gnu": "0.58.0", "@oxfmt/binding-linux-riscv64-musl": "0.58.0", "@oxfmt/binding-linux-s390x-gnu": "0.58.0", "@oxfmt/binding-linux-x64-gnu": "0.58.0", "@oxfmt/binding-linux-x64-musl": "0.58.0", "@oxfmt/binding-openharmony-arm64": "0.58.0", "@oxfmt/binding-win32-arm64-msvc": "0.58.0", "@oxfmt/binding-win32-ia32-msvc": "0.58.0", "@oxfmt/binding-win32-x64-msvc": "0.58.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-8feG/7NVEHDVwc1OUpP6Pks+TnaDFUw2jLLFIMi5bcmmwxAX2wBQvjSzj62RRTYBf2Op1Wt8xbkmagmPTR5ETg=="], + "oxfmt": ["oxfmt@0.64.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.64.0", "@oxfmt/binding-android-arm64": "0.64.0", "@oxfmt/binding-darwin-arm64": "0.64.0", "@oxfmt/binding-darwin-x64": "0.64.0", "@oxfmt/binding-freebsd-x64": "0.64.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.64.0", "@oxfmt/binding-linux-arm-musleabihf": "0.64.0", "@oxfmt/binding-linux-arm64-gnu": "0.64.0", "@oxfmt/binding-linux-arm64-musl": "0.64.0", "@oxfmt/binding-linux-ppc64-gnu": "0.64.0", "@oxfmt/binding-linux-riscv64-gnu": "0.64.0", "@oxfmt/binding-linux-riscv64-musl": "0.64.0", "@oxfmt/binding-linux-s390x-gnu": "0.64.0", "@oxfmt/binding-linux-x64-gnu": "0.64.0", "@oxfmt/binding-linux-x64-musl": "0.64.0", "@oxfmt/binding-openharmony-arm64": "0.64.0", "@oxfmt/binding-win32-arm64-msvc": "0.64.0", "@oxfmt/binding-win32-ia32-msvc": "0.64.0", "@oxfmt/binding-win32-x64-msvc": "0.64.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-XZ4GFBN/PLbXKq+0zrgpQfPKYuJlUuj+nzZJY7UpIbFMNyefNLCdN9EwViycNqnYcv0wrn0jXcQLlqJp8RCKBg=="], - "oxlint": ["oxlint@1.73.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.73.0", "@oxlint/binding-android-arm64": "1.73.0", "@oxlint/binding-darwin-arm64": "1.73.0", "@oxlint/binding-darwin-x64": "1.73.0", "@oxlint/binding-freebsd-x64": "1.73.0", "@oxlint/binding-linux-arm-gnueabihf": "1.73.0", "@oxlint/binding-linux-arm-musleabihf": "1.73.0", "@oxlint/binding-linux-arm64-gnu": "1.73.0", "@oxlint/binding-linux-arm64-musl": "1.73.0", "@oxlint/binding-linux-ppc64-gnu": "1.73.0", "@oxlint/binding-linux-riscv64-gnu": "1.73.0", "@oxlint/binding-linux-riscv64-musl": "1.73.0", "@oxlint/binding-linux-s390x-gnu": "1.73.0", "@oxlint/binding-linux-x64-gnu": "1.73.0", "@oxlint/binding-linux-x64-musl": "1.73.0", "@oxlint/binding-openharmony-arm64": "1.73.0", "@oxlint/binding-win32-arm64-msvc": "1.73.0", "@oxlint/binding-win32-ia32-msvc": "1.73.0", "@oxlint/binding-win32-x64-msvc": "1.73.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.24.0", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-u91G9TJzU6yqKWNZUYprQB07W7YvntZXaRxQ6CkoytepYhLWUXWsr1M8zUJ34VatNPuUAr3Z8GH+O2A331CluQ=="], + "oxlint": ["oxlint@1.79.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.79.0", "@oxlint/binding-android-arm64": "1.79.0", "@oxlint/binding-darwin-arm64": "1.79.0", "@oxlint/binding-darwin-x64": "1.79.0", "@oxlint/binding-freebsd-x64": "1.79.0", "@oxlint/binding-linux-arm-gnueabihf": "1.79.0", "@oxlint/binding-linux-arm-musleabihf": "1.79.0", "@oxlint/binding-linux-arm64-gnu": "1.79.0", "@oxlint/binding-linux-arm64-musl": "1.79.0", "@oxlint/binding-linux-ppc64-gnu": "1.79.0", "@oxlint/binding-linux-riscv64-gnu": "1.79.0", "@oxlint/binding-linux-riscv64-musl": "1.79.0", "@oxlint/binding-linux-s390x-gnu": "1.79.0", "@oxlint/binding-linux-x64-gnu": "1.79.0", "@oxlint/binding-linux-x64-musl": "1.79.0", "@oxlint/binding-openharmony-arm64": "1.79.0", "@oxlint/binding-win32-arm64-msvc": "1.79.0", "@oxlint/binding-win32-ia32-msvc": "1.79.0", "@oxlint/binding-win32-x64-msvc": "1.79.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-hVJ9hq9m2unPS+Of4eJJgCPdIeCC+3DHEUX3tkmrPJr3OK2hz7PhXwgC+ZP71ZcYu8cCDEtQrqLxWNvxBppBVg=="], - "oxlint-tsgolint": ["oxlint-tsgolint@0.24.0", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.24.0", "@oxlint-tsgolint/darwin-x64": "0.24.0", "@oxlint-tsgolint/linux-arm64": "0.24.0", "@oxlint-tsgolint/linux-x64": "0.24.0", "@oxlint-tsgolint/win32-arm64": "0.24.0", "@oxlint-tsgolint/win32-x64": "0.24.0" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-giCk5sEvG02d5tzPmFMX3hem8ndzEEu1xvGYS5OwNfO2WGl6ZVxt5LjE0yiMDoz94INI7XkXwgFAQiydPvVHDw=="], + "oxlint-tsgolint": ["oxlint-tsgolint@7.0.2001", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "7.0.2001", "@oxlint-tsgolint/darwin-x64": "7.0.2001", "@oxlint-tsgolint/linux-arm64": "7.0.2001", "@oxlint-tsgolint/linux-x64": "7.0.2001", "@oxlint-tsgolint/win32-arm64": "7.0.2001", "@oxlint-tsgolint/win32-x64": "7.0.2001" }, "bin": { "tsgolint": "./bin/tsgolint.js" } }, "sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg=="], "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], @@ -565,23 +593,23 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + "picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], - "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + "postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], - "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="], + "qs": ["qs@6.15.3", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A=="], "radash": ["radash@12.1.1", "", {}, "sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA=="], - "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], @@ -589,7 +617,7 @@ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], - "rolldown": ["rolldown@1.1.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.1.5", "@rolldown/binding-darwin-arm64": "1.1.5", "@rolldown/binding-darwin-x64": "1.1.5", "@rolldown/binding-freebsd-x64": "1.1.5", "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", "@rolldown/binding-linux-arm64-gnu": "1.1.5", "@rolldown/binding-linux-arm64-musl": "1.1.5", "@rolldown/binding-linux-ppc64-gnu": "1.1.5", "@rolldown/binding-linux-s390x-gnu": "1.1.5", "@rolldown/binding-linux-x64-gnu": "1.1.5", "@rolldown/binding-linux-x64-musl": "1.1.5", "@rolldown/binding-openharmony-arm64": "1.1.5", "@rolldown/binding-wasm32-wasi": "1.1.5", "@rolldown/binding-win32-arm64-msvc": "1.1.5", "@rolldown/binding-win32-x64-msvc": "1.1.5" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA=="], + "rolldown": ["rolldown@1.2.6", "", { "dependencies": { "@oxc-project/types": "=0.147.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.6", "@rolldown/binding-android-arm64": "1.2.6", "@rolldown/binding-darwin-arm64": "1.2.6", "@rolldown/binding-darwin-x64": "1.2.6", "@rolldown/binding-freebsd-x64": "1.2.6", "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", "@rolldown/binding-linux-arm64-gnu": "1.2.6", "@rolldown/binding-linux-arm64-musl": "1.2.6", "@rolldown/binding-linux-ppc64-gnu": "1.2.6", "@rolldown/binding-linux-s390x-gnu": "1.2.6", "@rolldown/binding-linux-x64-gnu": "1.2.6", "@rolldown/binding-linux-x64-musl": "1.2.6", "@rolldown/binding-openharmony-arm64": "1.2.6", "@rolldown/binding-win32-arm64-msvc": "1.2.6", "@rolldown/binding-win32-x64-msvc": "1.2.6" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA=="], "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], @@ -625,19 +653,19 @@ "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], - "std-env": ["std-env@4.1.0", "", {}, "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ=="], + "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], - "tinyexec": ["tinyexec@1.2.4", "", {}, "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg=="], + "tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], - "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], + "tinyrainbow": ["tinyrainbow@3.1.1", "", {}, "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw=="], "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], @@ -645,27 +673,25 @@ "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "type-fest": ["type-fest@5.7.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg=="], + "type-fest": ["type-fest@5.8.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA=="], "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], - "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="], + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], - "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], + "undici-types": ["undici-types@8.3.0", "", {}, "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ=="], "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], - "vestig": ["vestig@0.23.0", "", {}, "sha512-Jo9HAym5MyXn3zY4L1T/e7zGwP3Okf5VWtSWeSvjpeAbDD6iparFRxrlRSpwZLCxJY1DSlBFiHRLX4+wYVNtiA=="], + "vestig": ["vestig@0.24.1", "", {}, "sha512-sQTGvU3VdPgtM3PE3Eweo7AqJJPeoDl3Wke2OEVJRA5lZclZ5CmgDHLKCfvOJ/S82SyXtyzwCeQNvTxSbPttWg=="], - "vite": ["vite@8.1.5", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.5", "postcss": "^8.5.17", "rolldown": "~1.1.5", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.3.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw=="], + "vite": ["vite@8.2.2", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.5", "postcss": "^8.5.26", "rolldown": "~1.2.4", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.4.0 || ^0.5.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q=="], - "vite-plus": ["vite-plus@0.2.5", "", { "dependencies": { "@oxc-project/types": "=0.139.0", "@oxlint/plugins": "=1.73.0", "@vitest/browser": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "@voidzero-dev/vite-plus-core": "0.2.5", "oxfmt": "=0.58.0", "oxlint": "=1.73.0", "oxlint-tsgolint": "=0.24.0", "vitest": "4.1.10" }, "optionalDependencies": { "@voidzero-dev/vite-plus-darwin-arm64": "0.2.5", "@voidzero-dev/vite-plus-darwin-x64": "0.2.5", "@voidzero-dev/vite-plus-linux-arm64-gnu": "0.2.5", "@voidzero-dev/vite-plus-linux-arm64-musl": "0.2.5", "@voidzero-dev/vite-plus-linux-x64-gnu": "0.2.5", "@voidzero-dev/vite-plus-linux-x64-musl": "0.2.5", "@voidzero-dev/vite-plus-win32-arm64-msvc": "0.2.5", "@voidzero-dev/vite-plus-win32-x64-msvc": "0.2.5" }, "peerDependencies": { "@vitest/browser-playwright": "4.1.10", "@vitest/browser-webdriverio": "4.1.10" }, "optionalPeers": ["@vitest/browser-playwright", "@vitest/browser-webdriverio"], "bin": { "oxfmt": "bin/oxfmt", "oxlint": "bin/oxlint", "vp": "bin/vp", "vpr": "bin/vpr" } }, "sha512-QNJ0FnN8rfs5u8lZKZ1uR2Tegjg3VkT0AGTxSLGHg4fYmGxRNfAV0YX1parZrVa4VybSI56SWCoo7wQ6D7pMew=="], + "vite-plus": ["vite-plus@0.3.0", "", { "dependencies": { "@oxc-project/types": "=0.146.0", "@oxlint/plugins": "=1.79.0", "@vitest/browser": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "@voidzero-dev/vite-plus-core": "0.3.0", "oxfmt": "=0.64.0", "oxlint": "=1.79.0", "oxlint-tsgolint": "=7.0.2001", "vitest": "4.1.11" }, "optionalDependencies": { "@voidzero-dev/vite-plus-darwin-arm64": "0.3.0", "@voidzero-dev/vite-plus-darwin-x64": "0.3.0", "@voidzero-dev/vite-plus-linux-arm64-gnu": "0.3.0", "@voidzero-dev/vite-plus-linux-arm64-musl": "0.3.0", "@voidzero-dev/vite-plus-linux-x64-gnu": "0.3.0", "@voidzero-dev/vite-plus-linux-x64-musl": "0.3.0", "@voidzero-dev/vite-plus-win32-arm64-msvc": "0.3.0", "@voidzero-dev/vite-plus-win32-x64-msvc": "0.3.0" }, "peerDependencies": { "@vitest/browser-playwright": "4.1.11", "@vitest/browser-webdriverio": "4.1.11" }, "optionalPeers": ["@vitest/browser-playwright", "@vitest/browser-webdriverio"], "bin": { "oxfmt": "./bin/oxfmt", "oxlint": "./bin/oxlint", "vp": "./bin/vp", "vpr": "./bin/vpr" } }, "sha512-GNWbWuWD37frCSFrz6MLzUo62bTv5IOJozHEgZYOkxsLkuQtTwm4TowzpfoGrSsfwhAAtfPd/sK1Y0+v1SwhZA=="], - "vitest": ["vitest@4.1.10", "", { "dependencies": { "@vitest/expect": "4.1.10", "@vitest/mocker": "4.1.10", "@vitest/pretty-format": "4.1.10", "@vitest/runner": "4.1.10", "@vitest/snapshot": "4.1.10", "@vitest/spy": "4.1.10", "@vitest/utils": "4.1.10", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.10", "@vitest/browser-preview": "4.1.10", "@vitest/browser-webdriverio": "4.1.10", "@vitest/coverage-istanbul": "4.1.10", "@vitest/coverage-v8": "4.1.10", "@vitest/ui": "4.1.10", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw=="], + "vitest": ["vitest@4.1.11", "", { "dependencies": { "@vitest/expect": "4.1.11", "@vitest/mocker": "4.1.11", "@vitest/pretty-format": "4.1.11", "@vitest/runner": "4.1.11", "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.1.0", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.1.11", "@vitest/browser-preview": "4.1.11", "@vitest/browser-webdriverio": "4.1.11", "@vitest/coverage-istanbul": "4.1.11", "@vitest/coverage-v8": "4.1.11", "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw=="], "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], @@ -673,22 +699,22 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], "yuku-codegen": ["yuku-codegen@0.5.48", "", { "dependencies": { "@yuku-toolchain/types": "0.5.43" }, "optionalDependencies": { "@yuku-codegen/binding-darwin-arm64": "0.5.48", "@yuku-codegen/binding-darwin-x64": "0.5.48", "@yuku-codegen/binding-freebsd-x64": "0.5.48", "@yuku-codegen/binding-linux-arm-gnu": "0.5.48", "@yuku-codegen/binding-linux-arm-musl": "0.5.48", "@yuku-codegen/binding-linux-arm64-gnu": "0.5.48", "@yuku-codegen/binding-linux-arm64-musl": "0.5.48", "@yuku-codegen/binding-linux-x64-gnu": "0.5.48", "@yuku-codegen/binding-linux-x64-musl": "0.5.48", "@yuku-codegen/binding-win32-arm64": "0.5.48", "@yuku-codegen/binding-win32-x64": "0.5.48" } }, "sha512-p7HxD5Xl4jzDzqMrGePAOeSHmRY4g58h4HuGq15weQFPxuPWd/W6e7nqp/+Lea6JfpOdBwJOAyXFqIZ/J9Zfnw=="], "yuku-parser": ["yuku-parser@0.5.48", "", { "dependencies": { "@yuku-toolchain/types": "0.5.43" }, "optionalDependencies": { "@yuku-parser/binding-darwin-arm64": "0.5.48", "@yuku-parser/binding-darwin-x64": "0.5.48", "@yuku-parser/binding-freebsd-x64": "0.5.48", "@yuku-parser/binding-linux-arm-gnu": "0.5.48", "@yuku-parser/binding-linux-arm-musl": "0.5.48", "@yuku-parser/binding-linux-arm64-gnu": "0.5.48", "@yuku-parser/binding-linux-arm64-musl": "0.5.48", "@yuku-parser/binding-linux-x64-gnu": "0.5.48", "@yuku-parser/binding-linux-x64-musl": "0.5.48", "@yuku-parser/binding-win32-arm64": "0.5.48", "@yuku-parser/binding-win32-x64": "0.5.48" } }, "sha512-OWBfhrpgK9+/4+IXG9oT8Bao4AhViQA7vdyNNH7EUg8dQYgwa70XtIBWTpCEme1P1ECyoDNYkn0wT63f8XRcVA=="], - "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], - "body-parser/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], - "type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], - "vite/picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], + "rolldown/@oxc-project/types": ["@oxc-project/types@0.147.0", "", {}, "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg=="], - "vite/postcss": ["postcss@8.5.19", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ=="], + "type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], } } diff --git a/package.json b/package.json index 004c9b1..a553406 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "files": [ "dist", "src", + "!src/runtimes/openai/generated", "assets" ], "type": "module", @@ -75,6 +76,7 @@ "tc": "vp exec tsc", "test": "bun test", "test:coverage": "bun test --coverage", + "test:artifact:mcp": "AGENT_DRIVER_LIVE=1 bun test tests/driver-artifact-mcp.test.ts", "test:live": "vp run build && vp run test:live:artifact", "test:live:artifact": "AGENT_DRIVER_LIVE=1 AGENT_DRIVER_LIVE_SUITE=all bun test tests/driver-artifact-live.test.ts tests/driver-artifact-mcp.test.ts", "test:live:anthropic": "vp run build && AGENT_DRIVER_LIVE=1 AGENT_DRIVER_LIVE_SUITE=anthropic bun test tests/driver-artifact-live.test.ts", @@ -82,32 +84,33 @@ "test:live:opencode": "vp run build && AGENT_DRIVER_LIVE=1 AGENT_DRIVER_LIVE_SUITE=opencode bun test tests/driver-artifact-live.test.ts", "bench": "bun bench/ttft-bench.ts", "build": "vp run build:types && bun build src/bin/driver.ts --target bun --outfile dist/driver.mjs", - "build:types": "rm -rf dist/types && vp exec tsc -p tsconfig.types.json", - "build:image": "vp run build && buildah build -t agent-driver:local .", - "docker:smoke:environment": "docker run --rm --entrypoint node agent-driver:local /usr/local/libexec/mosoo/environment-package-manager-check.mjs smoke", - "check": "vp check && vp run tc && vp run test && vp run build", + "build:types": "vp pack", + "build:image": "vp run build && buildah build --http-proxy=false --platform linux/amd64 -t agent-driver:local .", + "check:generated": "bun scripts/sync-openai-generated.mjs --check", + "test:image:environment": "podman run --pull=never --rm --entrypoint node agent-driver:local /usr/local/libexec/mosoo/environment-package-manager-check.mjs smoke", + "check": "vp check && vp run check:generated && vp run tc && vp run test && vp run build && vp run test:artifact:mcp", "prepack": "vp run build" }, "dependencies": { - "@agentclientprotocol/sdk": "1.2.1", - "@anthropic-ai/claude-agent-sdk": "0.3.211", - "@anthropic-ai/sdk": "0.111.0", - "@modelcontextprotocol/client": "^2.0.0-alpha.2", - "@orpc/client": "^1.14.3", + "@agentclientprotocol/sdk": "1.4.0", + "@anthropic-ai/claude-agent-sdk": "0.3.257", + "@anthropic-ai/sdk": "0.123.0", + "@modelcontextprotocol/client": "^2.0.0", + "@orpc/client": "^1.15.0", "fflate": "^0.8.3", - "vestig": "^0.23.0", - "zod": "^4.4.3" + "vestig": "^0.24.1", + "zod": "4.5.4" }, "devDependencies": { - "@openai/codex-sdk": "0.144.5", - "@types/bun": "1.3.14", - "@types/node": "^25.8.0", - "opencode-ai": "1.18.4", - "typescript": "^6.0.3", - "vite-plus": "0.2.5" + "@openai/codex": "0.152.0", + "@types/bun": "1.4.0", + "@types/node": "^26.3.0", + "opencode-ai": "1.18.25", + "typescript": "^7.0.2", + "vite-plus": "0.3.0" }, "engines": { - "bun": ">=1.3.14" + "bun": ">=1.4.0" }, - "packageManager": "bun@1.3.14" + "packageManager": "bun@1.4.0" } diff --git a/scripts/sync-openai-generated.mjs b/scripts/sync-openai-generated.mjs new file mode 100644 index 0000000..c2f8f64 --- /dev/null +++ b/scripts/sync-openai-generated.mjs @@ -0,0 +1,200 @@ +import { execFileSync } from "node:child_process"; +import { + copyFileSync, + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, resolve, sep } from "node:path"; + +const root = resolve(import.meta.dirname, ".."); +const generatedDir = resolve(root, "src/runtimes/openai/generated"); +const schemaDir = resolve(root, "src/runtimes/openai/generated-json-schema"); +const protocolTypesPath = resolve(root, "src/runtimes/openai/app-server-protocol-types.ts"); +const checkOnly = process.argv.includes("--check"); +const schemaSources = new Map([ + ["InitializeResponse.json", "v1/InitializeResponse.json"], + ["ServerNotification.json", "ServerNotification.json"], + ["ServerRequest.json", "ServerRequest.json"], + ["ThreadBackgroundTerminalsCleanResponse.json", "v2/ThreadBackgroundTerminalsCleanResponse.json"], + ["ThreadInjectItemsResponse.json", "v2/ThreadInjectItemsResponse.json"], + ["ThreadResumeResponse.json", "v2/ThreadResumeResponse.json"], + ["ThreadStartResponse.json", "v2/ThreadStartResponse.json"], + ["TurnStartResponse.json", "v2/TurnStartResponse.json"], +]); + +function run(command, args) { + execFileSync(command, args, { cwd: root, stdio: "inherit" }); +} + +function listFiles(directory, extension) { + return readdirSync(directory, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith(extension)) + .map((entry) => resolve(entry.parentPath, entry.name)) + .sort(); +} + +function readMethods(stagedSchemaDir, schemaName) { + const schema = JSON.parse(readFileSync(resolve(stagedSchemaDir, `${schemaName}.json`), "utf8")); + if (!Array.isArray(schema.oneOf)) { + throw new TypeError(`${schemaName}.json must contain a oneOf array.`); + } + + const methods = schema.oneOf.map((branch, index) => { + const values = branch?.properties?.method?.enum; + if (values?.length !== 1 || typeof values[0] !== "string") { + throw new TypeError(`${schemaName}.json oneOf[${index}] must have one method enum value.`); + } + return values[0]; + }); + if (new Set(methods).size !== methods.length) { + throw new TypeError(`${schemaName}.json contains duplicate methods.`); + } + return methods; +} + +function renderUnion(name, methods) { + return `export type ${name} =\n${methods + .map((method) => ` | ${JSON.stringify(method)}`) + .join("\n")};`; +} + +function writeProtocolMethods(stagedSchemaDir, stagedTypesDir) { + writeFileSync( + resolve(stagedTypesDir, "ProtocolMethods.ts"), + [ + "// GENERATED CODE! DO NOT MODIFY BY HAND!", + "", + "// Derived from the matching runtime JSON Schemas by scripts/sync-openai-generated.mjs.", + renderUnion("ServerNotificationMethod", readMethods(stagedSchemaDir, "ServerNotification")), + "", + renderUnion("ServerRequestMethod", readMethods(stagedSchemaDir, "ServerRequest")), + "", + ].join("\n"), + ); +} + +function resolveImport(baseDir, importer, specifier) { + const unresolved = resolve(dirname(importer), specifier); + const candidates = unresolved.endsWith(".ts") + ? [unresolved] + : [`${unresolved}.ts`, resolve(unresolved, "index.ts")]; + const file = candidates.find(existsSync); + if (file === undefined) { + throw new Error(`${relative(baseDir, importer)} imports missing module ${specifier}.`); + } + + const path = relative(baseDir, file); + if (path === ".." || path.startsWith(`..${sep}`)) { + throw new Error(`${relative(baseDir, importer)} imports outside the generated directory.`); + } + return file; +} + +function pruneTypes(stagedTypesDir) { + const protocolSource = readFileSync(protocolTypesPath, "utf8"); + const protocolImportPattern = /\bfrom\s+["']\.\/generated(?:\/([^"']+))?["']/gu; + const protocolFile = resolve(stagedTypesDir, "__protocol__.ts"); + const pending = [...protocolSource.matchAll(protocolImportPattern)].map((match) => + resolveImport(stagedTypesDir, protocolFile, `./${match[1] ?? "index"}`), + ); + if (pending.length === 0) { + throw new Error("app-server-protocol-types.ts must import at least one generated type."); + } + + const reachable = new Set(); + const relativeImportPattern = /\b(?:from\s+|import\s*\()\s*["'](\.[^"']+)["']/gu; + while (pending.length > 0) { + const file = pending.pop(); + if (file === undefined || reachable.has(file)) { + continue; + } + reachable.add(file); + for (const match of readFileSync(file, "utf8").matchAll(relativeImportPattern)) { + pending.push(resolveImport(stagedTypesDir, file, match[1])); + } + } + + for (const file of listFiles(stagedTypesDir, ".ts")) { + if (!reachable.has(file)) { + rmSync(file); + } + } + return reachable.size; +} + +function replaceFiles(sourceDir, targetDir, extension) { + for (const file of listFiles(targetDir, extension)) { + rmSync(file); + } + for (const file of listFiles(sourceDir, extension)) { + const target = resolve(targetDir, relative(sourceDir, file)); + mkdirSync(dirname(target), { recursive: true }); + copyFileSync(file, target); + } +} + +function assertFilesMatch(expectedDir, actualDir, extension) { + const expected = listFiles(expectedDir, extension).map((file) => relative(expectedDir, file)); + const actual = listFiles(actualDir, extension).map((file) => relative(actualDir, file)); + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`Generated ${extension} file set is stale.`); + } + for (const path of expected) { + if ( + readFileSync(resolve(expectedDir, path), "utf8") !== + readFileSync(resolve(actualDir, path), "utf8") + ) { + throw new Error(`Generated file ${path} is stale.`); + } + } +} + +const temporaryRoot = mkdtempSync(join(tmpdir(), "mosoo-openai-generated-")); +try { + const completeSchemas = resolve(temporaryRoot, "complete-schemas"); + const completeTypes = resolve(temporaryRoot, "complete-types"); + const stagedSchemas = resolve(temporaryRoot, "schemas"); + const stagedTypes = resolve(temporaryRoot, "types"); + mkdirSync(stagedSchemas); + run(resolve(root, "node_modules/.bin/codex"), [ + "app-server", + "generate-json-schema", + "--experimental", + "--out", + completeSchemas, + ]); + run(resolve(root, "node_modules/.bin/codex"), [ + "app-server", + "generate-ts", + "--experimental", + "--out", + completeTypes, + ]); + + for (const [target, source] of schemaSources) { + copyFileSync(resolve(completeSchemas, source), resolve(stagedSchemas, target)); + } + cpSync(completeTypes, stagedTypes, { recursive: true }); + writeProtocolMethods(stagedSchemas, stagedTypes); + const retainedTypes = pruneTypes(stagedTypes); + run(resolve(root, "node_modules/.bin/vp"), ["fmt", stagedSchemas, stagedTypes]); + + if (checkOnly) { + assertFilesMatch(stagedSchemas, schemaDir, ".json"); + assertFilesMatch(stagedTypes, generatedDir, ".ts"); + console.log(`Verified 8 schemas and ${String(retainedTypes)} generated TypeScript files.`); + } else { + replaceFiles(stagedSchemas, schemaDir, ".json"); + replaceFiles(stagedTypes, generatedDir, ".ts"); + console.log(`Synchronized 8 schemas and ${String(retainedTypes)} generated TypeScript files.`); + } +} finally { + rmSync(temporaryRoot, { force: true, recursive: true }); +} diff --git a/src/bin/driver-process.ts b/src/bin/driver-process.ts index c67252e..c66671a 100644 --- a/src/bin/driver-process.ts +++ b/src/bin/driver-process.ts @@ -3,6 +3,7 @@ import { createAgentDriverContext } from "../core/agent-driver-backend"; import { AgentBackendLifecycle } from "../core/agent-backend-lifecycle"; import { DriverCommandDispatcher } from "../core/driver-command-dispatcher"; import { deliverRunTerminal } from "../core/driver-command-delivery"; +import type { RunTerminalUpdate } from "../core/driver-command-delivery"; import { pushDriverDiagnosticEvent } from "../core/driver-diagnostics"; import { DriverHeartbeatLoop } from "../core/driver-heartbeat-loop"; import { DriverPermissionBroker } from "../core/driver-permission-broker"; @@ -22,14 +23,12 @@ import type { Logger } from "../observability"; import { summarizeDriverBootPayload } from "../observability/driver-debug"; import { DRIVER_PROTOCOL_VERSION } from "../protocol/boot"; import type { DriverBootPayload } from "../protocol/boot"; -import { createDriverHostIntegrationSnapshotFromBootExecution } from "../protocol/host-integration"; -import type { DriverHostIntegrationSnapshot } from "../protocol/host-integration"; -import { parseDriverId } from "../protocol/id"; +import { parseRunId } from "../protocol/id"; import type { RunId } from "../protocol/id"; import { createDriverStartInputFromBootPayload } from "../protocol/start"; import type { DriverStartInput } from "../protocol/start"; import type { RunError } from "../runtime-command"; -import { executeRemoteHttpMcpCommand } from "../runtimes/mcp/remote-http-mcp-executor"; +import { prepareRemoteHttpMcpCommand } from "../runtimes/mcp/remote-http-mcp-executor"; import { AGENT_DRIVER_PROVIDER_REGISTRY, createAgentDriverProviderCapabilities, @@ -40,10 +39,6 @@ import { promiseWithTimeout } from "../utils/async"; const DRIVER_BACKEND_START_TIMEOUT_MS = 60_000; const DRIVER_SHUTDOWN_TIMEOUT_MS = 5_000; -function parseNullableRunId(value: string | null): RunId | null { - return value === null ? null : (parseDriverId(value, "Run ID") as RunId); -} - export class DriverProcess { readonly #startedAt = new Date().toISOString(); readonly #backendFactory: AgentDriverBackendFactory; @@ -51,12 +46,10 @@ export class DriverProcess { #backendLifecycle: AgentBackendLifecycle | null = null; #logger: Logger | null = null; #logUplink: DriverLogUplink | null = null; - #pendingRunCompletion = false; - #pendingRunFailure: RunError | null = null; + #pendingRunTerminal: RunTerminalUpdate | null = null; private readonly payload: DriverBootPayload; readonly #permissionBroker: DriverPermissionBroker; readonly #shutdownController = new AbortController(); - readonly #hostSnapshot: DriverHostIntegrationSnapshot; readonly #runtimeState = new DriverRuntimeStateMachine("created"); readonly #startInput: DriverStartInput; #shutdownReason: string | null = null; @@ -71,7 +64,6 @@ export class DriverProcess { ) { this.#backendFactory = backendFactory; this.payload = payload; - this.#hostSnapshot = createDriverHostIntegrationSnapshotFromBootExecution(payload.execution); this.#startInput = createDriverStartInputFromBootPayload(payload); this.#permissionBroker = new DriverPermissionBroker(() => this.#logger); this.#heartbeatLoop = new DriverHeartbeatLoop({ @@ -135,7 +127,7 @@ export class DriverProcess { startedAt: this.#startedAt, }), ); - const initialRunId = parseNullableRunId(hello.runId); + const initialRunId = hello.runId === null ? null : parseRunId(hello.runId); // The server accepts pushLogs only after hello commits; release the // buffered boot logs now instead of racing the handshake round-trip. uplink.open(); @@ -171,7 +163,6 @@ export class DriverProcess { backend, createContext: () => this.createAgentDriverContext(socket, logger), labels: { - deferredStop: "Driver deferred backend shutdown", finalStop: "Driver final backend shutdown", start: "Driver backend startup", stop: "Driver backend shutdown", @@ -237,7 +228,7 @@ export class DriverProcess { isShuttingDown: () => this.#runtimeState.isShuttingDown(), permissionRequests: this.#permissionBroker, rememberRunFailure: (error) => { - this.#pendingRunFailure ??= structuredClone(error); + this.rememberPendingRunTerminal({ error, status: "failed" }); this.rememberTerminalCause(new Error(error.message, { cause: error })); }, runtimeContextFactory: (_runtimeSocket, runtimeLogger) => @@ -260,12 +251,15 @@ export class DriverProcess { if (!shutdownAbort) { this.rememberTerminalCause(error); const failure = this.#terminalCause?.error ?? error; - this.#pendingRunFailure ??= { - code: "driver.runtime_failed", - details: {}, - message: failure instanceof Error ? failure.message : "Driver runtime failed.", - retryable: false, - }; + this.rememberPendingRunTerminal({ + error: { + code: "driver.runtime_failed", + details: {}, + message: failure instanceof Error ? failure.message : "Driver runtime failed.", + retryable: false, + }, + status: "failed", + }); if (!this.#runtimeState.isShuttingDown()) { this.#runtimeState.enter("failed"); } @@ -316,19 +310,16 @@ export class DriverProcess { socket.currentRunId() !== null && (reason === "signal.sigint" || reason === "signal.sigterm") ) { - this.#pendingRunCompletion = true; + this.rememberPendingRunTerminal({ status: "completed" }); } if (this.#runtimeState.status() !== "failed" && this.#runtimeState.status() !== "stopped") { this.#runtimeState.enter("stopping"); } - const permissionPending = this.#permissionBroker.hasPending(); const permissionCancellation = this.#permissionBroker.rejectAllAndWait(); this.#shutdownController.abort(new Error(reason)); socket.abortConnect(reason); - // Keep the RPC lane open long enough to publish the lossless permission cancellation. - if (!permissionPending) { - socket.abortPendingRequests(reason); - } + // Backend cleanup owns lossless terminal and committed-file reports, so the + // RPC lane remains open until that ownership barrier settles. // If hello never completed, a gated flush may still be pending; open the // gate so log teardown cannot hang shutdown. @@ -341,22 +332,15 @@ export class DriverProcess { this.#heartbeatLoop.stop(this.#logger, reason); try { - let permissionFailure: { error: unknown } | null = null; - - try { - await permissionCancellation; - } catch (error) { - permissionFailure = { error }; - } - - const backendShutdown = await Promise.allSettled([this.#backendLifecycle?.shutdown(reason)]); - const failure = backendShutdown.find((result) => result.status === "rejected"); + const [permissionResult] = await Promise.allSettled([permissionCancellation]); + const [backendResult] = await Promise.allSettled([this.#backendLifecycle?.shutdown(reason)]); + socket.abortPendingRequests(reason); - if (permissionFailure !== null) { - throw permissionFailure.error; + if (permissionResult.status === "rejected") { + throw permissionResult.reason; } - if (failure?.status === "rejected") { - throw failure.reason; + if (backendResult.status === "rejected") { + throw backendResult.reason; } if (this.#runtimeState.status() === "stopping") { @@ -462,15 +446,13 @@ export class DriverProcess { } } - if (this.#pendingRunFailure !== null && shutdownFailure === null) { + if (this.#pendingRunTerminal !== null && shutdownFailure === null) { try { - await this.reportRunFailure(socket, this.#pendingRunFailure); - } catch (error) { - terminalFailure = { error }; - } - } else if (this.#pendingRunCompletion && shutdownFailure === null) { - try { - await deliverRunTerminal(socket, { status: "completed" }); + if (this.#pendingRunTerminal.status === "failed") { + await this.reportRunFailure(socket, this.#pendingRunTerminal.error); + } else { + await deliverRunTerminal(socket, this.#pendingRunTerminal); + } } catch (error) { terminalFailure = { error }; } @@ -522,7 +504,9 @@ export class DriverProcess { } try { - return await this.#permissionBroker.request(socket, input, signal); + return await this.#permissionBroker.request(socket, input, signal, () => + this.#runtimeState.ownsRun(generation), + ); } finally { this.#runtimeState.endApproval(generation); } @@ -531,19 +515,12 @@ export class DriverProcess { }, ports: { mcp: { - execute: async (command, signal, effect) => { - if (effect === undefined) { - throw new Error("Driver external tool effect ledger is not configured."); - } - - return executeRemoteHttpMcpCommand(this.#startInput, command, signal, effect); - }, - }, - hostIntegration: { - snapshot: async () => this.#hostSnapshot, + prepare: (command, signal) => + prepareRemoteHttpMcpCommand(this.#startInput, command, signal, logger), }, skill: { - materialize: async (execution) => materializeResolvedSkills(execution, logger), + materialize: async (execution, signal) => + materializeResolvedSkills(execution, logger, signal), }, }, }); @@ -567,6 +544,13 @@ export class DriverProcess { this.#terminalCause ??= { error }; } + private rememberPendingRunTerminal(terminal: RunTerminalUpdate): void { + if (this.#pendingRunTerminal?.status === "failed") { + return; + } + this.#pendingRunTerminal = structuredClone(terminal); + } + private throwTerminalCause(): void { if (this.#terminalCause !== null) { throw this.#terminalCause.error; diff --git a/src/contract/common.ts b/src/contract/common.ts index 8b44876..bf4ebbd 100644 --- a/src/contract/common.ts +++ b/src/contract/common.ts @@ -1,11 +1,14 @@ import { z } from "zod"; -export const PROTOCOL_VERSION = 2; +export const PROTOCOL_VERSION = 3; export const protocolVersionSchema = z.literal(PROTOCOL_VERSION); export const timestampSchema = z.iso.datetime({ offset: true }); export const revisionSchema = z.number().int().nonnegative().safe(); -export const protocolIdSchema = z.ulid().transform((value) => value.toUpperCase()); +export const protocolIdSchema = z + .ulid() + .refine((value) => /^[0-7]/u.test(value), "ULID timestamp exceeds its 128-bit range") + .transform((value) => value.toUpperCase()); export const opaqueIdSchema = z.string().min(1).max(256); export const sha256Schema = z.string().regex(/^sha256:[0-9a-f]{64}$/u); export const requestDigestSchema = z.strictObject({ diff --git a/src/contract/reducer.ts b/src/contract/reducer.ts index a95776f..30ae46d 100644 --- a/src/contract/reducer.ts +++ b/src/contract/reducer.ts @@ -1,6 +1,6 @@ import type { CommittedMutation } from "./mutation"; import { committedMutationSchema } from "./mutation"; -import { assertProtocolAdmission, compareTimestamps } from "./common"; +import { PROTOCOL_VERSION, assertProtocolAdmission, compareTimestamps } from "./common"; import type { SyncPayload } from "./sync"; import { syncPayloadSchema } from "./sync"; import type { SessionSnapshot } from "./state"; @@ -169,7 +169,7 @@ function applyMutation(current: SessionSnapshot, mutation: CommittedMutation): S } const next: SessionSnapshot = { - protocolVersion: 2, + protocolVersion: PROTOCOL_VERSION, revision: mutation.revision, capturedAt: mutation.committedAt, session, diff --git a/src/contract/state-validation.ts b/src/contract/state-validation.ts index 0720e6e..2699212 100644 --- a/src/contract/state-validation.ts +++ b/src/contract/state-validation.ts @@ -135,7 +135,14 @@ function assertRunTransition(previous: Run | undefined, next: Run): void { "Run origin fields cannot change.", ); - for (const key of ["cachedInput", "input", "output", "reasoning", "total"] as const) { + for (const key of [ + "cachedInput", + "cachedWrite", + "input", + "output", + "reasoning", + "total", + ] as const) { const prior = previous.usage?.[key]; if (prior === undefined) { diff --git a/src/contract/state.ts b/src/contract/state.ts index 4cd3b3f..a3a0469 100644 --- a/src/contract/state.ts +++ b/src/contract/state.ts @@ -79,6 +79,7 @@ export const tokenUsageSchema = z.strictObject({ input: z.number().int().nonnegative().safe().optional(), output: z.number().int().nonnegative().safe().optional(), cachedInput: z.number().int().nonnegative().safe().optional(), + cachedWrite: z.number().int().nonnegative().safe().optional(), reasoning: z.number().int().nonnegative().safe().optional(), total: z.number().int().nonnegative().safe().optional(), cost: z @@ -291,7 +292,7 @@ export const permissionOptionSchema = z.strictObject({ label: z.string().min(1), description: z.string().optional(), effect: z.enum(["allow", "deny"]), - scope: z.enum(["once", "session"]), + scope: z.enum(["once", "session", "persistent"]), extensions: extensionsSchema.optional(), }); diff --git a/src/core/agent-backend-lifecycle.ts b/src/core/agent-backend-lifecycle.ts index 1422f05..7030b40 100644 --- a/src/core/agent-backend-lifecycle.ts +++ b/src/core/agent-backend-lifecycle.ts @@ -1,11 +1,10 @@ -import { promiseWithTimeout } from "../utils/async"; +import { promiseWithTimeout, settlePromiseWithTimeout } from "../utils/async"; import type { AgentDriverBackend, AgentDriverContext } from "./agent-driver-backend"; export interface AgentBackendLifecycleOptions { readonly backend: AgentDriverBackend; readonly createContext: () => AgentDriverContext; readonly labels: { - readonly deferredStop: string; readonly finalStop: string; readonly start: string; readonly stop: string; @@ -28,6 +27,16 @@ export interface AgentBackendLifecycleOptions { readonly stopTimeoutMs: number; } +type CleanupOwner = { + deadline: number; + deferred: DeferredCleanup | null; + task: Promise; +}; + +type DeferredCleanup = + | { after: Promise; kind: "final_stop" } + | { generation: number; kind: "late_stop"; task: Promise }; + export class AgentBackendLifecycle { readonly #backend: AgentDriverBackend; readonly #createContext: () => AgentDriverContext; @@ -39,12 +48,13 @@ export class AgentBackendLifecycle { readonly #shutdownSignal: AbortSignal; readonly #startTimeoutMs: number; readonly #stopTimeoutMs: number; - #finalStopTask: Promise | null = null; + #cleanupOwner: CleanupOwner | null = null; + #inFlightStop: Promise | null = null; #startController: AbortController | null = null; + #startupStopTask: Promise | null = null; #startTask: Promise | null = null; - #stopController: AbortController | null = null; - #stopNeedsReplay = false; - #stopTask: Promise | null = null; + #stopGeneration = 0; + #stopped = false; constructor(options: AgentBackendLifecycleOptions) { this.#backend = options.backend; @@ -92,135 +102,175 @@ export class AgentBackendLifecycle { } async shutdown(reason: string): Promise { - const startTask = this.#startTask; - const finalStopTask = this.#finalStopTask; - - if (startTask !== null) { - this.#startController?.abort(new Error(reason)); - this.#stopNeedsReplay = true; + if (this.#stopped) { + return; } - if (this.#stopTask === null) { - if (startTask === null) { - this.#stopNeedsReplay = false; - } - this.#stop(reason); - } + await (this.#cleanupOwner ?? this.#createCleanupOwner(reason)).task; + } - const tasks: Promise[] = []; - if (this.#stopTask !== null) { - tasks.push(this.#stopTask); - } - if (startTask !== null) { - tasks.push(startTask.catch(() => {})); - } - if (finalStopTask !== null) { - tasks.push(finalStopTask); + #clearStart(task: Promise): void { + if (this.#startTask === task) { + this.#startController = null; + this.#startTask = null; } + } - try { - await promiseWithTimeout(Promise.all(tasks), { - label: this.#labels.stop, - timeoutMs: this.#stopTimeoutMs, - }); + #createCleanupOwner(reason: string): CleanupOwner { + const startTask = this.#startTask; + this.#startController?.abort(new Error(reason)); - if (this.#stopNeedsReplay) { - this.#stopNeedsReplay = false; - await promiseWithTimeout(this.#stop(reason), { - label: this.#labels.finalStop, - timeoutMs: this.#stopTimeoutMs, - }); - } + const owner: CleanupOwner = { + deadline: Date.now() + this.#stopTimeoutMs, + deferred: null, + task: Promise.resolve(), + }; + owner.task = this.#runCleanup(owner, reason, startTask).then( + () => { + this.#stopped = true; + if (this.#cleanupOwner === owner) { + this.#cleanupOwner = null; + } + }, + (error: unknown) => { + if (this.#cleanupOwner === owner) { + this.#cleanupOwner = null; + } + if (owner.deferred !== null) { + this.#watchDeferredCleanup(owner.deferred, reason); + } + throw error; + }, + ); + this.#cleanupOwner = owner; + return owner; + } - this.#clear(); - } catch (error) { - this.#stopController?.abort(error); - this.#stopController = null; - this.#stopTask = null; - if (this.#finalStopTask === finalStopTask) { - this.#finalStopTask = null; - } - if (startTask !== null && this.#stopNeedsReplay) { - this.#scheduleFinalStop(startTask, reason); - } - throw error; + async #runCleanup( + owner: CleanupOwner, + reason: string, + startTask: Promise | null, + ): Promise { + if (startTask === null) { + await this.#stop(owner, reason, this.#labels.stop, true); + return; + } + + if (this.#startupStopTask !== startTask) { + this.#startupStopTask = startTask; + await this.#stop(owner, reason, this.#labels.stop, false).catch(() => {}); } + + const startResult = await settlePromiseWithTimeout(startTask, { + label: this.#labels.stop, + timeoutMs: this.#remaining(owner), + }); + if (startResult.status === "timed_out") { + const stopSettled = this.#inFlightStop ?? Promise.resolve(); + owner.deferred = { + after: Promise.allSettled([startTask, stopSettled]).then(() => {}), + kind: "final_stop", + }; + throw startResult.error; + } + + await this.#stop(owner, reason, this.#labels.finalStop, true); } - #clear(): void { - this.#finalStopTask = null; - this.#startController = null; - this.#stopController = null; - this.#stopTask = null; + #remaining(owner: CleanupOwner): number { + return Math.max(0, owner.deadline - Date.now()); } - #clearStart(task: Promise): void { - if (this.#startTask === task) { - this.#startController = null; - this.#startTask = null; + async #stop(owner: CleanupOwner, reason: string, label: string, final: boolean): Promise { + const previous = this.#inFlightStop; + if (previous !== null) { + const previousResult = await settlePromiseWithTimeout(previous, { + label, + timeoutMs: this.#remaining(owner), + }); + if (previousResult.status !== "completed") { + if (final && previousResult.status === "timed_out") { + owner.deferred = { after: previous, kind: "final_stop" }; + } + throw previousResult.error; + } } - } - #stop(reason: string): Promise { const controller = new AbortController(); + const generation = (this.#stopGeneration += 1); const task = Promise.resolve().then(() => this.#runStop(this.#backend, this.#createContext(), reason, controller.signal), ); - this.#stopController = controller; - this.#stopTask = task; - void task.then(undefined, () => { - if (this.#stopTask === task) { - this.#stopController = null; - this.#stopTask = null; - } + let settled: Promise; + settled = task.then( + (): void => this.#clearStop(settled), + (): void => this.#clearStop(settled), + ); + this.#inFlightStop = settled; + + const result = await settlePromiseWithTimeout(task, { + label, + timeoutMs: this.#remaining(owner), }); - return task; + if (result.status === "completed") { + return; + } + if (result.status === "timed_out") { + controller.abort(result.error); + if (final) { + owner.deferred = { generation, kind: "late_stop", task }; + } + } + throw result.error; + } + + #clearStop(operation: Promise): void { + if (this.#inFlightStop === operation) { + this.#inFlightStop = null; + } } - #scheduleFinalStop(startTask: Promise, reason: string): void { - if (this.#finalStopTask !== null) { + #watchDeferredCleanup(deferred: DeferredCleanup, reason: string): void { + if (deferred.kind === "final_stop") { + void deferred.after.then(() => this.#runDeferredCleanup(reason)); return; } - let stopTask: Promise | null = null; - let task!: Promise; - task = startTask - .catch(() => {}) - .then(async () => { - if (this.#finalStopTask !== task || !this.#stopNeedsReplay) { + void deferred.task.then( + () => this.#acceptLateStop(deferred.generation), + (error: unknown) => this.#onDeferredStopError?.(error), + ); + } + + async #runDeferredCleanup(reason: string): Promise { + try { + await this.shutdown(reason); + this.#onDeferredStopComplete?.(); + } catch (error) { + this.#onDeferredStopError?.(error); + } + } + + async #acceptLateStop(generation: number): Promise { + const owner = this.#cleanupOwner; + if (owner !== null) { + try { + await owner.task; + this.#onDeferredStopComplete?.(); + return; + } catch (error) { + if (generation !== this.#stopGeneration || this.#inFlightStop !== null) { + this.#onDeferredStopError?.(error); return; } + } + } - this.#stopNeedsReplay = false; - stopTask = this.#stop(reason); - await promiseWithTimeout(stopTask, { - label: this.#labels.deferredStop, - timeoutMs: this.#stopTimeoutMs, - }); + if (generation !== this.#stopGeneration) { + return; + } - if (this.#finalStopTask === task && this.#stopTask === stopTask) { - this.#clear(); - this.#onDeferredStopComplete?.(); - } - }); - this.#finalStopTask = task; - void task.then( - () => { - if (this.#finalStopTask === task) { - this.#finalStopTask = null; - } - }, - (error: unknown) => { - if (this.#finalStopTask === task) { - this.#finalStopTask = null; - } - if (stopTask !== null && this.#stopTask === stopTask) { - this.#stopController?.abort(error); - this.#stopController = null; - this.#stopTask = null; - } - this.#onDeferredStopError?.(error); - }, - ); + this.#stopped = true; + this.#onDeferredStopComplete?.(); } } diff --git a/src/core/agent-driver-backend.ts b/src/core/agent-driver-backend.ts index f6fee50..cdeec52 100644 --- a/src/core/agent-driver-backend.ts +++ b/src/core/agent-driver-backend.ts @@ -1,12 +1,8 @@ import type { AgentDriverCommandSource, AgentDriverEventSink, - AgentDriverFilePort, - AgentDriverHostIntegrationPort, AgentDriverHostPorts, - AgentDriverMcpPort, AgentDriverPermissionPort, - AgentDriverSkillPort, } from "../host-ports"; import type { Logger } from "../observability"; import type { DriverEventInput } from "../protocol/events"; @@ -28,15 +24,7 @@ export interface AgentDriverLifecycle { fail(error: Error): void; } -export type AgentDriverContextPortOverrides = Partial<{ - commandSource: AgentDriverCommandSource; - eventSink: AgentDriverEventSink; - file: AgentDriverFilePort; - hostIntegration: AgentDriverHostIntegrationPort; - mcp: AgentDriverMcpPort; - permission: AgentDriverPermissionPort; - skill: AgentDriverSkillPort; -}>; +export type AgentDriverContextPortOverrides = Partial; export interface AgentDriverContextInput { commandSource?: AgentDriverCommandSource; @@ -65,18 +53,18 @@ function toAgentDriverEventSink( return eventSink; } - const currentRunId = eventSink.currentRunId?.bind(eventSink); - return { claimExternalToolEffect: async () => { throw new Error("Driver external tool effect ledger is not configured."); }, commandUpdate: async () => {}, - completeExternalToolEffect: async () => { + observeExternalToolEffect: async () => { + throw new Error("Driver external tool effect ledger is not configured."); + }, + currentRunId: eventSink.currentRunId.bind(eventSink), + settleExternalToolEffect: async () => { throw new Error("Driver external tool effect ledger is not configured."); }, - ...(currentRunId === undefined ? {} : { currentRunId }), - markExternalToolEffectUnknown: async () => {}, pushEvents: (input) => eventSink.pushEvents(input), }; } @@ -94,7 +82,7 @@ function createDefaultHostPorts(input: AgentDriverContextInput): AgentDriverHost }), eventSink, file: { - reportChanged: async (fileChange) => { + reportChanged: async (fileChange, signal) => { const event = { kind: "file.changed", payload: { @@ -104,14 +92,11 @@ function createDefaultHostPorts(input: AgentDriverContextInput): AgentDriverHost }, } satisfies DriverEventInput; - await pushLosslessEvents(eventSink, [event]); + await pushLosslessEvents(eventSink, [event], undefined, signal); }, }, - hostIntegration: { - snapshot: async () => null, - }, mcp: { - execute: async () => { + prepare: async () => { throw new Error("Driver MCP host port is not configured."); }, }, diff --git a/src/core/agent-driver-kernel.ts b/src/core/agent-driver-kernel.ts index d09880e..2d6bbe4 100644 --- a/src/core/agent-driver-kernel.ts +++ b/src/core/agent-driver-kernel.ts @@ -1,11 +1,16 @@ -import { createBufferedSinkLogger } from "../observability"; +import { createDisabledLogger } from "../observability"; import type { Logger } from "../observability"; import type { DriverEventInput } from "../protocol/events"; import { createDriverId, type RunId } from "../protocol/id"; -import type { DriverEventBatchOutput } from "../protocol/orpc"; +import type { DriverEventBatchOutput, DriverEventReceipt } from "../protocol/orpc"; import type { DriverStartInput } from "../protocol/start"; -import { parseRuntimeCommand } from "../runtime-command"; -import type { RunError, RuntimeCommand, RuntimeCommandResult } from "../runtime-command"; +import { normalizeDurableRunError, parseRuntimeCommand } from "../runtime-command"; +import type { + DriverCommandUpdate, + RunError, + RuntimeCommand, + RuntimeCommandResult, +} from "../runtime-command"; import { AgentBackendLifecycle } from "./agent-backend-lifecycle"; import type { AgentDriverBackendFactory, @@ -13,12 +18,28 @@ import type { AgentDriverContextPortOverrides, } from "./agent-driver-backend"; import { createAgentDriverContext } from "./agent-driver-backend"; +import type { AgentDriverEventSink } from "../host-ports"; import { AsyncValueQueue } from "./async-value-queue"; import { DriverCommandDispatcher } from "./driver-command-dispatcher"; import { DriverPermissionBroker } from "./driver-permission-broker"; import { createDriverPermissionRequestHandler } from "./driver-permission-policy"; -import type { DriverRuntimeExternalToolEffectPort, DriverRuntimeIo } from "./driver-runtime-io"; +import { + assertDriverEventReceiptPrefix, + assertIsolatedRunTerminalBatch, + withSourceEventIds, + type DriverRuntimeExternalToolEffectPort, + type DriverRuntimeIo, + type DriverRunTerminalBarrier, +} from "./driver-runtime-io"; import { DriverRuntimeStateMachine } from "./driver-runtime-state"; +import { + DriverTerminalStateMachine, + type DriverInputOutcome, + type DriverInputSettlement, + type DriverRunSnapshot, + type DriverRunTerminalIdentity, + type DriverRunTicket, +} from "./driver-terminal-state"; export type AgentDriverKernelStartInput = DriverStartInput; export type AgentDriverRuntimeEvent = DriverEventInput; @@ -56,19 +77,7 @@ function jsonBytes(value: unknown): number { return Buffer.byteLength(JSON.stringify(value), "utf8"); } -function createKernelLogger(): Logger { - return createBufferedSinkLogger({ - level: "debug", - service: "agent-driver-kernel", - sink: async () => {}, - }); -} - -function toDispatchError(error: RunError | undefined, command: RuntimeCommand): Error { - if (!error) { - return new Error(`Driver command ${command.kind} failed.`); - } - +function toDispatchError(error: RunError): Error { const dispatchError = new Error(error.message); dispatchError.name = error.code; return dispatchError; @@ -100,18 +109,17 @@ export class AgentDriverKernelCore implements AgentDriverKernel, DriverRuntimeIo readonly #permissionBroker: DriverPermissionBroker; readonly #runtimeState = new DriverRuntimeStateMachine("created"); readonly #shutdownController = new AbortController(); - #activeRunId: RunId | null = null; + readonly #terminalState = new DriverTerminalStateMachine(); #backendLifecycle: AgentBackendLifecycle | null = null; + #eventFinalizationTask: Promise | null = null; + #initialRunId: RunId | null = null; #pushedEventSeq = 0; + #runTicket: DriverRunTicket | null = null; #runTask: Promise | null = null; #runTaskSettled = false; - #runEventTerminal: { - readonly runId: RunId; - readonly status: "cancelled" | "completed" | "failed"; - } | null = null; - #shutdownComplete = false; - #shutdownRunFailure: { error: RunError; runId: RunId | null } | null = null; - #runTerminal: "cancelled" | "completed" | "failed" | null = null; + #runEventTerminalTask: { task: Promise; ticket: DriverRunTicket } | null = + null; + #runTerminalBarrier: DriverRunTerminalBarrier | null = null; #shutdownTask: Promise | null = null; #stopTask: Promise | null = null; #terminalCause: { error: unknown } | null = null; @@ -120,33 +128,40 @@ export class AgentDriverKernelCore implements AgentDriverKernel, DriverRuntimeIo this.#backendFactory = options.backendFactory; this.#externalToolEffectLedger = options.externalToolEffectLedger; this.#hostPorts = options.hostPorts; - this.#logger = options.logger ?? createKernelLogger(); + this.#logger = options.logger ?? createDisabledLogger(); this.#permissionBroker = new DriverPermissionBroker(() => this.#logger); } - beginRun(runId: RunId): void { - this.#activeRunId = runId; - this.#runEventTerminal = null; - this.#runTerminal = null; + beginRun(runId: RunId): DriverRunTicket { + const ticket = this.#terminalState.beginRun(runId); + this.#runTicket = ticket; + return ticket; + } + + claimRunCancellation( + ticket: DriverRunTicket, + reason: string, + source?: Parameters[2], + ): "already_claimed" | "claimed" | "terminal_selected" { + return this.#terminalState.claimCancellation(ticket, reason, source); } async cancel(reason: string): Promise { + const runId = this.currentRunId(); + + if (runId === null) { + throw new Error("Driver has no active run to cancel."); + } + await this.dispatch({ commandId: createDriverId(), kind: "turn.cancel", reason, + runId, }); } - async commandUpdate( - input: { - commandId: string; - error?: RunError; - result?: RuntimeCommandResult; - status: "accepted" | "cancelled" | "completed" | "failed"; - }, - _signal: AbortSignal, - ): Promise { + async commandUpdate(input: DriverCommandUpdate, _signal: AbortSignal): Promise { const update = structuredClone(input); if (update.status === "accepted") { return; @@ -163,11 +178,11 @@ export class AgentDriverKernelCore implements AgentDriverKernel, DriverRuntimeIo this.#commandResults.delete(update.commandId); if (update.status === "failed") { - result.reject(toDispatchError(update.error, command)); + result.reject(toDispatchError(update.error)); return; } - result.resolve(update.result === undefined ? undefined : update.result); + result.resolve(update.status === "completed" ? update.result : undefined); } async claimExternalToolEffect( @@ -177,24 +192,43 @@ export class AgentDriverKernelCore implements AgentDriverKernel, DriverRuntimeIo return this.#requireExternalToolEffectLedger().claimExternalToolEffect(input, signal); } - async completeExternalToolEffect( - input: Parameters[0], + async observeExternalToolEffect( + input: Parameters[0], signal: AbortSignal, - ): Promise { - await this.#requireExternalToolEffectLedger().completeExternalToolEffect(input, signal); + ): ReturnType { + return this.#requireExternalToolEffectLedger().observeExternalToolEffect(input, signal); } async completeRun(): Promise { - if (this.#runTerminal !== null) { + const runId = this.#terminalState.terminalRunId(this.#initialRunId); + if (runId === null) { + throw new Error("Driver run terminal requires an exact run ID."); + } + const terminal = { runId, status: "completed" } as const; + const selection = this.#terminalState.selectInstanceTerminal(terminal); + if (selection === "acked") { return; } - this.#pushTerminalEvent({ - kind: "run.completed", - payload: { - stopReason: "end_turn", - }, - }); - this.#runTerminal = "completed"; + + try { + if ( + this.#terminalState.currentRunId() === null && + this.#terminalState.acknowledgedRunTerminal() === null + ) { + this.#pushTerminalEvent({ + kind: "run.completed", + payload: { + stopReason: "end_turn", + }, + }); + } + } catch (error) { + if (selection === "selected") { + this.#terminalState.abandonInstanceTerminal(terminal); + } + throw error; + } + this.#terminalState.ackInstanceTerminal(terminal); } async dispatch(command: RuntimeCommand): Promise { @@ -212,9 +246,10 @@ export class AgentDriverKernelCore implements AgentDriverKernel, DriverRuntimeIo return result.promise; } - endRun(runId: RunId): void { - if (this.#activeRunId === runId) { - this.#activeRunId = null; + releaseRun(ticket: DriverRunTicket, reason: "command_acked" | "driver_failing"): void { + this.#terminalState.releaseRun(ticket, reason); + if (this.#runTicket === ticket) { + this.#runTicket = null; } } @@ -223,22 +258,43 @@ export class AgentDriverKernelCore implements AgentDriverKernel, DriverRuntimeIo } async failRun(error: RunError): Promise { - this.#publishRunFailure(error, this.#activeRunId); + this.#publishRunFailure(error, this.currentRunId()); } #publishRunFailure(error: RunError, runId: RunId | null): void { - if (this.#runTerminal !== null) { + const durableError = normalizeDurableRunError(error); + const exactRunId = this.#terminalState.terminalRunId(runId ?? this.#initialRunId); + if (exactRunId === null) { + throw new Error("Driver run terminal requires an exact run ID."); + } + const terminal = { + error: structuredClone(durableError), + runId: exactRunId, + status: "failed", + } as const; + const selection = this.#terminalState.selectInstanceTerminal(terminal); + if (selection === "acked") { return; } - this.#pushTerminalEvent({ - kind: "run.failed", - payload: { - error, - recoverable: false, - }, - ...(runId === null ? {} : { runId }), - }); - this.#runTerminal = "failed"; + + try { + if (this.#terminalState.acknowledgedRunTerminal(exactRunId) === null) { + this.#pushTerminalEvent({ + kind: "run.failed", + payload: { + error: durableError, + recoverable: false, + }, + runId: exactRunId, + }); + } + } catch (publishError) { + if (selection === "selected") { + this.#terminalState.abandonInstanceTerminal(terminal); + } + throw publishError; + } + this.#terminalState.ackInstanceTerminal(terminal); } async heartbeat( @@ -256,18 +312,65 @@ export class AgentDriverKernelCore implements AgentDriverKernel, DriverRuntimeIo } currentRunId(): RunId | null { - return this.#activeRunId; + return this.#terminalState.currentRunId(); } - async markExternalToolEffectUnknown( - input: Parameters[0], + async settleExternalToolEffect( + input: Parameters[0], signal: AbortSignal, - ): Promise { - await this.#requireExternalToolEffectLedger().markExternalToolEffectUnknown(input, signal); - } - async pushEvents(input: { events: DriverEventInput[] }): Promise { - const events = structuredClone(input.events); - for (const event of events) { + ): ReturnType { + return this.#requireExternalToolEffectLedger().settleExternalToolEffect(input, signal); + } + + async pushEvents(input: { + events: DriverEventInput[]; + signal?: AbortSignal; + }): Promise { + return this.#pushEvents(input); + } + + async #pushEvents( + input: { events: DriverEventInput[]; signal?: AbortSignal }, + eventSink?: AgentDriverEventSink, + ): Promise { + input.signal?.throwIfAborted(); + const ticket = this.#runTicket; + const selectedTerminal = + ticket === null ? null : this.#terminalState.snapshotRun(ticket.runId)?.terminal?.value; + const ownedEvents = input.events.map((event) => + selectedTerminal !== null && + selectedTerminal !== undefined && + event.sourceEventId === undefined && + (event.kind === "run.cancelled" || + event.kind === "run.completed" || + event.kind === "run.failed") + ? { ...event, sourceEventId: selectedTerminal.sourceEventId } + : event, + ); + const events = structuredClone(withSourceEventIds(ownedEvents)); + assertIsolatedRunTerminalBatch(events); + const barrier = this.#runTerminalBarrier; + if (barrier !== null) { + const pending = barrier(events); + if (pending !== undefined) { + await pending; + } + } + const activeRunId = this.currentRunId(); + let hasRunScopedEvent = false; + let terminal: DriverRunTerminalIdentity | null = null; + let terminalIndex = -1; + + for (const [index, event] of events.entries()) { + const runId = event.runId === undefined ? activeRunId : event.runId; + + if (runId !== null) { + if (runId !== activeRunId) { + throw new Error("Driver event must target the active run."); + } + hasRunScopedEvent = true; + } + const status = event.kind === "run.cancelled" ? "cancelled" @@ -276,27 +379,146 @@ export class AgentDriverKernelCore implements AgentDriverKernel, DriverRuntimeIo : event.kind === "run.failed" ? "failed" : null; - const runId = event.runId ?? this.#activeRunId; - if (status !== null && runId !== null) { - this.#runEventTerminal = { runId, status }; - this.#runTerminal = status; + + if (status === null) { + continue; + } + + if (runId === null) { + throw new Error("Driver run terminal must target the active run."); + } + + if (event.delivery === "best_effort") { + throw new Error("Driver run terminal must be lossless."); + } + + if (terminal !== null) { + throw new Error("Driver event batch cannot contain multiple run terminals."); } - } - this.#events.pushMany(events); - const accepted = events.map((event) => { - this.#pushedEventSeq += 1; - return { - seq: this.#pushedEventSeq, - type: event.kind, + terminal = { + event, + runId, + sourceEventId: event.sourceEventId!, + status, }; - }); + terminalIndex = index; + } + + if (terminalIndex >= 0 && terminalIndex !== events.length - 1) { + throw new Error("Driver run terminal must be the final event in its batch."); + } + + let terminalSelection: "acked" | "cancelled" | "pending" | "selected" | null = null; + if (terminal !== null) { + if (ticket === null) { + throw new Error("Driver run terminal must target the active run."); + } + + terminalSelection = this.#terminalState.selectRunTerminal(ticket, terminal); + if (terminalSelection === "cancelled") { + ticket.signal.throwIfAborted(); + throw new Error("Driver run terminal lost to cancellation."); + } + if (terminalSelection === "acked") { + const selected = this.#terminalState.snapshotRun(ticket.runId)?.terminal; + if (events.length !== 1 || selected?.phase !== "acked") { + throw new Error("Driver acknowledged terminal retry must contain only that terminal."); + } + return { accepted: [selected.receipt] }; + } + if (terminalSelection === "pending" && this.#runEventTerminalTask !== null) { + if (this.#runEventTerminalTask.ticket !== ticket) { + throw new Error("Driver active run changed during terminal delivery."); + } + return { accepted: [await this.#runEventTerminalTask.task] }; + } + } else if (hasRunScopedEvent && this.#terminalState.snapshotRun()?.terminal !== null) { + throw new Error("Driver event cannot target a terminated run."); + } + + let delivery: Promise; + if (eventSink === undefined) { + try { + this.#events.pushMany(events); + } catch (error) { + if (terminal !== null && terminalSelection === "selected") { + this.#terminalState.abandonRunTerminal(ticket!, terminal); + } + throw error; + } + delivery = Promise.resolve({ + accepted: events.map((event) => { + this.#pushedEventSeq += 1; + return { + eventId: event.sourceEventId!, + seq: this.#pushedEventSeq, + type: event.kind, + }; + }), + }); + } else { + delivery = eventSink.pushEvents({ + events, + ...(input.signal === undefined ? {} : { signal: input.signal }), + }); + } + + const terminalTask = + terminal === null || ticket === null + ? null + : { + task: delivery.then((result) => { + assertDriverEventReceiptPrefix(events, result.accepted); + if (result.accepted.length !== events.length) { + throw new Error("Driver run terminal was not fully acknowledged."); + } + const receipt = result.accepted[terminalIndex]; + if (receipt === undefined) { + throw new Error("Driver run terminal receipt is missing."); + } + this.#terminalState.ackRunTerminal(ticket, receipt); + return receipt; + }), + ticket, + }; + + if (terminalTask !== null) { + this.#runEventTerminalTask = terminalTask; + void terminalTask.task.catch(() => {}); + } + + try { + const result = await delivery; + assertDriverEventReceiptPrefix(events, result.accepted); + await terminalTask?.task; + return result; + } finally { + if (this.#runEventTerminalTask === terminalTask) { + this.#runEventTerminalTask = null; + } + } + } + + registerRunTerminalBarrier(barrier: DriverRunTerminalBarrier): () => void { + if (this.#runTerminalBarrier !== null) { + throw new Error("Driver run terminal barrier is already registered."); + } + + this.#runTerminalBarrier = barrier; + return () => { + if (this.#runTerminalBarrier === barrier) { + this.#runTerminalBarrier = null; + } + }; + } - return { accepted }; + runSnapshot(runId?: RunId): DriverRunSnapshot | null { + return this.#terminalState.snapshotRun(runId); } - runEventTerminal(runId: RunId): "cancelled" | "completed" | "failed" | null { - return this.#runEventTerminal?.runId === runId ? this.#runEventTerminal.status : null; + settleRunInput(ticket: DriverRunTicket, outcome: DriverInputOutcome): DriverInputSettlement { + return this.#terminalState.settleInput(ticket, outcome); } async start(input: AgentDriverKernelStartInput): Promise { @@ -305,6 +527,7 @@ export class AgentDriverKernelCore implements AgentDriverKernel, DriverRuntimeIo } const admitted = structuredClone(input); + this.#initialRunId = admitted.execution.run.runId; this.#runtimeState.enter("starting"); await this.#start(admitted); } @@ -325,13 +548,14 @@ export class AgentDriverKernelCore implements AgentDriverKernel, DriverRuntimeIo backend, createContext: () => this.#createContext(input), labels: { - deferredStop: "Driver kernel deferred backend shutdown", finalStop: "Driver kernel final backend shutdown", start: "Driver kernel backend startup", stop: "Driver kernel backend shutdown", }, onDeferredStopComplete: () => { - void this.#closeEventsAfterPermissions(); + void this.#completeShutdown().catch((error: unknown) => { + this.#logger.error("driver.kernel.deferred_finalization.failed", error, {}); + }); }, onDeferredStopError: (error) => { this.#logger.error("driver.kernel.deferred_shutdown.failed", error, {}); @@ -391,9 +615,7 @@ export class AgentDriverKernelCore implements AgentDriverKernel, DriverRuntimeIo this.#rejectPendingCommands( this.#terminalCause?.error ?? new Error("Driver kernel stopped."), ); - if (this.#shutdownComplete) { - await this.#finalizeEvents(); - } + await this.#finalizeShutdown(); }); } catch (error) { this.#rememberTerminalCause(error); @@ -473,9 +695,15 @@ export class AgentDriverKernelCore implements AgentDriverKernel, DriverRuntimeIo } #createContext(payload: DriverStartInput): AgentDriverContext { + const customEventSink = this.#hostPorts?.eventSink; + const hostPorts = + customEventSink === undefined + ? this.#hostPorts + : { ...this.#hostPorts, eventSink: this.#guardEventSink(customEventSink) }; + return createAgentDriverContext({ eventSink: this, - ...(this.#hostPorts === undefined ? {} : { ports: this.#hostPorts }), + ...(hostPorts === undefined ? {} : { ports: hostPorts }), lifecycle: { fail: (error) => this.#onBackendFailure(error), }, @@ -491,7 +719,9 @@ export class AgentDriverKernelCore implements AgentDriverKernel, DriverRuntimeIo } try { - return await this.#permissionBroker.request(this, input, signal); + return await this.#permissionBroker.request(this, input, signal, () => + this.#runtimeState.ownsRun(generation), + ); } finally { this.#runtimeState.endApproval(generation); } @@ -501,6 +731,39 @@ export class AgentDriverKernelCore implements AgentDriverKernel, DriverRuntimeIo }); } + #guardEventSink(eventSink: AgentDriverEventSink): AgentDriverEventSink { + const claimExternalToolEffect = eventSink.claimExternalToolEffect; + const observeExternalToolEffect = eventSink.observeExternalToolEffect; + const settleExternalToolEffect = eventSink.settleExternalToolEffect; + + return { + ...(claimExternalToolEffect === undefined + ? {} + : { + claimExternalToolEffect: (input, signal) => + claimExternalToolEffect.call(eventSink, input, signal), + }), + commandUpdate: async (input, signal) => { + await eventSink.commandUpdate(input, signal); + await this.commandUpdate(input, signal); + }, + currentRunId: () => this.currentRunId(), + ...(observeExternalToolEffect === undefined + ? {} + : { + observeExternalToolEffect: (input, signal) => + observeExternalToolEffect.call(eventSink, input, signal), + }), + pushEvents: (input) => this.#pushEvents(input, eventSink), + ...(settleExternalToolEffect === undefined + ? {} + : { + settleExternalToolEffect: (input, signal) => + settleExternalToolEffect.call(eventSink, input, signal), + }), + }; + } + #requireExternalToolEffectLedger(): DriverRuntimeExternalToolEffectPort { if (this.#externalToolEffectLedger === undefined) { throw new Error( @@ -534,7 +797,7 @@ export class AgentDriverKernelCore implements AgentDriverKernel, DriverRuntimeIo this.#runtimeState.enter("failed"); this.#rejectPendingCommands(error); - if (this.#activeRunId !== null) { + if (this.currentRunId() !== null) { this.#rememberRunFailure({ code: "driver.runtime_failed", details: {}, @@ -562,10 +825,7 @@ export class AgentDriverKernelCore implements AgentDriverKernel, DriverRuntimeIo } #rememberRunFailure(error: RunError): void { - this.#shutdownRunFailure ??= { - error: structuredClone(error), - runId: this.#activeRunId, - }; + this.#terminalState.recordFailure(error); } #pushTerminalEvent(event: AgentDriverRuntimeEvent): void { @@ -589,12 +849,41 @@ export class AgentDriverKernelCore implements AgentDriverKernel, DriverRuntimeIo } async #finalizeEvents(): Promise { - if (this.#shutdownRunFailure !== null) { - this.#publishRunFailure(this.#shutdownRunFailure.error, this.#shutdownRunFailure.runId); + const failure = this.#terminalState.shutdownSnapshot()?.failure; + const instanceTerminal = this.#terminalState.snapshotInstance(); + if ( + failure !== null && + failure !== undefined && + (instanceTerminal.phase === "open" || instanceTerminal.terminal.status === "failed") + ) { + this.#publishRunFailure(failure.error, failure.runId); } await this.#closeEventsAfterPermissions(); } + async #completeShutdown(): Promise { + if (this.#runtimeState.status() === "stopping") { + this.#runtimeState.enter("stopped"); + } + this.#backendLifecycle = null; + this.#terminalState.markCleanupCompleted(); + await this.#finalizeShutdown(); + } + + async #finalizeShutdown(): Promise { + if ( + this.#terminalState.shutdownSnapshot()?.cleanup !== "completed" || + (this.#runTask !== null && !this.#runTaskSettled) + ) { + return; + } + + await (this.#eventFinalizationTask ??= this.#finalizeEvents().catch((error: unknown) => { + this.#eventFinalizationTask = null; + throw error; + })); + } + #shutdown(reason: string): Promise { return (this.#shutdownTask ??= this.#runShutdown(reason).catch((error: unknown) => { this.#shutdownTask = null; @@ -603,7 +892,9 @@ export class AgentDriverKernelCore implements AgentDriverKernel, DriverRuntimeIo } async #runShutdown(reason: string): Promise { + this.#terminalState.requestShutdown(); if (this.#runtimeState.status() === "stopped") { + await this.#finalizeShutdown(); return; } @@ -615,32 +906,17 @@ export class AgentDriverKernelCore implements AgentDriverKernel, DriverRuntimeIo this.#commands.close({ discard: true }); try { - let permissionFailure: { error: unknown } | null = null; - - try { - await permissionCancellation; - } catch (error) { - permissionFailure = { error }; - } - - const backendShutdown = await Promise.allSettled([this.#backendLifecycle?.shutdown(reason)]); - const failure = backendShutdown.find((result) => result.status === "rejected"); + const [permissionResult] = await Promise.allSettled([permissionCancellation]); + const [backendResult] = await Promise.allSettled([this.#backendLifecycle?.shutdown(reason)]); - if (permissionFailure !== null) { - throw permissionFailure.error; + if (permissionResult.status === "rejected") { + throw permissionResult.reason; } - if (failure?.status === "rejected") { - throw failure.reason; + if (backendResult.status === "rejected") { + throw backendResult.reason; } - if (this.#runtimeState.status() === "stopping") { - this.#runtimeState.enter("stopped"); - } - this.#backendLifecycle = null; - this.#shutdownComplete = true; - if (this.#runTask === null || this.#runTaskSettled) { - await this.#finalizeEvents(); - } + await this.#completeShutdown(); } catch (error) { if (this.#runtimeState.status() === "stopping") { this.#runtimeState.enter("failed"); diff --git a/src/core/driver-command-delivery.ts b/src/core/driver-command-delivery.ts index 16bd819..8564bd2 100644 --- a/src/core/driver-command-delivery.ts +++ b/src/core/driver-command-delivery.ts @@ -14,36 +14,25 @@ const COMMAND_UPDATE_TIMEOUT_MS = 1_000; const RUN_TERMINAL_UPDATE_ATTEMPT_TIMEOUT_MS = 250; const TERMINAL_UPDATE_MAX_ATTEMPTS = 3; -export interface TerminalCommandUpdate { - error?: RunError; - result?: RuntimeCommandResult; - status: "cancelled" | "completed" | "failed"; -} - -export type RunTerminalUpdate = +export type TerminalCommandUpdate = + | { readonly status: "cancelled" } | { - status: "completed"; + readonly result?: Exclude | undefined; + readonly status: "completed"; } - | { - error: RunError; - status: "failed"; - }; + | { readonly error: RunError; readonly status: "failed" }; -type RunTerminalDelivery = +export type RunTerminalUpdate = | { - delivered: boolean; status: "completed"; - task?: Promise; } | { - delivered: boolean; error: RunError; status: "failed"; - task?: Promise; }; export interface TrackedCommand { - readonly command: RuntimeCommand; + readonly identity: unknown; delivery: Promise; terminal?: TerminalCommandUpdate; terminalTask?: Promise; @@ -70,25 +59,19 @@ function toErrorMessage(error: unknown, fallback: string): string { async function sendCommandUpdate( runtimeContext: AgentDriverContext, command: RuntimeCommand, - update: { - error?: RunError; - result?: RuntimeCommandResult; - status: "accepted" | "cancelled" | "completed" | "failed"; - }, + update: { readonly status: "accepted" } | TerminalCommandUpdate, signal: AbortSignal, ): Promise { - const delivery = structuredClone({ - commandId: command.commandId, - ...(update.error === undefined ? {} : { error: update.error }), - ...(update.result === undefined ? {} : { result: update.result }), - status: update.status, - }); + const delivery = structuredClone({ commandId: command.commandId, ...update }); await raceWithAbort(runtimeContext.ports.eventSink.commandUpdate(delivery, signal), signal); runtimeContext.logger.debug("driver.runtime.command.status.sent", { command: summarizeRuntimeCommand(command), - ...(update.error ? { error: update.error } : {}), - result: update.result ? summarizeRuntimeCommandResult(update.result) : null, + ...(update.status === "failed" ? { error: update.error } : {}), + result: + update.status === "completed" && update.result !== undefined + ? summarizeRuntimeCommandResult(update.result) + : null, status: update.status, }); } @@ -96,22 +79,16 @@ async function sendCommandUpdate( export class DriverCommandDelivery { readonly #shutdownSignal: AbortSignal; readonly #trackedCommands = new Map(); - #runTerminal: RunTerminalDelivery | null = null; constructor(shutdownSignal: AbortSignal) { this.#shutdownSignal = shutdownSignal; } - receive(command: RuntimeCommand): CommandReceipt { - const tracked = this.#trackedCommands.get(command.commandId); + receive(command: RuntimeCommand, identity: unknown = command): CommandReceipt { + const replay = this.replay(command, identity); - if (tracked !== undefined) { - if (!isDeepStrictEqual(tracked.command, command)) { - throw new Error( - `Driver command ${command.commandId} was replayed with changed identity or content.`, - ); - } - return { replay: true, tracked }; + if (replay !== null) { + return replay; } if (this.#trackedCommands.size >= MAX_TRACKED_COMMANDS) { @@ -119,19 +96,37 @@ export class DriverCommandDelivery { } const added: TrackedCommand = { - command: structuredClone(command), delivery: Promise.resolve(), + identity: structuredClone(identity), }; this.#trackedCommands.set(command.commandId, added); return { replay: false, tracked: added }; } + replay(command: RuntimeCommand, identity: unknown = command): CommandReceipt | null { + const tracked = this.#trackedCommands.get(command.commandId); + + if (tracked !== undefined) { + if (!isDeepStrictEqual(tracked.identity, identity)) { + throw new Error( + `Driver command ${command.commandId} was replayed with changed identity or content.`, + ); + } + return { replay: true, tracked }; + } + return null; + } + hasTerminal(commandId: string): boolean { return this.#trackedCommands.get(commandId)?.terminal !== undefined; } - resetRunTerminal(): void { - this.#runTerminal = null; + reject( + runtimeContext: AgentDriverContext, + command: RuntimeCommand, + update: TerminalCommandUpdate, + ): Promise { + return this.#deliverTerminal(runtimeContext, command, structuredClone(update)); } accept( @@ -199,59 +194,6 @@ export class DriverCommandDelivery { await task; } - claimRunTerminal( - socket: DriverRuntimeIo, - status: "completed" | "failed", - error?: RunError, - ): Promise { - let terminal = this.#runTerminal; - - if (terminal === null) { - if (status === "failed" && error === undefined) { - return Promise.reject(new Error("Failed run terminal requires an error.")); - } - - terminal = - status === "completed" - ? { delivered: false, status } - : { delivered: false, error: structuredClone(error!), status }; - this.#runTerminal = terminal; - } - - if (terminal.status !== status) { - return Promise.resolve(); - } - if ( - terminal.status === "failed" && - (error === undefined || !isDeepStrictEqual(terminal.error, error)) - ) { - return Promise.reject(new Error("Failed run terminal was retried with a different error.")); - } - if (terminal.delivered) { - return Promise.resolve(); - } - if (terminal.task !== undefined) { - return terminal.task; - } - - const task = deliverRunTerminal(socket, terminal); - terminal.task = task; - void task.then( - () => { - if (terminal.task === task) { - terminal.delivered = true; - delete terminal.task; - } - }, - () => { - if (terminal.task === task) { - delete terminal.task; - } - }, - ); - return task; - } - async #deliverTerminal( runtimeContext: AgentDriverContext, command: RuntimeCommand, diff --git a/src/core/driver-command-dispatcher.ts b/src/core/driver-command-dispatcher.ts index ab93d89..2b5c9db 100644 --- a/src/core/driver-command-dispatcher.ts +++ b/src/core/driver-command-dispatcher.ts @@ -1,25 +1,49 @@ +import { randomUUID } from "node:crypto"; + +import { AGENT_DRIVER_MCP_EXECUTE_TIMEOUT_MS } from "../host-ports"; import { createScopedWideEvent, emitWideEvent } from "../observability"; import type { Logger } from "../observability"; import { summarizeRuntimeCommand } from "../observability/driver-debug"; -import { parseDriverId } from "../protocol/id"; +import { createMcpExecuteFailedEventIdentity } from "../protocol/events"; +import type { DriverEventInput } from "../protocol/events"; +import { parseRunId } from "../protocol/id"; import type { RunId } from "../protocol/id"; -import type { McpExecuteCommandResult, RunError, RuntimeCommand } from "../runtime-command"; +import type { + McpExecuteCommandResult, + McpExternalToolEffectSettlement, + McpExternalToolEffectState, + RunError, + RuntimeCommand, +} from "../runtime-command"; +import { + createMcpUnknownEffectRunError, + createMcpUnsettledEffectRunError, + normalizeDurableRunError, + parseRuntimeCommand, +} from "../runtime-command"; import { promiseWithTimeout, raceWithAbort, sleepPromise } from "../utils/async"; import type { AgentDriverBackend, AgentDriverContext } from "./agent-driver-backend"; -import { DriverCommandDelivery, TerminalCommandDeliveryError } from "./driver-command-delivery"; +import { + type CommandReceipt, + deliverRunTerminal, + DriverCommandDelivery, + TerminalCommandDeliveryError, +} from "./driver-command-delivery"; import { pushDriverDiagnosticEvent } from "./driver-diagnostics"; +import type { DriverPermissionBroker } from "./driver-permission-broker"; import { - PermissionEventDeliveryError, - type DriverPermissionBroker, -} from "./driver-permission-broker"; -import { pushLosslessEvents } from "./driver-runtime-io"; -import type { DriverRuntimeIo } from "./driver-runtime-io"; -import type { DriverRuntimeStateMachine } from "./driver-runtime-state"; + createDurableMcpSucceededSettlement, + requireDurableMcpResultIdentity, +} from "./external-tool-effect-settlement"; import { - DriverTurnCancellationCleanupError, - DriverTurnCancelledError, - isDriverTurnCancelledError, -} from "./driver-runtime-state"; + DRIVER_EVENT_DELIVERY_TIMEOUT_MS, + pushLosslessEvents, + type DriverRuntimeIo, +} from "./driver-runtime-io"; +import type { DriverRuntimeStateMachine } from "./driver-runtime-state"; +import { isDriverTurnCancelledError } from "./driver-runtime-state"; +import type { DriverInputOutcome, DriverRunTicket } from "./driver-terminal-state"; +import type { DriverTurnCancellationSource } from "./driver-turn-cancelled-error"; interface DriverCommandDispatcherOptions { backend: AgentDriverBackend; @@ -35,22 +59,44 @@ interface DriverCommandDispatcherOptions { } const COMMAND_POLL_INTERVAL_MS = 250; -const ACTIVE_INPUT_SETTLE_GRACE_MS = 5_000; export const ACTIVE_TURN_CANCEL_GRACE_MS = 2_000; +// Fault-containment ceiling, not a normal cancellation SLA: an admitted turn can +// legitimately cross several independent durable-delivery and cleanup epochs. +export const ACTIVE_INPUT_SETTLE_GRACE_MS = DRIVER_EVENT_DELIVERY_TIMEOUT_MS * 9; const EXTERNAL_TOOL_EFFECT_FENCE_TIMEOUT_MS = 2_000; +// The remote disposer can spend three independent two-second epochs terminating, +// retrying, and closing before this end-to-end fault-containment ceiling applies. +const MCP_PREPARED_DISPOSE_TIMEOUT_MS = 7_000; +export const ACTIVE_MCP_COMMIT_GRACE_MS = AGENT_DRIVER_MCP_EXECUTE_TIMEOUT_MS + 30_000; const MAX_ACTIVE_MCP_COMMANDS = 32; interface ActiveMcpCommand { readonly controller: AbortController; + readonly runId: RunId; readonly task: Promise; } +interface ActiveMcpAdmission { + readonly runId: RunId; + readonly settled: Promise; + settle(): void; +} + +interface RunTerminalEvent { + readonly kind: "run.cancelled" | "run.completed" | "run.failed"; + readonly runId: RunId; +} + function isFatalCommand(command: RuntimeCommand): boolean { return command.kind === "input.start" || command.kind === "turn.cancel"; } function toCommandFailure(command: RuntimeCommand, error: unknown): RunError { - return { + if (error instanceof ExternalToolEffectUnknownError) { + return normalizeDurableRunError(error.failure); + } + + return normalizeDurableRunError({ code: `driver.command_failed.${command.kind}`, details: { commandId: command.commandId, @@ -58,24 +104,45 @@ function toCommandFailure(command: RuntimeCommand, error: unknown): RunError { }, message: error instanceof Error ? error.message : `Driver command ${command.kind} failed.`, retryable: false, - }; + }); } function toErrorMessage(error: unknown, fallback: string): string { return error instanceof Error ? error.message : fallback; } -class ExternalToolEffectResolutionRequiredError extends Error { +class ExternalToolEffectUnknownError extends Error { + readonly failure: RunError; + constructor(command: Extract, effectId: string) { - super( - `External effect ${effectId} for MCP tool ${command.toolName} is unknown. Verify the provider outcome and explicitly decide whether to create a new action; this command will never replay automatically.`, - ); - this.name = "ExternalToolEffectResolutionRequiredError"; + const failure = createMcpUnknownEffectRunError(command, effectId); + super(failure.message); + this.name = "ExternalToolEffectUnknownError"; + this.failure = failure; + } +} + +class ExternalToolEffectUnsettledError extends Error { + readonly failure: RunError; + + constructor( + command: Extract, + effectId: string, + cause?: unknown, + ) { + const failure = createMcpUnsettledEffectRunError(command, effectId); + super(failure.message, cause === undefined ? undefined : { cause }); + this.name = "ExternalToolEffectUnsettledError"; + this.failure = failure; } } -function parseRunId(value: string): RunId { - return parseDriverId(value, "Run ID") as RunId; +async function retryOnce(operation: () => Promise): Promise { + try { + return await operation(); + } catch { + return operation(); + } } async function settleInput(activeRunTask: Promise): Promise { @@ -97,10 +164,15 @@ export class DriverCommandDispatcher { readonly #shutdownSignal: AbortSignal; readonly #shutdown: (socket: DriverRuntimeIo, reason: string) => Promise; readonly #commandDelivery: DriverCommandDelivery; + #activeWorkFenceFailure: { error: unknown } | null = null; #activeWorkFailure: { error: unknown } | null = null; - #activeInputCancellation: AbortController | null = null; + #activeRunTicket: DriverRunTicket | null = null; + readonly #activeMcpAdmissions = new Set(); readonly #activeMcpCommands = new Map(); + readonly #mcpRunFailures = new Map(); + readonly #terminalRunFences = new Set(); #activeRunGeneration = 0; + #activeRunSettleTask: Promise | null = null; #activeRunTask: Promise | null = null; #shutdownCompleted = false; #shutdownPermissionTask: Promise | null = null; @@ -121,12 +193,15 @@ export class DriverCommandDispatcher { async run(socket: DriverRuntimeIo, logger: Logger): Promise { const runtimeContext = this.#runtimeContextFactory(socket, logger); + const unregisterRunTerminalBarrier = socket.registerRunTerminalBarrier((events) => + this.#waitForRunTerminal(socket, events), + ); let joiningActiveWork = false; const onShutdown = () => { - this.#shutdownPermissionTask = this.#permissionRequests.rejectAllAndWait(); - void this.#shutdownPermissionTask.catch(() => {}); this.#abortActiveWork( + socket, toErrorMessage(this.#shutdownSignal.reason, "driver.command_loop.stopped"), + "shutdown", ); }; this.#shutdownSignal.addEventListener("abort", onShutdown, { once: true }); @@ -153,12 +228,15 @@ export class DriverCommandDispatcher { await logger.span("driver.command-loop", async () => { while (!this.#isShuttingDown()) { let command: RuntimeCommand | null; + let commandIdentity: unknown; try { - command = await raceWithAbort( + const candidate = await raceWithAbort( runtimeContext.ports.commandSource.nextCommand(this.#shutdownSignal), this.#shutdownSignal, ); + command = candidate === null ? null : parseRuntimeCommand(candidate); + commandIdentity = candidate; } catch (error) { if (this.#isShuttingDown()) { return; @@ -176,15 +254,19 @@ export class DriverCommandDispatcher { continue; } - await this.#handleCommand(runtimeContext, socket, command); + await this.#handleCommand(runtimeContext, socket, command, commandIdentity); if (this.#isShuttingDown()) { return; } } }); - if (this.#runtimeState.isShuttingDown()) { - this.#abortActiveWork("driver.command_loop.stopped"); + if ( + this.#shutdownSignal.aborted || + this.#isShuttingDown() || + this.#runtimeState.isShuttingDown() + ) { + this.#abortActiveWork(socket, "driver.command_loop.stopped", "shutdown"); joiningActiveWork = true; await this.#joinActiveWork(); joiningActiveWork = false; @@ -203,7 +285,9 @@ export class DriverCommandDispatcher { this.#isShuttingDown() || this.#runtimeState.isShuttingDown()); this.#abortActiveWork( + socket, shuttingDown ? "driver.command_loop.stopped" : "driver.command_loop.failed", + "shutdown", ); if (shuttingDown) { @@ -292,17 +376,204 @@ export class DriverCommandDispatcher { throw failure; } finally { this.#shutdownSignal.removeEventListener("abort", onShutdown); + unregisterRunTerminalBarrier(); + } + } + + #waitForRunTerminal( + socket: DriverRuntimeIo, + events: readonly DriverEventInput[], + ): Promise | void { + const terminal = this.#readRunTerminal(socket, events); + + if (terminal === null) { + return; } + + this.#terminalRunFences.add(terminal.runId); + const permissions = this.#permissionRequests.rejectRunAndWait(terminal.runId); + const hasWork = + [...this.#activeMcpAdmissions].some((admission) => admission.runId === terminal.runId) || + [...this.#activeMcpCommands.values()].some((command) => command.runId === terminal.runId); + + if (hasWork) { + return permissions === undefined + ? this.#joinRunMcpWork(terminal) + : Promise.all([permissions, this.#joinRunMcpWork(terminal)]).then(() => undefined); + } + + const failure = this.#mcpRunFailures.get(terminal.runId); + if (permissions === undefined) { + if (failure !== undefined && terminal.kind !== "run.failed") { + throw failure.error; + } + return; + } + return permissions.then(() => { + if (failure !== undefined && terminal.kind !== "run.failed") { + throw failure.error; + } + }); + } + + #readRunTerminal( + socket: DriverRuntimeIo, + events: readonly DriverEventInput[], + ): RunTerminalEvent | null { + let terminal: RunTerminalEvent | null = null; + let terminalIndex = -1; + + for (const [index, event] of events.entries()) { + if ( + event.kind !== "run.cancelled" && + event.kind !== "run.completed" && + event.kind !== "run.failed" + ) { + continue; + } + if (terminal !== null) { + throw new Error("Driver event batch cannot contain multiple run terminals."); + } + + const runId = event.runId === undefined ? socket.currentRunId() : event.runId; + if (runId === null) { + throw new Error("Driver run terminal requires an active run."); + } + + terminal = { kind: event.kind, runId: parseRunId(runId) }; + terminalIndex = index; + } + + if (terminalIndex >= 0 && terminalIndex !== events.length - 1) { + throw new Error("Driver run terminal must be the final event in its batch."); + } + + return terminal; + } + + #beginMcpAdmission(runId: RunId): ActiveMcpAdmission | null { + if (this.#terminalRunFences.has(runId)) { + return null; + } + + const settlement = Promise.withResolvers(); + const admission: ActiveMcpAdmission = { + runId, + settle: () => settlement.resolve(), + settled: settlement.promise, + }; + this.#activeMcpAdmissions.add(admission); + return admission; + } + + async #joinRunMcpWork(terminal: RunTerminalEvent): Promise { + while (true) { + const admissions = [...this.#activeMcpAdmissions] + .filter((admission) => admission.runId === terminal.runId) + .map((admission) => admission.settled); + const commands = [...this.#activeMcpCommands.values()] + .filter((command) => command.runId === terminal.runId) + .map((command) => command.task); + const work = [...admissions, ...commands]; + + if (work.length === 0) { + break; + } + + await promiseWithTimeout(Promise.allSettled(work), { + label: `Driver MCP commands for run ${terminal.runId}`, + timeoutMs: ACTIVE_MCP_COMMIT_GRACE_MS, + }); + } + + const failure = this.#mcpRunFailures.get(terminal.runId); + if (failure !== undefined && terminal.kind !== "run.failed") { + throw failure.error; + } + } + + #clearRunMcpState(socket: DriverRuntimeIo, runId: RunId): void { + if ( + socket.currentRunId() === runId || + [...this.#activeMcpAdmissions].some((admission) => admission.runId === runId) || + [...this.#activeMcpCommands.values()].some((command) => command.runId === runId) + ) { + return; + } + + this.#mcpRunFailures.delete(runId); + this.#terminalRunFences.delete(runId); } async #handleCommand( runtimeContext: AgentDriverContext, socket: DriverRuntimeIo, command: RuntimeCommand, + commandIdentity: unknown, ): Promise { const commandSummary = summarizeRuntimeCommand(command); + const replay = this.#commandDelivery.replay(command, commandIdentity); + const mcpAdmission = + command.kind === "mcp.execute" && replay === null + ? this.#beginMcpAdmission(parseRunId(command.runId)) + : undefined; + + try { + await this.#handleAdmittedCommand( + runtimeContext, + socket, + command, + commandIdentity, + commandSummary, + replay, + mcpAdmission !== null, + ); + } catch (error) { + if (mcpAdmission !== undefined && mcpAdmission !== null) { + this.#mcpRunFailures.set(mcpAdmission.runId, { error }); + this.#terminalRunFences.add(mcpAdmission.runId); + } + throw error; + } finally { + if (mcpAdmission !== undefined && mcpAdmission !== null) { + this.#activeMcpAdmissions.delete(mcpAdmission); + mcpAdmission.settle(); + } + } + } + + async #handleAdmittedCommand( + runtimeContext: AgentDriverContext, + socket: DriverRuntimeIo, + command: RuntimeCommand, + commandIdentity: unknown, + commandSummary: ReturnType, + replay: CommandReceipt | null, + mcpAdmissionGranted: boolean, + ): Promise { + const replayMode = + replay === null ? null : replay.tracked.terminal === undefined ? "active" : "terminal"; + + try { + if (command.kind === "mcp.execute" && replay === null && !mcpAdmissionGranted) { + throw new Error(`Run ${command.runId} is already publishing its terminal event.`); + } + this.#assertCommandRunOwnership(socket, command, replayMode); + } catch (error) { + await this.#commandDelivery.reject(runtimeContext, command, { + error: toCommandFailure(command, error), + status: "failed", + }); + runtimeContext.logger.warn("driver.runtime.command.run-rejected", { + commandId: command.commandId, + commandKind: command.kind, + message: toErrorMessage(error, "Driver command does not target the active run."), + }); + return; + } + runtimeContext.logger.debug("driver.runtime.command.received", commandSummary); - const receipt = this.#commandDelivery.receive(command); + const receipt = replay ?? this.#commandDelivery.receive(command, commandIdentity); if (receipt.replay) { if (receipt.tracked.terminal) { @@ -315,13 +586,19 @@ export class DriverCommandDispatcher { const eagerCancellation = command.kind === "turn.cancel" - ? this.#cancelActiveWork(runtimeContext, command.reason ?? "turn.cancelled") + ? this.#cancelActiveWork( + runtimeContext, + socket, + command.reason ?? "turn.cancelled", + "turn.cancel", + ) : null; void eagerCancellation?.catch(() => {}); await this.#commandDelivery.accept(runtimeContext, command, receipt.tracked); try { if (command.kind === "permission.resolve") { + this.#assertCommandRunOwnership(socket, command); this.#permissionRequests.resolve(command.requestId, command.decision); await this.#commandDelivery.finish(runtimeContext, command, { status: "completed", @@ -330,31 +607,18 @@ export class DriverCommandDispatcher { } if (command.kind === "input.start") { - if (this.#activeRunTask) { - await settleInput(this.#activeRunTask); - } - if (this.#activeRunTask) { - throw new Error("Driver run input is already in progress."); - } - if (this.#runtimeState.status() !== "ready") { - throw new Error(`Driver is not ready for input: ${this.#runtimeState.status()}.`); - } - this.#activeRunGeneration += 1; this.#runtimeState.beginRun(this.#activeRunGeneration); - const cancellation = new AbortController(); const runId = parseRunId(command.runId); - this.#activeInputCancellation = cancellation; - this.#commandDelivery.resetRunTerminal(); - socket.beginRun(runId); + const ticket = socket.beginRun(runId); + this.#activeRunTicket = ticket; let activeRunTask!: Promise; activeRunTask = this.#runInputTask( runtimeContext, socket, command, - cancellation, this.#activeRunGeneration, - runId, + ticket, ) .catch(async (error: unknown) => { this.#activeWorkFailure ??= { error }; @@ -373,7 +637,8 @@ export class DriverCommandDispatcher { .finally(() => { if (this.#activeRunTask === activeRunTask) { this.#activeRunTask = null; - this.#activeInputCancellation = null; + this.#activeRunSettleTask = null; + this.#activeRunTicket = null; } }); this.#activeRunTask = activeRunTask; @@ -381,6 +646,7 @@ export class DriverCommandDispatcher { } if (command.kind === "mcp.execute") { + this.#assertCommandRunOwnership(socket, command); this.#startMcpCommand(runtimeContext, socket, command); return; } @@ -398,7 +664,9 @@ export class DriverCommandDispatcher { this.#runtimeState.enter("stopping"); await this.#cancelActiveWork( runtimeContext, + socket, reason, + "session.stop", this.#permissionRequests.rejectAllAndWait(), ); @@ -409,7 +677,7 @@ export class DriverCommandDispatcher { commandId: command.commandId, reason, }); - await this.#commandDelivery.claimRunTerminal(socket, "completed"); + await deliverRunTerminal(socket, { status: "completed" }); runtimeContext.logger.debug("driver.runtime.run.completed", { commandId: command.commandId, reason, @@ -436,24 +704,79 @@ export class DriverCommandDispatcher { } } + #assertCommandRunOwnership( + socket: DriverRuntimeIo, + command: RuntimeCommand, + replay: "active" | "terminal" | null = null, + ): void { + if (command.kind === "session.stop") { + return; + } + + const runId = parseRunId(command.runId); + const currentRunId = socket.currentRunId(); + + if (command.kind === "input.start") { + if ( + replay === "terminal" + ? currentRunId !== null && currentRunId !== runId + : replay === "active" + ? currentRunId !== runId + : this.#activeRunTask !== null || currentRunId !== null + ) { + throw new Error(`Input command ${command.commandId} cannot replace the active run.`); + } + if (replay === null && this.#runtimeState.status() !== "ready") { + throw new Error(`Driver is not ready for input: ${this.#runtimeState.status()}.`); + } + return; + } + + if (currentRunId !== runId && !(replay === "terminal" && currentRunId === null)) { + throw new Error(`Command ${command.commandId} does not target the active run.`); + } + } + #abortMcpCommands(reason: string): void { for (const { controller } of this.#activeMcpCommands.values()) { controller.abort(new Error(reason)); } } - #abortActiveWork(reason: string): void { - this.#permissionRequests.rejectAll(); - this.#activeInputCancellation?.abort(new DriverTurnCancelledError(reason)); - this.#abortMcpCommands(reason); + #abortActiveWork( + socket: DriverRuntimeIo, + reason: string, + source: DriverTurnCancellationSource, + ): "already_claimed" | "claimed" | "idle" | "terminal_selected" { + const ticket = this.#activeRunTicket; + const cancellation = + ticket === null || socket.runSnapshot(ticket.runId) === null + ? "idle" + : socket.claimRunCancellation(ticket, reason, source); + + if (source !== "turn.cancel" || cancellation !== "terminal_selected") { + if (source === "shutdown") { + this.#shutdownPermissionTask ??= this.#permissionRequests.rejectAllAndWait(); + void this.#shutdownPermissionTask.catch(() => {}); + } else { + this.#permissionRequests.rejectAll(); + } + this.#abortMcpCommands(reason); + } + if (cancellation === "idle") { + this.#activeRunTicket = null; + } + return cancellation; } async #cancelActiveWork( runtimeContext: AgentDriverContext, + socket: DriverRuntimeIo, reason: string, + source: DriverTurnCancellationSource, permissionCancellation?: Promise, ): Promise { - this.#abortActiveWork(reason); + const cancellation = this.#abortActiveWork(socket, reason, source); let permissionFailure: { error: unknown } | null = null; if (permissionCancellation !== undefined) { @@ -464,14 +787,16 @@ export class DriverCommandDispatcher { } } - const backendCancellation = promiseWithTimeout( - this.#backend.cancelActiveTurn(runtimeContext, reason), - { - label: "Active driver turn cancellation", - timeoutMs: ACTIVE_TURN_CANCEL_GRACE_MS, - }, - ); - const results = await Promise.allSettled([backendCancellation, this.#joinActiveWork()]); + const tasks = [this.#joinActiveWork()]; + if (source !== "turn.cancel" || cancellation !== "terminal_selected") { + tasks.push( + promiseWithTimeout(this.#backend.cancelActiveTurn(runtimeContext, reason), { + label: "Active driver turn cancellation", + timeoutMs: ACTIVE_TURN_CANCEL_GRACE_MS, + }), + ); + } + const results = await Promise.allSettled(tasks); const failure = results.find((result) => result.status === "rejected"); if (permissionFailure !== null) { @@ -495,10 +820,14 @@ export class DriverCommandDispatcher { permissionFailure = { error }; } } + if (this.#activeWorkFenceFailure !== null) { + throw this.#activeWorkFenceFailure.error; + } const tasks: Promise[] = []; - if (this.#activeRunTask !== null) { - tasks.push(settleInput(this.#activeRunTask)); + const activeRunSettleTask = this.#settleActiveRun(); + if (activeRunSettleTask !== null) { + tasks.push(activeRunSettleTask); } if (this.#activeMcpCommands.size > 0) { @@ -507,7 +836,7 @@ export class DriverCommandDispatcher { Promise.allSettled([...this.#activeMcpCommands.values()].map(({ task }) => task)), { label: "Active driver MCP commands", - timeoutMs: ACTIVE_INPUT_SETTLE_GRACE_MS, + timeoutMs: ACTIVE_MCP_COMMIT_GRACE_MS, }, ), ); @@ -520,8 +849,22 @@ export class DriverCommandDispatcher { throw permissionFailure.error; } if (failure?.status === "rejected") { - throw failure.reason; + this.#activeWorkFenceFailure ??= { error: failure.reason }; + throw this.#activeWorkFenceFailure.error; + } + } + + #settleActiveRun(): Promise | null { + if (this.#activeRunTask === null) { + return null; } + + return (this.#activeRunSettleTask ??= settleInput(this.#activeRunTask).catch( + (error: unknown) => { + this.#activeWorkFenceFailure ??= { error }; + throw this.#activeWorkFenceFailure.error; + }, + )); } #startMcpCommand( @@ -533,10 +876,14 @@ export class DriverCommandDispatcher { throw new Error(`Driver has ${MAX_ACTIVE_MCP_COMMANDS} active MCP commands.`); } + const runId = parseRunId(command.runId); const controller = new AbortController(); const task = this.#runMcpCommand(runtimeContext, socket, command, controller) - .catch(async (error: unknown) => { + .catch((error: unknown) => { this.#activeWorkFailure ??= { error }; + if (!this.#mcpRunFailures.has(runId)) { + this.#mcpRunFailures.set(runId, { error }); + } this.#rememberRunFailure({ code: "driver.mcp_task_failed", details: { commandId: command.commandId }, @@ -547,19 +894,22 @@ export class DriverCommandDispatcher { commandId: command.commandId, driverInstanceId: this.#driverInstanceId, }); - await this.#shutdown(socket, "driver.mcp_task_failed").catch((shutdownError: unknown) => { - runtimeContext.logger.error("driver.runtime.shutdown.failed", shutdownError, { - commandId: command.commandId, - }); - }); + throw error; }) .finally(() => { if (this.#activeMcpCommands.get(command.commandId)?.task === task) { this.#activeMcpCommands.delete(command.commandId); } + this.#clearRunMcpState(socket, runId); + }); + this.#activeMcpCommands.set(command.commandId, { controller, runId, task }); + void task.catch(async () => { + await this.#shutdown(socket, "driver.mcp_task_failed").catch((shutdownError: unknown) => { + runtimeContext.logger.error("driver.runtime.shutdown.failed", shutdownError, { + commandId: command.commandId, + }); }); - this.#activeMcpCommands.set(command.commandId, { controller, task }); - void task; + }); } async #runMcpCommand( @@ -568,21 +918,13 @@ export class DriverCommandDispatcher { command: Extract, controller: AbortController, ): Promise { - let effectClaimed = false; - let effectCompleted = false; + let durableResult: McpExecuteCommandResult | null = null; const effectLedger = runtimeContext.ports.eventSink; - if ( - effectLedger.claimExternalToolEffect === undefined || - effectLedger.completeExternalToolEffect === undefined || - effectLedger.markExternalToolEffectUnknown === undefined - ) { - throw new Error("Driver external tool effect ledger is not configured."); - } - try { await pushLosslessEvents(socket, [ { + correlationId: command.commandId, kind: "tool.call.updated", payload: { kind: "mcp", @@ -591,48 +933,33 @@ export class DriverCommandDispatcher { title: command.toolName, toolCallId: command.toolCallId, }, + runId: parseRunId(command.runId), + sourceEventId: `mcp.execute.running:${command.commandId}`, }, ]); - const effect = await effectLedger.claimExternalToolEffect( + controller.signal.throwIfAborted(); + const observeExternalToolEffect = effectLedger.observeExternalToolEffect; + if ( + observeExternalToolEffect === undefined || + effectLedger.claimExternalToolEffect === undefined || + effectLedger.settleExternalToolEffect === undefined + ) { + throw new Error("Driver external tool effect ledger is not configured."); + } + const observed = await observeExternalToolEffect.call( + effectLedger, { commandId: command.commandId }, controller.signal, ); - if (effect.kind === "unknown") { - throw new ExternalToolEffectResolutionRequiredError(command, effect.effectId); - } - - let result: McpExecuteCommandResult; - - if (effect.kind === "completed") { - result = effect.result; - } else { - effectClaimed = true; - runtimeContext.logger.info("driver.runtime.mcp.execute.started", { - effectAttempt: effect.attempt, - serverId: command.serverId, - toolName: command.toolName, - }); - const execution = await runtimeContext.ports.mcp.execute( - command, - controller.signal, - effect, - ); - const { providerReceiptJson, ...executionResult } = execution; - result = executionResult; - controller.signal.throwIfAborted(); - await effectLedger.completeExternalToolEffect( - { - commandId: command.commandId, - ...(providerReceiptJson === undefined ? {} : { providerReceiptJson }), - result, - }, - controller.signal, - ); - effectCompleted = true; - } + const result = + observed.kind === "intent" + ? await this.#executeMcpIntent(runtimeContext, command, controller, observed.effectId) + : this.#resolveExternalToolEffectState(command, observed, observed.effectId); + durableResult = result; await pushLosslessEvents(socket, [ { + correlationId: command.commandId, kind: "tool.call.updated", payload: { kind: "mcp", @@ -642,6 +969,8 @@ export class DriverCommandDispatcher { title: command.toolName, toolCallId: command.toolCallId, }, + runId: parseRunId(command.runId), + sourceEventId: `mcp.execute.completed:${command.commandId}`, }, ]); await this.#commandDelivery.finish(runtimeContext, command, { @@ -654,58 +983,229 @@ export class DriverCommandDispatcher { toolName: command.toolName, }); } catch (error) { - if (effectClaimed && !effectCompleted) { - await this.#markExternalToolEffectUnknown(effectLedger, command.commandId); + if (durableResult !== null) { + throw error; + } + + if (error instanceof ExternalToolEffectUnsettledError) { + throw error; } if (this.#commandDelivery.hasTerminal(command.commandId)) { throw error; } - if (controller.signal.aborted) { + if (controller.signal.aborted && !(error instanceof ExternalToolEffectUnknownError)) { + await pushLosslessEvents(socket, [ + { + correlationId: command.commandId, + kind: "tool.call.updated", + payload: { + kind: "mcp", + rawInput: command.argumentsJson, + status: "cancelled", + title: command.toolName, + toolCallId: command.toolCallId, + }, + runId: parseRunId(command.runId), + sourceEventId: `mcp.execute.cancelled:${command.commandId}`, + }, + ]); await this.#commandDelivery.finish(runtimeContext, command, { status: "cancelled", }); return; } + const commandFailure = toCommandFailure(command, error); + const failedEvent = createMcpExecuteFailedEventIdentity({ + commandId: command.commandId, + rawInput: command.argumentsJson, + rawOutput: commandFailure.message, + title: command.toolName, + toolCallId: command.toolCallId, + }); + await pushLosslessEvents(socket, [ { + correlationId: command.commandId, kind: "tool.call.updated", - payload: { - kind: "mcp", - rawInput: command.argumentsJson, - rawOutput: toErrorMessage(error, "MCP tool execution failed."), - status: "failed", - title: command.toolName, - toolCallId: command.toolCallId, - }, + payload: failedEvent.payload, + runId: parseRunId(command.runId), + sourceEventId: failedEvent.sourceEventId, }, - ]).catch((deliveryError: unknown) => { - runtimeContext.logger.error("driver.runtime.mcp.failed-event.failed", deliveryError, { + ]); + + await this.#failCommand(runtimeContext, socket, command, error, commandFailure); + } + } + + async #executeMcpIntent( + runtimeContext: AgentDriverContext, + command: Extract, + controller: AbortController, + effectId: string, + ): Promise { + this.#assertActiveMcpRun(runtimeContext, command); + const durableResultIdentity = requireDurableMcpResultIdentity(command); + const prepared = await runtimeContext.ports.mcp.prepare(command, controller.signal); + + try { + controller.signal.throwIfAborted(); + this.#assertActiveMcpRun(runtimeContext, command); + const claimToken = randomUUID(); + const claimInput = { claimToken, commandId: command.commandId } as const; + const claimExternalToolEffect = runtimeContext.ports.eventSink.claimExternalToolEffect; + let claim: McpExternalToolEffectState; + + if (claimExternalToolEffect === undefined) { + throw new Error("Driver external tool effect ledger is not configured."); + } + + try { + claim = await retryOnce(() => + claimExternalToolEffect.call( + runtimeContext.ports.eventSink, + claimInput, + AbortSignal.timeout(EXTERNAL_TOOL_EFFECT_FENCE_TIMEOUT_MS), + ), + ); + } catch (error) { + runtimeContext.logger.warn("driver.runtime.mcp.claim.outcome-unknown", { commandId: command.commandId, - toolCallId: command.toolCallId, + effectId, + message: toErrorMessage(error, "External effect claim failed."), }); + throw new ExternalToolEffectUnsettledError(command, effectId, error); + } + + if (claim.kind !== "claimed") { + return this.#resolveExternalToolEffectState(command, claim, effectId); + } + if (claim.effectId !== effectId) { + throw new ExternalToolEffectUnsettledError( + command, + effectId, + new Error(`External effect claim returned mismatched effect ID ${claim.effectId}.`), + ); + } + + runtimeContext.logger.info("driver.runtime.mcp.execute.started", { + effectAttempt: claim.attempt, + serverId: command.serverId, + toolName: command.toolName, }); + let execution: Awaited>; - await this.#failCommand(runtimeContext, socket, command, error); + try { + execution = await prepared.execute(claim); + } catch (error) { + runtimeContext.logger.warn("driver.runtime.mcp.execute.outcome-unknown", { + commandId: command.commandId, + effectId, + message: toErrorMessage(error, "MCP execution outcome is unknown."), + }); + const settled = await this.#settleExternalToolEffect( + runtimeContext, + command, + claimToken, + effectId, + { kind: "unknown" }, + ); + return this.#resolveExternalToolEffectState(command, settled, effectId); + } + + const settled = await this.#settleExternalToolEffect( + runtimeContext, + command, + claimToken, + effectId, + createDurableMcpSucceededSettlement(execution, durableResultIdentity), + ); + return this.#resolveExternalToolEffectState(command, settled, effectId); + } finally { + await promiseWithTimeout( + Promise.try(() => prepared[Symbol.asyncDispose]()), + { + label: "Prepared MCP command cleanup", + timeoutMs: MCP_PREPARED_DISPOSE_TIMEOUT_MS, + }, + ).catch((error: unknown) => { + runtimeContext.logger.warn("driver.runtime.mcp.cleanup.failed", { + commandId: command.commandId, + message: toErrorMessage(error, "MCP cleanup failed."), + }); + }); } } - async #markExternalToolEffectUnknown( - effectLedger: AgentDriverContext["ports"]["eventSink"], - commandId: string, - ): Promise { - const markUnknown = effectLedger.markExternalToolEffectUnknown; - if (markUnknown === undefined) { + #assertActiveMcpRun( + runtimeContext: AgentDriverContext, + command: Extract, + ): void { + if (runtimeContext.ports.eventSink.currentRunId() !== parseRunId(command.runId)) { + throw new Error(`Command ${command.commandId} does not target the active run.`); + } + } + + #resolveExternalToolEffectState( + command: Extract, + state: McpExternalToolEffectState, + effectId: string, + ): McpExecuteCommandResult { + if (state.effectId !== effectId) { + throw new ExternalToolEffectUnsettledError( + command, + effectId, + new Error(`External effect state returned mismatched effect ID ${state.effectId}.`), + ); + } + if (state.kind === "succeeded") { + if ( + state.result.requestId !== command.requestId || + state.result.serverId !== command.serverId || + state.result.toolName !== command.toolName + ) { + throw new ExternalToolEffectUnsettledError( + command, + effectId, + new Error("External effect result does not match the current MCP command."), + ); + } + return state.result; + } + if (state.kind === "unknown") { + throw new ExternalToolEffectUnknownError(command, effectId); + } + + throw new ExternalToolEffectUnsettledError(command, effectId); + } + + async #settleExternalToolEffect( + runtimeContext: AgentDriverContext, + command: Extract, + claimToken: string, + effectId: string, + settlement: McpExternalToolEffectSettlement, + ): Promise { + const input = { claimToken, commandId: command.commandId, effectId, settlement } as const; + const settleExternalToolEffect = runtimeContext.ports.eventSink.settleExternalToolEffect; + + if (settleExternalToolEffect === undefined) { throw new Error("Driver external tool effect ledger is not configured."); } - const signal = AbortSignal.any([ - this.#shutdownSignal, - AbortSignal.timeout(EXTERNAL_TOOL_EFFECT_FENCE_TIMEOUT_MS), - ]); - await markUnknown.call(effectLedger, { commandId }, signal); + try { + return await retryOnce(() => + settleExternalToolEffect.call( + runtimeContext.ports.eventSink, + input, + AbortSignal.timeout(EXTERNAL_TOOL_EFFECT_FENCE_TIMEOUT_MS), + ), + ); + } catch (error) { + throw new ExternalToolEffectUnsettledError(command, effectId, error); + } } async #failCommand( @@ -713,9 +1213,8 @@ export class DriverCommandDispatcher { socket: DriverRuntimeIo, command: RuntimeCommand, error: unknown, + commandFailure = toCommandFailure(command, error), ): Promise { - const commandFailure = toCommandFailure(command, error); - if (command.kind === "session.stop") { if (this.#runtimeState.status() === "stopping") { this.#runtimeState.enter("failed"); @@ -770,89 +1269,96 @@ export class DriverCommandDispatcher { runtimeContext: AgentDriverContext, socket: DriverRuntimeIo, command: Extract, - cancellation: AbortController, - runId: RunId, - ): Promise { + ticket: DriverRunTicket, + ): Promise<"command_acked" | "driver_failing"> { + let outcome: DriverInputOutcome; try { - cancellation.signal.throwIfAborted(); - await this.#backend.handleInput(runtimeContext, command.input, runId, cancellation.signal); - cancellation.signal.throwIfAborted(); + ticket.signal.throwIfAborted(); + await this.#backend.handleInput( + runtimeContext, + { text: command.input.text }, + ticket.runId, + ticket.signal, + ); + outcome = { status: "resolved" }; + } catch (error) { + outcome = isDriverTurnCancelledError(error) + ? { error, status: "cancelled" } + : { error, status: "rejected" }; + } + + const settlement = socket.settleRunInput(ticket, outcome); + if (settlement.status === "resolved") { await this.#commandDelivery.finish(runtimeContext, command, { result: { requestId: command.requestId, }, status: "completed", }); - } catch (error) { - if (this.#commandDelivery.hasTerminal(command.commandId)) { - throw error; - } - - if ( - isDriverTurnCancelledError(error) || - (cancellation.signal.aborted && - !(error instanceof DriverTurnCancellationCleanupError) && - !(error instanceof PermissionEventDeliveryError)) - ) { - await this.#commandDelivery.finish(runtimeContext, command, { - status: "cancelled", - }); - runtimeContext.logger.info("driver.runtime.input.cancelled", { - commandId: command.commandId, - commandKind: command.kind, - driverInstanceId: this.#driverInstanceId, - }); - - return; - } - - const commandFailure = toCommandFailure(command, error); - - this.#runtimeState.enter("failed"); + return "command_acked"; + } + if (settlement.status === "cancelled") { await this.#commandDelivery.finish(runtimeContext, command, { - error: commandFailure, - status: "failed", + status: "cancelled", }); - if (socket.runEventTerminal?.(runId) == null) { - await pushDriverDiagnosticEvent( - socket, - { - code: "driver.command_failed", - details: { - commandId: command.commandId, - commandKind: command.kind, - }, - message: commandFailure.message, - severity: "error", - source: "core", - }, - runtimeContext.logger, - ); - } - runtimeContext.logger.error("driver.runtime.command.failed", error, { + runtimeContext.logger.info("driver.runtime.input.cancelled", { commandId: command.commandId, commandKind: command.kind, driverInstanceId: this.#driverInstanceId, - fatal: true, }); - this.#rememberRunFailure(commandFailure); - await this.#shutdown(socket, commandFailure.code); - this.#shutdownCompleted = true; + return "command_acked"; + } + + const commandFailure = toCommandFailure(command, settlement.failure); + this.#runtimeState.enter("failed"); + await this.#commandDelivery.finish(runtimeContext, command, { + error: commandFailure, + status: "failed", + }); + if (socket.runSnapshot(ticket.runId)?.terminal === null) { + await pushDriverDiagnosticEvent( + socket, + { + code: "driver.command_failed", + details: { + commandId: command.commandId, + commandKind: command.kind, + }, + message: commandFailure.message, + severity: "error", + source: "core", + }, + runtimeContext.logger, + ); } + runtimeContext.logger.error("driver.runtime.command.failed", settlement.failure, { + commandId: command.commandId, + commandKind: command.kind, + driverInstanceId: this.#driverInstanceId, + fatal: true, + }); + this.#rememberRunFailure(commandFailure); + await this.#shutdown(socket, commandFailure.code); + this.#shutdownCompleted = true; + return "driver_failing"; } async #runInputTask( runtimeContext: AgentDriverContext, socket: DriverRuntimeIo, command: Extract, - cancellation: AbortController, generation: number, - runId: RunId, + ticket: DriverRunTicket, ): Promise { + let releaseReason: "command_acked" | "driver_failing" = "driver_failing"; try { - await this.#runInputCommand(runtimeContext, socket, command, cancellation, runId); + releaseReason = await this.#runInputCommand(runtimeContext, socket, command, ticket); } finally { - socket.endRun(runId); + socket.releaseRun(ticket, releaseReason); + this.#clearRunMcpState(socket, ticket.runId); + if (this.#activeRunTicket === ticket) { + this.#activeRunTicket = null; + } this.#runtimeState.endRun(generation); } } diff --git a/src/core/driver-diagnostics.ts b/src/core/driver-diagnostics.ts index bbf1fd9..01178e6 100644 --- a/src/core/driver-diagnostics.ts +++ b/src/core/driver-diagnostics.ts @@ -39,7 +39,7 @@ export function createDriverDiagnosticEvent(input: DriverDiagnosticInput): Drive } export async function pushDriverDiagnosticEvent( - port: DriverRuntimeEventPort, + port: Pick, input: DriverDiagnosticInput, logger?: Logger, ): Promise { diff --git a/src/core/driver-permission-broker.ts b/src/core/driver-permission-broker.ts index 98ccaa2..f564e27 100644 --- a/src/core/driver-permission-broker.ts +++ b/src/core/driver-permission-broker.ts @@ -1,10 +1,13 @@ +import { createHash } from "node:crypto"; + import { summarizeDriverPermissionRequest } from "../observability/driver-debug"; import type { Logger } from "../observability"; +import type { DriverPermissionRequest } from "../host-ports"; import type { DriverEventInput } from "../protocol/events"; import type { RunId } from "../protocol/id"; import { promiseWithTimeout, settlePromiseWithTimeout } from "../utils/async"; import { createDriverDiagnosticEvent } from "./driver-diagnostics"; -import { pushLosslessEvents } from "./driver-runtime-io"; +import { DriverEventDeliveryOutcomeUnknownError, pushLosslessEvents } from "./driver-runtime-io"; import type { DriverRuntimeEventPort } from "./driver-runtime-io"; const PERMISSION_REQUEST_TIMEOUT_MS = 5 * 60 * 1000; @@ -12,6 +15,7 @@ const PERMISSION_EVENT_DELIVERY_TIMEOUT_MS = 10_000; const PERMISSION_CANCEL_DELIVERY_TIMEOUT_MS = 1_500; const MAX_PENDING_PERMISSION_REQUEST_BYTES = 8 * 1_024 * 1_024; const MAX_PENDING_PERMISSION_REQUESTS = 1_024; +const MAX_PERMISSION_REQUEST_EVENT_BYTES = 512 * 1_024; const UTF8 = new TextEncoder(); export type PermissionDecision = "allow_once" | "reject_once"; @@ -39,12 +43,9 @@ interface PermissionCancellationDelivery { useTurnBudget(): void; } -export interface DriverPermissionRequest { - rawInput: string | null; - requestId: string; - title: string; - toolCallId: string | null; - toolKind: string | null; +interface PermissionDeliveryFailure { + readonly error: PermissionEventDeliveryError; + recover(): Promise; } export interface DriverPermissionBrokerOptions { @@ -66,6 +67,8 @@ export class DriverPermissionBroker { #activeRequestBytes = 0; readonly #cancellationDeliveries = new Map(); #cancellationTask: Promise | null = null; + #closedRunId: RunId | null = null; + readonly #deliveryFailures = new Map(); #idle: PromiseWithResolvers | null = null; readonly #resolvers = new Map void>(); @@ -140,7 +143,7 @@ export class DriverPermissionBroker { this.#rejectAll(reason); if (!this.hasPending()) { - return Promise.resolve(); + return this.#recoverDeliveryFailures(); } if (this.#cancellationTask !== null) { return this.#cancellationTask; @@ -150,16 +153,62 @@ export class DriverPermissionBroker { const task = promiseWithTimeout(idle.promise, { label: "Driver permission cancellation", timeoutMs: this.#eventDeliveryTimeoutMs * 2, - }); + }).then(() => this.#recoverDeliveryFailures()); this.#cancellationTask = task; - void idle.promise.then(() => { + const clear = () => { if (this.#cancellationTask === task) { this.#cancellationTask = null; } - }); + }; + void task.then(clear, clear); return task; } + rejectRunAndWait( + runId: RunId, + reason: PermissionResolutionReason = "cancelled", + ): Promise | void { + this.#closedRunId = runId; + if (!this.hasPending() && this.#deliveryFailures.size === 0) { + this.#rejectAll(reason); + return; + } + return this.rejectAllAndWait(reason); + } + + async #recoverDeliveryFailures(): Promise { + const failures = [...this.#deliveryFailures.entries()]; + await Promise.allSettled( + failures.map(async ([lifecycleId, failure]) => { + const recovery = failure.recover(); + const result = await settlePromiseWithTimeout(recovery, { + label: "Driver permission lifecycle recovery", + timeoutMs: this.#eventDeliveryTimeoutMs, + }); + if (result.status !== "completed") { + if (result.status === "timed_out") { + void recovery.then( + () => { + if (this.#deliveryFailures.get(lifecycleId) === failure) { + this.#deliveryFailures.delete(lifecycleId); + } + }, + () => {}, + ); + } + throw result.error; + } + if (this.#deliveryFailures.get(lifecycleId) === failure) { + this.#deliveryFailures.delete(lifecycleId); + } + }), + ); + const failure = this.#deliveryFailures.values().next().value; + if (failure !== undefined) { + throw failure.error; + } + } + private resolveRequest(requestId: string, resolution: PermissionResolution): boolean { const resolve = this.#resolvers.get(requestId); @@ -176,6 +225,7 @@ export class DriverPermissionBroker { socket: DriverRuntimeEventPort, input: DriverPermissionRequest, signal?: AbortSignal, + ownsRun?: () => boolean, ): Promise { if (!this.#interactiveRequests) { this.#logger()?.debug("driver.runtime.permission.request.rejected", { @@ -185,6 +235,12 @@ export class DriverPermissionBroker { return "reject_once"; } + const runId = socket.currentRunId(); + const isCurrentRun = ownsRun ?? (() => socket.currentRunId() === runId); + if (!isCurrentRun() || (runId !== null && this.#closedRunId === runId)) { + return "reject_once"; + } + if (this.#activeRequestIds.has(input.requestId)) { throw new Error(`Driver permission request ${input.requestId} is already pending.`); } @@ -199,12 +255,17 @@ export class DriverPermissionBroker { throw new RangeError("Driver permission broker pending request byte budget is exhausted."); } - const runId = socket.currentRunId?.() ?? null; + const lifecycleId = permissionLifecycleId(runId, input.requestId); const events: DriverEventInput[] = [ { kind: "permission.requested", payload: { + ...(input.agentId === undefined ? {} : { agentId: input.agentId }), + ...(input.blockedPath === undefined ? {} : { blockedPath: input.blockedPath }), + ...(input.decisionReason === undefined ? {} : { decisionReason: input.decisionReason }), details: input.rawInput, + ...(input.description === undefined ? {} : { description: input.description }), + ...(input.matchedAskRule === undefined ? {} : { matchedAskRule: input.matchedAskRule }), options: [], requestId: input.requestId, targetItemId: input.toolCallId, @@ -215,8 +276,16 @@ export class DriverPermissionBroker { }, }, ...(runId === null ? {} : { runId }), + sourceEventId: `${lifecycleId}:requested`, }, ]; + + if (UTF8.encode(JSON.stringify(events[0])).byteLength > MAX_PERMISSION_REQUEST_EVENT_BYTES) { + throw new RangeError( + `Driver permission request event exceeds ${String(MAX_PERMISSION_REQUEST_EVENT_BYTES)} UTF-8 bytes.`, + ); + } + const deferred = Promise.withResolvers(); const cancellationDelivery = new AbortController(); let cancellationDeliveryTimeout: ReturnType | null = null; @@ -296,29 +365,76 @@ export class DriverPermissionBroker { timeoutMs: this.#requestTimeoutMs, }); - const requestedTask = pushLosslessEvents( - socket, - events, - undefined, + const deliverySignal = () => AbortSignal.any([ cancellationDelivery.signal, AbortSignal.timeout(this.#eventDeliveryTimeoutMs), - ]), - ); + ]); + const requestedTask = pushPermissionEvents(socket, events, deliverySignal()); const requestedDelivery = await settlePromiseWithTimeout(requestedTask, { label: `Driver permission request ${input.requestId} event delivery`, timeoutMs: this.#eventDeliveryTimeoutMs, }); if (requestedDelivery.status !== "completed") { - if (requestedDelivery.status === "timed_out") { - lateDelivery = requestedTask; - } - throw new PermissionEventDeliveryError( + const deliveryError = new PermissionEventDeliveryError( input.requestId, "requested", requestedDelivery.error, ); + const outcomeUnknown = + requestedDelivery.status === "timed_out" || + requestedDelivery.error instanceof DriverEventDeliveryOutcomeUnknownError; + + if (!outcomeUnknown) { + throw deliveryError; + } + + const cancelled: PermissionResolution = { + decision: "reject_once", + reason: "cancelled", + }; + const resolutionEvents = toResolutionEvents(input, cancelled, runId, lifecycleId); + this.#deliveryFailures.set(lifecycleId, { + error: deliveryError, + recover: async () => { + await pushPermissionEvents( + socket, + [...events, ...resolutionEvents], + AbortSignal.timeout(this.#eventDeliveryTimeoutMs), + ); + }, + }); + const closeLifecycle = () => + pushPermissionEvents(socket, [...events, ...resolutionEvents], deliverySignal()).then( + () => { + this.#deliveryFailures.delete(lifecycleId); + }, + ); + + if (requestedDelivery.status === "timed_out") { + lateDelivery = requestedTask.then( + () => pushPermissionEvents(socket, resolutionEvents, deliverySignal()), + closeLifecycle, + ); + lateDelivery = lateDelivery.then(() => { + this.#deliveryFailures.delete(lifecycleId); + }); + throw deliveryError; + } + + const recoveryTask = closeLifecycle(); + const recovery = await settlePromiseWithTimeout(recoveryTask, { + label: `Driver permission request ${input.requestId} lifecycle recovery`, + timeoutMs: this.#eventDeliveryTimeoutMs, + }); + if (recovery.status === "completed") { + return "reject_once"; + } + if (recovery.status === "timed_out") { + lateDelivery = recoveryTask; + } + throw new PermissionEventDeliveryError(input.requestId, "requested", recovery.error); } this.#logger()?.debug("driver.runtime.permission.request.sent", { @@ -328,19 +444,27 @@ export class DriverPermissionBroker { toolKind: input.toolKind, }); - const result = await settlePromiseWithTimeout(deferred.promise, { - label: `Driver permission request ${input.requestId}`, - timeoutMs: this.#requestTimeoutMs, - }); + const result = isCurrentRun() + ? await settlePromiseWithTimeout(deferred.promise, { + label: `Driver permission request ${input.requestId}`, + timeoutMs: this.#requestTimeoutMs, + }) + : ({ + status: "completed", + value: { decision: "reject_once", reason: "cancelled" }, + } as const); if (result.status === "failed") { throw result.error; } - const resolution: PermissionResolution = + let resolution: PermissionResolution = result.status === "timed_out" ? { decision: "reject_once", reason: "timed_out" } : result.value; + if (!isCurrentRun()) { + resolution = { decision: "reject_once", reason: "cancelled" }; + } unregister(); const decision = resolution.decision; @@ -351,50 +475,48 @@ export class DriverPermissionBroker { }); } - const resolutionTask = pushLosslessEvents( - socket, - [ - { - kind: "permission.resolved", - payload: { - outcome: decision, - permissionRequests: [], - reason: resolution.reason, - requestId: input.requestId, - }, - ...(runId === null ? {} : { runId }), - }, - ...toResolutionDiagnostics(input, resolution.reason, runId), - ], - undefined, - AbortSignal.any([ - cancellationDelivery.signal, - AbortSignal.timeout(this.#eventDeliveryTimeoutMs), - ]), - ); + const resolutionEvents = toResolutionEvents(input, resolution, runId, lifecycleId); + const resolutionTask = pushPermissionEvents(socket, resolutionEvents, deliverySignal()); const resolutionDelivery = await settlePromiseWithTimeout(resolutionTask, { label: `Driver permission resolution ${input.requestId} event delivery`, timeoutMs: this.#eventDeliveryTimeoutMs, }); if (resolutionDelivery.status !== "completed") { - if (resolutionDelivery.status === "timed_out") { - lateDelivery = resolutionTask; - } - throw new PermissionEventDeliveryError( + const deliveryError = new PermissionEventDeliveryError( input.requestId, "resolved", resolutionDelivery.error, ); + this.#deliveryFailures.set(lifecycleId, { + error: deliveryError, + recover: async () => { + await pushPermissionEvents( + socket, + resolutionEvents, + AbortSignal.timeout(this.#eventDeliveryTimeoutMs), + ); + }, + }); + if (resolutionDelivery.status === "timed_out") { + lateDelivery = resolutionTask + .catch(() => pushPermissionEvents(socket, resolutionEvents, deliverySignal())) + .then(() => { + this.#deliveryFailures.delete(lifecycleId); + }); + } + throw deliveryError; } + this.#deliveryFailures.delete(lifecycleId); + this.#logger()?.debug("driver.runtime.permission.request.resolved", { decision, reason: resolution.reason, requestId: input.requestId, }); - return decision; + return isCurrentRun() ? decision : "reject_once"; } finally { unregister(); signal?.removeEventListener("abort", cancel); @@ -410,10 +532,62 @@ export class DriverPermissionBroker { } } +function permissionLifecycleId(runId: RunId | null, requestId: string): string { + const digest = createHash("sha256") + .update(JSON.stringify([runId, requestId])) + .digest("hex"); + return `permission:${digest}`; +} + +async function pushPermissionEvents( + socket: Pick, + events: readonly DriverEventInput[], + signal: AbortSignal, +) { + try { + return await pushLosslessEvents(socket, events, undefined, signal); + } catch (error) { + if (!(error instanceof DriverEventDeliveryOutcomeUnknownError)) { + throw error; + } + + try { + return await pushLosslessEvents(socket, events, undefined, signal); + } catch (retryError) { + throw retryError instanceof DriverEventDeliveryOutcomeUnknownError + ? retryError + : new DriverEventDeliveryOutcomeUnknownError(retryError); + } + } +} + +function toResolutionEvents( + input: DriverPermissionRequest, + resolution: PermissionResolution, + runId: RunId | null, + lifecycleId: string, +): DriverEventInput[] { + return [ + { + kind: "permission.resolved", + payload: { + outcome: resolution.decision, + permissionRequests: [], + reason: resolution.reason, + requestId: input.requestId, + }, + ...(runId === null ? {} : { runId }), + sourceEventId: `${lifecycleId}:resolved`, + }, + ...toResolutionDiagnostics(input, resolution.reason, runId, lifecycleId), + ]; +} + function toResolutionDiagnostics( input: DriverPermissionRequest, reason: PermissionResolutionReason, runId: RunId | null, + lifecycleId: string, ): DriverEventInput[] { if (reason !== "cancelled" && reason !== "timed_out") { return []; @@ -437,6 +611,7 @@ function toResolutionDiagnostics( source: "permission", }), ...(runId === null ? {} : { runId }), + sourceEventId: `${lifecycleId}:diagnostic:${reason}`, }, ]; } diff --git a/src/core/driver-permission-policy.ts b/src/core/driver-permission-policy.ts index 8fc98b8..2a593ac 100644 --- a/src/core/driver-permission-policy.ts +++ b/src/core/driver-permission-policy.ts @@ -1,6 +1,7 @@ import type { DriverPermissionPolicy } from "../protocol/boot"; import type { DriverStartInput } from "../protocol/start"; -import type { DriverPermissionRequest, PermissionDecision } from "./driver-permission-broker"; +import type { DriverPermissionRequest } from "../host-ports"; +import type { PermissionDecision } from "./driver-permission-broker"; export type { DriverPermissionPolicy }; @@ -11,11 +12,10 @@ export function isDriverFullAccess(payload: DriverStartInput): boolean { /** * Build the permission handler the runtime context uses. * - * Under the `full_access` policy (the default) every tool call is approved - * synchronously inside the runtime — no control-plane round-trip, no - * `needs_approval` state churn, and no 5-minute reject-on-timeout window. The - * sandbox is the isolation boundary, so this is safe and removes permission - * latency from the critical path entirely. + * Under the `full_access` policy (the default), ordinary tool calls are + * approved synchronously inside the runtime. A provider-reported matched ask + * rule still delegates to the interactive handler so user policy cannot be + * bypassed. * * Under `supervised` the caller's interactive handler (the permission broker) * is used unchanged. @@ -28,7 +28,8 @@ export function createDriverPermissionRequestHandler(input: { ) => Promise; }): (request: DriverPermissionRequest, signal?: AbortSignal) => Promise { if (isDriverFullAccess(input.payload)) { - return async () => "allow_once"; + return async (request, signal) => + request.matchedAskRule === undefined ? "allow_once" : input.supervised(request, signal); } return input.supervised; diff --git a/src/core/driver-runtime-io.ts b/src/core/driver-runtime-io.ts index 19c14be..0cadae8 100644 --- a/src/core/driver-runtime-io.ts +++ b/src/core/driver-runtime-io.ts @@ -1,3 +1,4 @@ +import type { AgentDriverEventSink } from "../host-ports"; import type { DriverEventInput } from "../protocol/events"; import { createDriverId } from "../protocol/id"; import type { RunId } from "../protocol/id"; @@ -8,23 +9,27 @@ import type { DriverHeartbeatInput, DriverHeartbeatOutput, } from "../protocol/orpc"; +import type { DriverCommandUpdate, RuntimeCommand } from "../runtime-command"; import type { - McpExecuteCommandResult, - McpExternalToolEffectClaim, - RunError, - RuntimeCommand, - RuntimeCommandResult, -} from "../runtime-command"; + DriverInputOutcome, + DriverInputSettlement, + DriverRunSnapshot, + DriverRunTicket, +} from "./driver-terminal-state"; +import type { DriverTurnCancellationSource } from "./driver-turn-cancelled-error"; export interface DriverRuntimeEventPort { - currentRunId?(): RunId | null; + currentRunId(): RunId | null; pushEvents(input: { events: DriverEventInput[]; signal?: AbortSignal; }): Promise; - runEventTerminal?(runId: RunId): "cancelled" | "completed" | "failed" | null; } +export type DriverRunTerminalBarrier = ( + events: readonly DriverEventInput[], +) => Promise | void; + export const DRIVER_EVENT_DELIVERY_TIMEOUT_MS = 10_000; export class DriverEventRejectedError extends Error { @@ -37,6 +42,15 @@ export class DriverEventRejectedError extends Error { } } +export class DriverEventDeliveryOutcomeUnknownError extends Error { + constructor(cause: unknown) { + super(cause instanceof Error ? cause.message : "Driver event delivery outcome is unknown.", { + cause, + }); + this.name = "DriverEventDeliveryOutcomeUnknownError"; + } +} + /** * Stamps missing source IDs without taking ownership of event payloads. * @@ -50,6 +64,19 @@ export function withSourceEventIds(events: readonly DriverEventInput[]): DriverE ); } +export function assertIsolatedRunTerminalBatch(events: readonly DriverEventInput[]): void { + const terminals = events.filter( + ({ kind }) => kind === "run.cancelled" || kind === "run.completed" || kind === "run.failed", + ); + + if (terminals.length > 1) { + throw new Error("Driver event batch cannot contain multiple run terminals."); + } + if (terminals.length === 1 && events.length !== 1) { + throw new Error("Driver run terminal must be the only event in its batch."); + } +} + export function assertDriverEventReceiptPrefix( events: readonly DriverEventInput[], receipts: readonly DriverEventReceipt[], @@ -74,7 +101,7 @@ export function assertDriverEventReceiptPrefix( const event = events[index]; const eventId = event?.sourceEventId ?? event?.id; - if (receipt.eventId !== undefined && receipt.eventId !== eventId) { + if (receipt.eventId !== eventId) { throw new Error(`Driver event receipt ${index} does not match the submitted event ID.`); } } @@ -83,10 +110,10 @@ export function assertDriverEventReceiptPrefix( /** * Owns one invocation's input and drains partial receipt prefixes. * - * Transport failures are returned to the caller and are not retried automatically. + * One transport failure after a valid receipt prefix is retried with the same event IDs. */ export async function pushLosslessEvents( - port: DriverRuntimeEventPort, + port: Pick, events: readonly DriverEventInput[], onAccepted?: (receipts: readonly DriverEventReceipt[]) => void, signal?: AbortSignal, @@ -94,12 +121,30 @@ export async function pushLosslessEvents( const receipts: DriverEventReceipt[] = []; const deadline = signal ?? AbortSignal.timeout(DRIVER_EVENT_DELIVERY_TIMEOUT_MS); let remaining = structuredClone(withSourceEventIds(events)); + let retryTransport = false; while (remaining.length > 0) { - const result = await port.pushEvents({ - events: remaining, - signal: deadline, - }); + deadline.throwIfAborted(); + let result: DriverEventBatchOutput; + + try { + result = await port.pushEvents({ + events: remaining, + signal: deadline, + }); + } catch (error) { + if (error instanceof DriverEventRejectedError) { + throw error; + } + + if (!retryTransport) { + throw new DriverEventDeliveryOutcomeUnknownError(error); + } + + retryTransport = false; + continue; + } + assertDriverEventReceiptPrefix(remaining, result.accepted); if (result.accepted.length === 0) { @@ -109,46 +154,37 @@ export async function pushLosslessEvents( onAccepted?.(result.accepted); receipts.push(...result.accepted); remaining = remaining.slice(result.accepted.length); + retryTransport = true; } return receipts; } export interface DriverRuntimeCommandPort { - commandUpdate( - input: { - commandId: string; - error?: RunError; - result?: RuntimeCommandResult; - status: "accepted" | "cancelled" | "completed" | "failed"; - }, - signal: AbortSignal, - ): Promise; + commandUpdate(input: DriverCommandUpdate, signal: AbortSignal): Promise; nextCommand(signal: AbortSignal): Promise; } /** API-owned durable effect ledger used only for external MCP calls. */ -export interface DriverRuntimeExternalToolEffectPort { - claimExternalToolEffect( - input: { commandId: string }, - signal: AbortSignal, - ): Promise; - completeExternalToolEffect( - input: { - commandId: string; - providerReceiptJson?: string | null | undefined; - result: McpExecuteCommandResult; - }, - signal: AbortSignal, - ): Promise; - markExternalToolEffectUnknown(input: { commandId: string }, signal: AbortSignal): Promise; -} +export type DriverRuntimeExternalToolEffectPort = Required< + Pick< + AgentDriverEventSink, + "claimExternalToolEffect" | "observeExternalToolEffect" | "settleExternalToolEffect" + > +>; export interface DriverRuntimeRunPort { - beginRun(runId: RunId): void; + beginRun(runId: RunId): DriverRunTicket; + claimRunCancellation( + ticket: DriverRunTicket, + reason: string, + source?: DriverTurnCancellationSource, + ): "already_claimed" | "claimed" | "terminal_selected"; completeRun(signal?: AbortSignal): Promise; - endRun(runId: RunId): void; failRun(error: DriverFailureInput["error"], signal?: AbortSignal): Promise; + releaseRun(ticket: DriverRunTicket, reason: "command_acked" | "driver_failing"): void; + runSnapshot(runId?: RunId): DriverRunSnapshot | null; + settleRunInput(ticket: DriverRunTicket, outcome: DriverInputOutcome): DriverInputSettlement; } export interface DriverRuntimeHeartbeatPort { @@ -161,4 +197,6 @@ export interface DriverRuntimeIo DriverRuntimeExternalToolEffectPort, DriverRuntimeEventPort, DriverRuntimeHeartbeatPort, - DriverRuntimeRunPort {} + DriverRuntimeRunPort { + registerRunTerminalBarrier(barrier: DriverRunTerminalBarrier): () => void; +} diff --git a/src/core/driver-runtime-state.ts b/src/core/driver-runtime-state.ts index d6abbf1..5cf0407 100644 --- a/src/core/driver-runtime-state.ts +++ b/src/core/driver-runtime-state.ts @@ -1,15 +1,12 @@ -export const DRIVER_RUNTIME_STATUSES = [ - "created", - "starting", - "ready", - "running", - "needs_approval", - "stopping", - "stopped", - "failed", -] as const; - -export type DriverRuntimeStatus = (typeof DRIVER_RUNTIME_STATUSES)[number]; +export type DriverRuntimeStatus = + | "created" + | "starting" + | "ready" + | "running" + | "needs_approval" + | "stopping" + | "stopped" + | "failed"; export const DRIVER_RUNTIME_TRANSITIONS: Readonly< Record @@ -60,6 +57,10 @@ export class DriverRuntimeStateMachine { } } + ownsRun(generation: number): boolean { + return this.#activeRunGeneration === generation; + } + beginApproval(): number | null { const generation = this.#activeRunGeneration; diff --git a/src/core/driver-terminal-state.ts b/src/core/driver-terminal-state.ts new file mode 100644 index 0000000..45c6065 --- /dev/null +++ b/src/core/driver-terminal-state.ts @@ -0,0 +1,373 @@ +import { isDeepStrictEqual } from "node:util"; + +import type { DriverEventInput } from "../protocol/events"; +import type { RunId } from "../protocol/id"; +import type { DriverEventReceipt } from "../protocol/orpc"; +import type { RunError } from "../runtime-command"; +import { + DriverTurnCancelledError, + type DriverTurnCancellationSource, +} from "./driver-turn-cancelled-error"; + +export type DriverRunTerminalStatus = "cancelled" | "completed" | "failed"; + +export interface DriverRunTicket { + readonly revision: number; + readonly runId: RunId; + readonly signal: AbortSignal; +} + +export interface DriverRunTerminalIdentity { + readonly event: DriverEventInput; + readonly runId: RunId; + readonly sourceEventId: string; + readonly status: DriverRunTerminalStatus; +} + +export type DriverRunTerminalState = + | { + readonly phase: "selected"; + readonly value: DriverRunTerminalIdentity; + } + | { + readonly phase: "acked"; + readonly receipt: DriverEventReceipt; + readonly value: DriverRunTerminalIdentity; + }; + +export interface DriverRunSnapshot { + readonly cancellation: { + readonly reason: string; + } | null; + readonly revision: number; + readonly runId: RunId; + readonly terminal: DriverRunTerminalState | null; +} + +export type DriverInstanceTerminal = + | { readonly runId: RunId; readonly status: "completed" } + | { readonly error: RunError; readonly runId: RunId; readonly status: "failed" }; + +export type DriverInstanceTerminalState = + | { readonly phase: "open" } + | { readonly phase: "selected"; readonly terminal: DriverInstanceTerminal } + | { readonly phase: "acked"; readonly terminal: DriverInstanceTerminal }; + +export type DriverInputOutcome = + | { readonly status: "resolved" } + | { readonly error: DriverTurnCancelledError; readonly status: "cancelled" } + | { readonly error: unknown; readonly status: "rejected" }; + +export type DriverInputSettlement = + | { readonly status: "resolved" } + | { readonly status: "cancelled" } + | { readonly failure: unknown; readonly status: "failed" }; + +interface ActiveRun { + readonly cancellation: AbortController; + cancellationClaim: DriverRunSnapshot["cancellation"]; + readonly revision: number; + readonly runId: RunId; + terminal: DriverRunTerminalState | null; +} + +interface ShutdownState { + cleanup: "completed" | "pending"; + failure: { + readonly error: RunError; + readonly runId: RunId | null; + } | null; +} + +export interface DriverShutdownSnapshot { + readonly cleanup: "completed" | "pending"; + readonly failure: { readonly error: RunError; readonly runId: RunId | null } | null; +} + +export class DriverTerminalStateMachine { + #activeRun: ActiveRun | null = null; + #instanceTerminal: DriverInstanceTerminalState = { phase: "open" }; + #lastOwnedRunId: RunId | null = null; + #lastRunTerminal: { readonly runId: RunId; readonly status: DriverRunTerminalStatus } | null = + null; + #revision = 0; + #shutdown: ShutdownState | null = null; + + beginRun(runId: RunId): DriverRunTicket { + if (this.#activeRun !== null) { + throw new Error(`Driver run ${this.#activeRun.runId} is already active.`); + } + if (this.#instanceTerminal.phase !== "open") { + throw new Error("Cannot begin a run after an instance terminal has been selected."); + } + + const cancellation = new AbortController(); + const revision = ++this.#revision; + this.#lastOwnedRunId = runId; + this.#lastRunTerminal = null; + this.#activeRun = { + cancellation, + cancellationClaim: null, + revision, + runId, + terminal: null, + }; + return { revision, runId, signal: cancellation.signal }; + } + + claimCancellation( + ticket: DriverRunTicket, + reason: string, + source: DriverTurnCancellationSource = "turn.cancel", + ): "already_claimed" | "claimed" | "terminal_selected" { + const active = this.#requireRun(ticket); + if (active.terminal !== null) { + return "terminal_selected"; + } + if (active.cancellationClaim !== null) { + if ( + source !== "turn.cancel" && + active.cancellation.signal.reason instanceof DriverTurnCancelledError + ) { + active.cancellation.signal.reason.preventResume(); + } + return "already_claimed"; + } + + active.cancellationClaim = { reason }; + active.cancellation.abort(new DriverTurnCancelledError(reason, source)); + return "claimed"; + } + + currentRunId(): RunId | null { + return this.#activeRun?.runId ?? null; + } + + rememberOwnedRunId(runId: RunId): void { + if (this.#activeRun !== null && this.#activeRun.runId !== runId) { + throw new Error("Driver handshake run conflicts with the active run."); + } + this.#lastOwnedRunId = runId; + } + + selectRunTerminal( + ticket: DriverRunTicket, + terminal: DriverRunTerminalIdentity, + ): "acked" | "cancelled" | "pending" | "selected" { + const active = this.#requireRun(ticket); + if (terminal.runId !== ticket.runId) { + throw new Error("Driver run terminal must target the active run."); + } + if (terminal.sourceEventId.length === 0) { + throw new Error("Driver run terminal source event ID must be non-empty."); + } + + const selected = active.terminal; + if (selected !== null) { + if (!isDeepStrictEqual(selected.value, terminal)) { + throw new Error("Driver run terminal conflicts with the selected terminal."); + } + return selected.phase === "acked" ? "acked" : "pending"; + } + + if (active.cancellationClaim !== null && terminal.status === "completed") { + return "cancelled"; + } + + active.terminal = { phase: "selected", value: structuredClone(terminal) }; + return "selected"; + } + + ackRunTerminal(ticket: DriverRunTicket, receipt: DriverEventReceipt): void { + const active = this.#requireRun(ticket); + const terminal = active.terminal; + if (terminal === null) { + throw new Error("Driver run terminal has not been selected."); + } + if (receipt.eventId !== terminal.value.sourceEventId) { + throw new Error("Driver run terminal receipt does not match the selected source event ID."); + } + if (terminal.phase === "acked") { + if (!isDeepStrictEqual(terminal.receipt, receipt)) { + throw new Error("Driver run terminal was acknowledged with a different receipt."); + } + return; + } + + active.terminal = { + phase: "acked", + receipt: structuredClone(receipt), + value: terminal.value, + }; + } + + abandonRunTerminal(ticket: DriverRunTicket, terminal: DriverRunTerminalIdentity): void { + const active = this.#requireRun(ticket); + if ( + active.terminal?.phase !== "selected" || + !isDeepStrictEqual(active.terminal.value, terminal) + ) { + throw new Error("Only the exact unpublished driver run terminal can be abandoned."); + } + active.terminal = null; + } + + acknowledgedRunTerminal(runId?: RunId): DriverRunTerminalStatus | null { + const active = this.#activeRun; + if (active?.terminal?.phase === "acked" && (runId === undefined || active.runId === runId)) { + return active.terminal.value.status; + } + return this.#lastRunTerminal !== null && + (runId === undefined || this.#lastRunTerminal.runId === runId) + ? this.#lastRunTerminal.status + : null; + } + + snapshotRun(runId?: RunId): DriverRunSnapshot | null { + const active = this.#activeRun; + if (active === null || (runId !== undefined && active.runId !== runId)) { + return null; + } + + return { + cancellation: active.cancellationClaim, + revision: active.revision, + runId: active.runId, + terminal: active.terminal, + }; + } + + settleInput(ticket: DriverRunTicket, outcome: DriverInputOutcome): DriverInputSettlement { + const active = this.#requireRun(ticket); + const terminal = active.terminal; + + if (outcome.status === "rejected") { + return { failure: outcome.error, status: "failed" }; + } + if (terminal === null || terminal.phase === "selected") { + return { + failure: new Error( + terminal === null + ? "Driver input settled without a run terminal." + : "Driver run terminal was selected but not acknowledged.", + ), + status: "failed", + }; + } + if (outcome.status === "cancelled") { + if (terminal.value.status === "cancelled") { + return { status: "cancelled" }; + } + return { + failure: new Error( + `Driver cancellation settled with a ${terminal.value.status} run terminal.`, + ), + status: "failed", + }; + } + + return terminal.value.status === "completed" + ? { status: "resolved" } + : { + failure: new Error( + `Driver input resolved after a ${terminal.value.status} run terminal.`, + ), + status: "failed", + }; + } + + releaseRun(ticket: DriverRunTicket, reason: "command_acked" | "driver_failing"): void { + const active = this.#requireRun(ticket); + if (reason === "command_acked" && active.terminal?.phase !== "acked") { + throw new Error("Cannot acknowledge a run without an acknowledged terminal."); + } + + this.#lastRunTerminal = + active.terminal?.phase === "acked" + ? { runId: active.runId, status: active.terminal.value.status } + : null; + this.#lastOwnedRunId = ticket.runId; + this.#activeRun = null; + } + + requestShutdown(): void { + this.#shutdown ??= { cleanup: "pending", failure: null }; + } + + recordFailure(error: RunError, runId = this.currentRunId()): void { + this.requestShutdown(); + this.#shutdown!.failure ??= { error: structuredClone(error), runId }; + } + + markCleanupCompleted(): void { + if (this.#shutdown === null) { + throw new Error("Driver shutdown has not been requested."); + } + this.#shutdown.cleanup = "completed"; + } + + shutdownSnapshot(): DriverShutdownSnapshot | null { + return this.#shutdown === null ? null : structuredClone(this.#shutdown); + } + + selectInstanceTerminal(terminal: DriverInstanceTerminal): "acked" | "pending" | "selected" { + const selected = this.#instanceTerminal; + if (selected.phase !== "open") { + if (!isDeepStrictEqual(selected.terminal, terminal)) { + throw new Error("Driver instance terminal conflicts with the selected terminal."); + } + return selected.phase === "acked" ? "acked" : "pending"; + } + + const frozen = structuredClone(terminal); + this.#instanceTerminal = { phase: "selected", terminal: frozen }; + return "selected"; + } + + ackInstanceTerminal(terminal: DriverInstanceTerminal): void { + const selected = this.#instanceTerminal; + if (selected.phase === "open") { + throw new Error("Driver instance terminal has not been selected."); + } + if (!isDeepStrictEqual(selected.terminal, terminal)) { + throw new Error("Driver instance terminal acknowledgement conflicts with its selection."); + } + this.#instanceTerminal = { phase: "acked", terminal: selected.terminal }; + } + + abandonInstanceTerminal(terminal: DriverInstanceTerminal): void { + const selected = this.#instanceTerminal; + if (selected.phase !== "selected" || !isDeepStrictEqual(selected.terminal, terminal)) { + throw new Error("Only the exact unpublished driver instance terminal can be abandoned."); + } + this.#instanceTerminal = { phase: "open" }; + } + + snapshotInstance(): DriverInstanceTerminalState { + return this.#instanceTerminal; + } + + terminalRunId(fallback: RunId | null = null): RunId | null { + const instanceTerminal = this.#instanceTerminal; + if (instanceTerminal.phase !== "open") { + return instanceTerminal.terminal.runId; + } + + return ( + this.#shutdown?.failure?.runId ?? this.#activeRun?.runId ?? this.#lastOwnedRunId ?? fallback + ); + } + + #requireRun(ticket: DriverRunTicket): ActiveRun { + const active = this.#activeRun; + if ( + active === null || + active.revision !== ticket.revision || + active.runId !== ticket.runId || + active.cancellation.signal !== ticket.signal + ) { + throw new Error("Driver run ticket is stale."); + } + return active; + } +} diff --git a/src/core/driver-turn-cancelled-error.ts b/src/core/driver-turn-cancelled-error.ts index 63165f2..5cf8ad9 100644 --- a/src/core/driver-turn-cancelled-error.ts +++ b/src/core/driver-turn-cancelled-error.ts @@ -1,7 +1,23 @@ +export type DriverTurnCancellationSource = "session.stop" | "shutdown" | "turn.cancel"; + export class DriverTurnCancelledError extends Error { - constructor(reason: string) { + readonly #resumeCancellation = new AbortController(); + readonly resumeSignal = this.#resumeCancellation.signal; + + get resumeAllowed(): boolean { + return !this.resumeSignal.aborted; + } + + constructor(reason: string, source: DriverTurnCancellationSource = "turn.cancel") { super(reason); this.name = "DriverTurnCancelledError"; + if (source !== "turn.cancel") { + this.#resumeCancellation.abort(); + } + } + + preventResume(): void { + this.#resumeCancellation.abort(); } } diff --git a/src/core/external-tool-effect-settlement.ts b/src/core/external-tool-effect-settlement.ts new file mode 100644 index 0000000..fcb9559 --- /dev/null +++ b/src/core/external-tool-effect-settlement.ts @@ -0,0 +1,75 @@ +import type { + McpExecuteCommand, + McpExternalToolEffectSettlement, + McpExternalToolExecutionResult, +} from "../runtime-command"; +import { + measureRuntimeCommandJson, + RUNTIME_COMMAND_TERMINAL_PAYLOAD_MAX_UTF8_BYTES, +} from "../runtime-command"; +const OVERSIZED_MCP_OUTPUT = + "MCP tool output was omitted because its durable settlement exceeded the 1044480-byte limit."; + +type McpResultIdentity = Pick; + +function createOmittedSettlement( + identity: McpResultIdentity, +): Extract { + return { + kind: "succeeded", + result: { isError: true, outputText: OVERSIZED_MCP_OUTPUT, ...identity }, + }; +} + +/** Proves the fixed result identity is settleable before the provider may run. */ +export function requireDurableMcpResultIdentity(command: McpResultIdentity): McpResultIdentity { + const identity = { + requestId: command.requestId, + serverId: command.serverId, + toolName: command.toolName, + }; + + if ( + measureRuntimeCommandJson(createOmittedSettlement(identity)) > + RUNTIME_COMMAND_TERMINAL_PAYLOAD_MAX_UTF8_BYTES + ) { + throw new RangeError("MCP command identity exceeds the durable settlement byte limit."); + } + + return identity; +} + +/** + * Keeps a known provider result durably settleable without retaining unbounded + * diagnostic data or presenting an omitted response as a successful tool result. + */ +export function createDurableMcpSucceededSettlement( + execution: McpExternalToolExecutionResult, + identity: McpResultIdentity, +): Extract { + const result = { + ...(execution.isError === undefined ? {} : { isError: execution.isError }), + outputText: execution.outputText, + ...identity, + }; + const settlement = { + kind: "succeeded", + ...(execution.providerReceiptJson === undefined + ? {} + : { providerReceiptJson: execution.providerReceiptJson }), + result, + } as const; + + if (measureRuntimeCommandJson(settlement) <= RUNTIME_COMMAND_TERMINAL_PAYLOAD_MAX_UTF8_BYTES) { + return settlement; + } + + const withoutReceipt = { kind: "succeeded", result } as const; + if ( + measureRuntimeCommandJson(withoutReceipt) <= RUNTIME_COMMAND_TERMINAL_PAYLOAD_MAX_UTF8_BYTES + ) { + return withoutReceipt; + } + + return createOmittedSettlement(identity); +} diff --git a/src/host-ports/index.ts b/src/host-ports/index.ts index 9a51fca..b2bb0c2 100644 --- a/src/host-ports/index.ts +++ b/src/host-ports/index.ts @@ -1,26 +1,20 @@ import type { DriverEventInput } from "../protocol/events"; import type { DriverExecutionInput } from "../protocol/execution"; -import type { DriverHostIntegrationSnapshot } from "../protocol/host-integration"; import type { RunId } from "../protocol/id"; import type { DriverEventBatchOutput } from "../protocol/orpc"; import type { + DriverCommandUpdate, McpExecuteCommand, - McpExecuteCommandResult, McpExternalToolEffectClaim, McpExternalToolEffectExecution, + McpExternalToolEffectSettlement, + McpExternalToolEffectState, McpExternalToolExecutionResult, RuntimeCommand, - RuntimeCommandResult, } from "../runtime-command"; -export type AgentDriverHostPortName = - | "command_source" - | "event_sink" - | "permission" - | "mcp" - | "skill" - | "file" - | "host_integration"; +/** Maximum provider-call duration after the durable MCP claim commits. */ +export const AGENT_DRIVER_MCP_EXECUTE_TIMEOUT_MS = 60_000; export interface AgentDriverCommandSource { nextCommand(signal: AbortSignal): Promise; @@ -28,27 +22,24 @@ export interface AgentDriverCommandSource { export interface AgentDriverEventSink { claimExternalToolEffect?( - input: { commandId: string }, + input: { claimToken: string; commandId: string }, signal: AbortSignal, ): Promise; - commandUpdate( - input: { - commandId: string; - result?: RuntimeCommandResult; - status: "accepted" | "cancelled" | "completed" | "failed"; - }, + commandUpdate(input: DriverCommandUpdate, signal: AbortSignal): Promise; + observeExternalToolEffect?( + input: { commandId: string }, signal: AbortSignal, - ): Promise; - completeExternalToolEffect?( + ): Promise; + settleExternalToolEffect?( input: { + claimToken: string; commandId: string; - providerReceiptJson?: string | null | undefined; - result: McpExecuteCommandResult; + effectId: string; + settlement: McpExternalToolEffectSettlement; }, signal: AbortSignal, - ): Promise; - currentRunId?(): RunId | null; - markExternalToolEffectUnknown?(input: { commandId: string }, signal: AbortSignal): Promise; + ): Promise; + currentRunId(): RunId | null; pushEvents(input: { events: DriverEventInput[]; signal?: AbortSignal; @@ -57,23 +48,39 @@ export interface AgentDriverEventSink { export interface AgentDriverPermissionPort { request( - input: { - rawInput: string | null; - requestId: string; - title: string; - toolCallId: string | null; - toolKind: string | null; - }, + input: DriverPermissionRequest, signal?: AbortSignal, ): Promise<"allow_once" | "reject_once">; } +export interface DriverPermissionRequest { + agentId?: string; + blockedPath?: string; + decisionReason?: string; + description?: string; + matchedAskRule?: { + readonly ruleContent?: string; + readonly source: string; + readonly toolName: string; + }; + rawInput: string | null; + requestId: string; + title: string; + toolCallId: string | null; + toolKind: string | null; +} + +export interface AgentDriverMcpExecution extends AsyncDisposable { + /** + * Runs after the durable effect claim commits. Implementations must bound + * this call independently instead of reusing the cancellable prepare signal. + */ + execute(effect: McpExternalToolEffectExecution): Promise; +} + export interface AgentDriverMcpPort { - execute( - command: McpExecuteCommand, - signal: AbortSignal, - effect?: McpExternalToolEffectExecution, - ): Promise; + /** Prepares provider state without invoking the external tool. */ + prepare(command: McpExecuteCommand, signal: AbortSignal): Promise; } export interface AgentDriverMaterializedSkill { @@ -85,26 +92,27 @@ export interface AgentDriverMaterializedSkill { } export interface AgentDriverSkillPort { - materialize(execution: DriverExecutionInput): Promise; + materialize( + execution: DriverExecutionInput, + signal: AbortSignal, + ): Promise; } export interface AgentDriverFilePort { - reportChanged(input: { - change: "delete" | "upsert"; - path: string; - reason: string; - }): Promise; -} - -export interface AgentDriverHostIntegrationPort { - snapshot(): Promise; + reportChanged( + input: { + change: "delete" | "upsert"; + path: string; + reason: string; + }, + signal: AbortSignal, + ): Promise; } export interface AgentDriverHostPorts { commandSource: AgentDriverCommandSource; eventSink: AgentDriverEventSink; file: AgentDriverFilePort; - hostIntegration: AgentDriverHostIntegrationPort; mcp: AgentDriverMcpPort; permission: AgentDriverPermissionPort; skill: AgentDriverSkillPort; diff --git a/src/index.ts b/src/index.ts index 4d9e5ab..d80dcc5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,28 +27,8 @@ export type { CmaUserMessageEvent, CmaUserToolConfirmationEvent, } from "./projections/cma"; -export { - CMA_DEFAULT_BETA_HEADER_NAME, - CMA_DEFAULT_BETA_HEADER_VALUE, - createCmaHttpHandler, -} from "./surfaces/cma-http"; -export type { - CmaHttpAuthorizationContext, - CmaHttpAuthorizer, - CmaHttpBetaHeaderRequirement, - CmaHttpDriverCommandDispatcher, - CmaHttpDriverCommandDispatchInput, - CmaHttpHandler, - CmaHttpHandlerOptions, -} from "./surfaces/cma-http"; -export { CmaSdkError, createCmaSdkClient } from "./surfaces/cma-sdk"; -export type { - CmaSdkBetaHeader, - CmaSdkClient, - CmaSdkClientOptions, - CmaSdkFetch, - CmaSessionEventDispatchRecord, -} from "./surfaces/cma-sdk"; +export * from "./surfaces/cma-http"; +export * from "./surfaces/cma-sdk"; export { createCmaMemoryStore } from "./stores/memory"; export type { CmaMemoryStoreIdFactory, CmaMemoryStoreOptions } from "./stores/memory"; export { @@ -79,16 +59,15 @@ export type { CmaStoreResourceKind, } from "./stores/cma-store"; export type { - AgentDriverHostPortName, AgentDriverHostPorts, AgentDriverCommandSource, AgentDriverEventSink, AgentDriverPermissionPort, + DriverPermissionRequest, AgentDriverMaterializedSkill, AgentDriverMcpPort, AgentDriverSkillPort, AgentDriverFilePort, - AgentDriverHostIntegrationPort, } from "./host-ports"; export type { AgentDriverBackend, @@ -98,24 +77,7 @@ export type { AgentDriverContextPortOverrides, } from "./core/agent-driver-backend"; export { createAgentDriverContext } from "./core/agent-driver-backend"; -export { OPENAI_DEFAULT_MODEL_ID } from "./models"; -export { - isSupportedDriverRuntime, - isSupportedDriverRuntimeTransport, - SUPPORTED_DRIVER_NATIVE_RUNTIME_REF_KINDS, - SUPPORTED_DRIVER_RUNTIMES, - SUPPORTED_DRIVER_RUNTIME_TRANSPORTS, -} from "./protocol/runtime"; -export type { - DriverNativeRuntimeRef, - DriverNativeRuntimeRefKind, - DriverRuntime, - DriverRuntimeTransport, -} from "./protocol/runtime"; -export { - getExpectedDriverNativeRuntimeRefKind, - parseDriverNativeRuntimeRef, -} from "./protocol/runtime"; +export * from "./protocol/runtime"; export { parseDriverEventEnvelope } from "./protocol/events"; export type { DriverEvent, DriverEventEnvelope, DriverEventInput } from "./protocol/events"; export type { @@ -123,7 +85,6 @@ export type { DriverExecutionRunInput, DriverExecutionSessionInput, } from "./protocol/execution"; -export type { DriverHostIntegrationSnapshot } from "./protocol/host-integration"; export type { DriverStartInput } from "./protocol/start"; export { createDriverId, @@ -142,10 +103,15 @@ export type { MessageId, RunId, } from "./protocol/id"; -export { parseRuntimeCommand } from "./runtime-command"; +export { + createMcpUnknownEffectRunError, + createMcpUnsettledEffectRunError, + parseRuntimeCommand, +} from "./runtime-command"; export type { DriverCapability, DriverCapabilityId, + DriverCommandUpdate, InputStartCommand, InputStartCommandResult, McpExecuteCommand, @@ -162,9 +128,5 @@ export type { export { AGENT_DRIVER_PROVIDER_REGISTRY, createAgentDriverProviderCapabilities, - createAgentDriverProviderRegistry, -} from "./runtimes/provider-registry"; -export type { - AgentDriverProviderDescriptor, - AgentDriverProviderRegistry, } from "./runtimes/provider-registry"; +export type { AgentDriverProviderDescriptor } from "./runtimes/provider-registry"; diff --git a/src/infrastructure/logging/driver-logger.ts b/src/infrastructure/logging/driver-logger.ts index 3093f04..2615fd4 100644 --- a/src/infrastructure/logging/driver-logger.ts +++ b/src/infrastructure/logging/driver-logger.ts @@ -109,6 +109,7 @@ export function createDriverLogger( payload: DriverBootPayload, socket: DriverInstanceSocket, ): DriverLoggerHandle { + const sandboxId = payload.execution.session.context.sandboxId; let nextSeq = 0; let openUplink: () => void = () => undefined; // The API rejects pushLogs until the hello handshake commits. Hold every @@ -121,7 +122,7 @@ export function createDriverLogger( const logger = createBufferedSinkLogger({ context: { - sandboxId: payload.sandboxId, + sandboxId, }, flushIntervalMs: 200, level: "trace", @@ -159,7 +160,7 @@ export async function runWithDriverLogContext( return runWithLogContextAsync( createTraceLogContext({ context: { - sandboxId: payload.sandboxId, + sandboxId: payload.execution.session.context.sandboxId, }, service: "driver", traceparent: payload.traceparent, diff --git a/src/infrastructure/runtime/driver-event-envelope.ts b/src/infrastructure/runtime/driver-event-envelope.ts index af3cc7c..8a6c653 100644 --- a/src/infrastructure/runtime/driver-event-envelope.ts +++ b/src/infrastructure/runtime/driver-event-envelope.ts @@ -1,7 +1,7 @@ import { DriverEventRejectedError } from "../../core/driver-runtime-io"; import type { DriverBootPayload } from "../../protocol/boot"; import type { DriverEventEnvelope, DriverEventInput } from "../../protocol/events"; -import { createDriverId, parseDriverId } from "../../protocol/id"; +import { createDriverId, parseDriverId, parseRunId } from "../../protocol/id"; import type { DriverInstanceId, EventId, SessionId, RunId } from "../../protocol/id"; import { isRuntimeEventEnvelope, toRuntimeEventInput } from "../../runtime-events"; @@ -11,14 +11,14 @@ function readExplicitSourceEventId(event: DriverEventInput): string | undefined : undefined; } -function parseRunId(value: string): RunId { - return parseDriverId(value, "Run ID") as RunId; -} - function readEventRunId(event: DriverEventInput, activeRunId: RunId | null): RunId | undefined { const { runId: eventRunId } = event; - return eventRunId === undefined ? (activeRunId ?? undefined) : parseRunId(eventRunId); + return eventRunId === null + ? undefined + : eventRunId === undefined + ? (activeRunId ?? undefined) + : parseRunId(eventRunId); } export function toDriverEventEnvelopes( @@ -54,13 +54,11 @@ export function toDriverEventEnvelopes( sourceEventId, }, event, - ).map( - (canonicalEvent): DriverEventEnvelope => ({ - event: canonicalEvent, - eventId: canonicalEvent.sourceEventId ?? canonicalEvent.id, - occurredAt: canonicalEvent.occurredAt, - }), - ); + ).map((canonicalEvent): DriverEventEnvelope => ({ + event: canonicalEvent, + eventId: canonicalEvent.sourceEventId ?? canonicalEvent.id, + occurredAt: canonicalEvent.occurredAt, + })); } catch (error) { throw new DriverEventRejectedError(sourceEventId, error); } diff --git a/src/infrastructure/runtime/driver-instance-socket.ts b/src/infrastructure/runtime/driver-instance-socket.ts index ac12ac4..d7380a7 100644 --- a/src/infrastructure/runtime/driver-instance-socket.ts +++ b/src/infrastructure/runtime/driver-instance-socket.ts @@ -1,16 +1,31 @@ -import { isDeepStrictEqual } from "node:util"; - import { createORPCClient } from "@orpc/client"; import { RPCLink } from "@orpc/client/websocket"; -import { assertDriverEventReceiptPrefix } from "../../core/driver-runtime-io"; +import { + assertDriverEventReceiptPrefix, + assertIsolatedRunTerminalBatch, +} from "../../core/driver-runtime-io"; +import type { DriverRuntimeIo } from "../../core/driver-runtime-io"; +import type { DriverRunTerminalBarrier } from "../../core/driver-runtime-io"; +import { + DriverTerminalStateMachine, + type DriverInputOutcome, + type DriverInputSettlement, + type DriverInstanceTerminal, + type DriverRunSnapshot, + type DriverRunTerminalIdentity, + type DriverRunTicket, +} from "../../core/driver-terminal-state"; import type { DriverBootPayload } from "../../protocol/boot"; -import type { DriverEventInput } from "../../protocol/events"; +import type { DriverEventEnvelope, DriverEventInput } from "../../protocol/events"; +import { parseRunId } from "../../protocol/id"; import type { RunId } from "../../protocol/id"; +import { driverRuntimeRpcSchemas } from "../../protocol/orpc"; import type { DriverFailureInput, DriverEventBatchOutput, DriverExternalToolEffectClaimOutput, + DriverExternalToolEffectState, DriverHeartbeatInput, DriverHeartbeatOutput, DriverHelloInput, @@ -21,13 +36,13 @@ import type { } from "../../protocol/orpc"; import type { DriverRuntimeClient } from "../../protocol/orpc"; import type { - McpExecuteCommandResult, + DriverCommandUpdate, McpExternalToolEffectClaim, - RunError, + McpExternalToolEffectState, RuntimeCommand, - RuntimeCommandResult, } from "../../runtime-command"; -import { parseRuntimeCommand } from "../../runtime-command"; +import { normalizeDurableRunError } from "../../runtime-command"; +import { raceWithAbort } from "../../utils/async"; import { dialDriverControlSocket } from "./driver-control-dial"; import type { DriverWireSocket } from "./driver-control-dial"; import { toDriverEventEnvelopes } from "./driver-event-envelope"; @@ -38,33 +53,47 @@ interface DriverInstanceSocketHandlers { onClose: (code: number, reason: string) => void; } -type RunTerminalDelivery = - | { - delivered: boolean; - status: "completed"; - task?: Promise; - } - | { - delivered: boolean; - error: DriverFailureInput["error"]; - status: "failed"; - task?: Promise; - }; +interface PreparedEventPush { + readonly client: DriverRuntimeClient; + readonly delivery: DriverEventEnvelope["event"]["delivery"] | undefined; + readonly events: DriverEventEnvelope[]; + readonly generation: number; + readonly hasRunScopedEvent: boolean; + readonly maxBatchSize: number; + readonly runTicket: DriverRunTicket | null; + readonly terminal: DriverRunTerminalIdentity | null; + readonly terminalSelection: "acked" | "pending" | "selected" | null; + readonly signal: AbortSignal | undefined; +} + +interface RunEventTerminalTask { + readonly task: Promise; + readonly ticket: DriverRunTicket; +} const DRIVER_RPC_TIMEOUT_MS = 10_000; +const MAX_WEBSOCKET_CLOSE_REASON_BYTES = 123; + +function toWebSocketCloseReason(reason: string): string { + const { read } = new TextEncoder().encodeInto( + reason, + new Uint8Array(MAX_WEBSOCKET_CLOSE_REASON_BYTES), + ); + return reason.slice(0, read); +} export class DriverInstanceSocket { - #activeRunId: RunId | null = null; + #activeRunTicket: DriverRunTicket | null = null; #client: DriverRuntimeClient | null = null; #connectionGeneration = 0; #connectAbortController: AbortController | null = null; + #deliveryTail: Promise = Promise.resolve(); #eventBatchMaxSize: number | null = null; #rpcAbortController = new AbortController(); - #runEventTerminal: { - readonly runId: RunId; - readonly status: "cancelled" | "completed" | "failed"; - } | null = null; - #runTerminal: RunTerminalDelivery | null = null; + #instanceTerminalTask: Promise | null = null; + #runEventTerminalTask: RunEventTerminalTask | null = null; + #runTerminalBarrier: DriverRunTerminalBarrier | null = null; + readonly #terminalState = new DriverTerminalStateMachine(); private readonly handlers: DriverInstanceSocketHandlers; private readonly payload: DriverBootPayload; #socket: DriverWireSocket | null = null; @@ -95,11 +124,11 @@ export class DriverInstanceSocket { this.#eventBatchMaxSize = null; this.#rpcAbortController = new AbortController(); this.#socket = socket; - this.#client = createORPCClient( + this.#client = createORPCClient( new RPCLink({ websocket: socket, }), - ) as unknown as DriverRuntimeClient; + ); socket.addEventListener("close", (event) => { if (generation !== this.#connectionGeneration || this.#socket !== socket) { @@ -132,89 +161,119 @@ export class DriverInstanceSocket { this.#client = null; this.#eventBatchMaxSize = null; const socket = this.#socket; - socket?.close(code, reason); + socket?.close(code, toWebSocketCloseReason(reason)); if (this.#socket === socket) { this.#socket = null; } } - beginRun(runId: RunId): void { - this.#activeRunId = runId; - this.#runEventTerminal = null; - this.#runTerminal = null; + beginRun(runId: RunId): DriverRunTicket { + const ticket = this.#terminalState.beginRun(runId); + this.#activeRunTicket = ticket; + return ticket; } - endRun(runId: RunId): void { - if (this.#activeRunId === runId) { - this.#activeRunId = null; + claimRunCancellation( + ticket: DriverRunTicket, + reason: string, + source?: Parameters[2], + ): "already_claimed" | "claimed" | "terminal_selected" { + return this.#terminalState.claimCancellation(ticket, reason, source); + } + + releaseRun(ticket: DriverRunTicket, reason: "command_acked" | "driver_failing"): void { + this.#terminalState.releaseRun(ticket, reason); + if (this.#activeRunTicket === ticket) { + this.#activeRunTicket = null; + this.#runEventTerminalTask = null; } } currentRunId(): RunId | null { - return this.#activeRunId; + return this.#terminalState.currentRunId(); } - runEventTerminal(runId: RunId): "cancelled" | "completed" | "failed" | null { - return this.#runEventTerminal?.runId === runId ? this.#runEventTerminal.status : null; + runSnapshot(runId?: RunId): DriverRunSnapshot | null { + return this.#terminalState.snapshotRun(runId); } - async commandUpdate( - input: { - commandId: string; - error?: RunError; - result?: RuntimeCommandResult; - status: "accepted" | "cancelled" | "completed" | "delivered" | "expired" | "failed"; - }, - signal: AbortSignal, - ): Promise { - await this.#requireClient().driver.commandUpdate( - { - commandId: input.commandId, - driverInstanceId: this.payload.driverInstanceId, - ...(input.error === undefined ? {} : { error: input.error }), - status: input.status, - ...(input.result === undefined ? {} : { result: input.result }), - }, - this.#rpcOptions(signal), + settleRunInput(ticket: DriverRunTicket, outcome: DriverInputOutcome): DriverInputSettlement { + return this.#terminalState.settleInput(ticket, outcome); + } + + async commandUpdate(input: DriverCommandUpdate, signal: AbortSignal): Promise { + const update = + input.status === "failed" + ? { ...input, error: normalizeDurableRunError(input.error) } + : input; + driverRuntimeRpcSchemas.driver.commandUpdate.output.parse( + await this.#requireClient().driver.commandUpdate( + { + ...update, + driverInstanceId: this.payload.driverInstanceId, + }, + this.#rpcOptions(signal), + ), ); } async claimExternalToolEffect( - input: { commandId: string }, + input: { claimToken: string; commandId: string }, signal: AbortSignal, ): Promise { const result: DriverExternalToolEffectClaimOutput = - await this.#requireClient().driver.claimExternalToolEffect( - { - commandId: input.commandId, - driverInstanceId: this.payload.driverInstanceId, - }, - this.#rpcOptions(signal), + driverRuntimeRpcSchemas.driver.claimExternalToolEffect.output.parse( + await this.#requireClient().driver.claimExternalToolEffect( + { + claimToken: input.claimToken, + commandId: input.commandId, + driverInstanceId: this.payload.driverInstanceId, + }, + this.#rpcOptions(signal), + ), ); return result; } - async completeExternalToolEffect( - input: { - commandId: string; - providerReceiptJson?: string | null | undefined; - result: McpExecuteCommandResult; - }, + async observeExternalToolEffect( + input: Parameters[0], signal: AbortSignal, - ): Promise { - await this.#requireClient().driver.completeExternalToolEffect( - { - commandId: input.commandId, - driverInstanceId: this.payload.driverInstanceId, - ...(input.providerReceiptJson === undefined - ? {} - : { providerReceiptJson: input.providerReceiptJson }), - result: input.result, - }, - this.#rpcOptions(signal), - ); + ): Promise { + const result: DriverExternalToolEffectState = + driverRuntimeRpcSchemas.driver.observeExternalToolEffect.output.parse( + await this.#requireClient().driver.observeExternalToolEffect( + { + commandId: input.commandId, + driverInstanceId: this.payload.driverInstanceId, + }, + this.#rpcOptions(signal), + ), + ); + + return result; + } + + async settleExternalToolEffect( + input: Parameters[0], + signal: AbortSignal, + ): Promise { + const result: DriverExternalToolEffectState = + driverRuntimeRpcSchemas.driver.settleExternalToolEffect.output.parse( + await this.#requireClient().driver.settleExternalToolEffect( + { + claimToken: input.claimToken, + commandId: input.commandId, + driverInstanceId: this.payload.driverInstanceId, + effectId: input.effectId, + settlement: input.settlement, + }, + this.#effectSettlementRpcOptions(signal), + ), + ); + + return result; } completeRun(signal?: AbortSignal): Promise { @@ -226,13 +285,15 @@ export class DriverInstanceSocket { } async heartbeat(input: Omit): Promise { - return this.#requireClient().driver.heartbeat( - { - at: input.at, - pid: process.pid, - reason: input.reason, - }, - this.#rpcOptions(), + return driverRuntimeRpcSchemas.driver.heartbeat.output.parse( + await this.#requireClient().driver.heartbeat( + { + at: input.at, + pid: process.pid, + reason: input.reason, + }, + this.#rpcOptions(), + ), ); } @@ -243,33 +304,27 @@ export class DriverInstanceSocket { ): Promise { const generation = this.#connectionGeneration; const client = this.#requireClient(); - const result = await client.driver.hello( - { - capabilities: input.capabilities, - driverVersion: input.driverVersion, - pid: process.pid, - protocolVersion: input.protocolVersion, - runtime: this.payload.runtime, - startedAt: input.startedAt, - }, - this.#rpcOptions(), + const result = driverRuntimeRpcSchemas.driver.hello.output.parse( + await client.driver.hello( + { + capabilities: input.capabilities, + driverVersion: input.driverVersion, + pid: process.pid, + protocolVersion: input.protocolVersion, + runtime: this.payload.runtime, + startedAt: input.startedAt, + }, + this.#rpcOptions(), + ), ); if (generation !== this.#connectionGeneration || client !== this.#client) { throw new Error("Driver socket connection changed during hello."); } - if (!Number.isSafeInteger(result.heartbeatIntervalMs) || result.heartbeatIntervalMs < 250) { - throw new Error("Driver heartbeat interval must be an integer of at least 250ms."); + if (result.runId !== null) { + this.#terminalState.rememberOwnedRunId(parseRunId(result.runId)); } - - if ( - !Number.isSafeInteger(result.runConfig.eventBatchMaxSize) || - result.runConfig.eventBatchMaxSize < 1 - ) { - throw new Error("Driver event batch max size must be a positive integer."); - } - this.#eventBatchMaxSize = result.runConfig.eventBatchMaxSize; return result; } @@ -278,6 +333,87 @@ export class DriverInstanceSocket { events: DriverEventInput[]; signal?: AbortSignal; }): Promise { + input.signal?.throwIfAborted(); + const ownedInput = { ...input, events: structuredClone(input.events) }; + assertIsolatedRunTerminalBatch(ownedInput.events); + const barrier = this.#runTerminalBarrier; + if (barrier !== null) { + const pending = barrier(ownedInput.events); + if (pending !== undefined) { + await pending; + } + } + const prepared = this.#prepareEventPush(ownedInput); + const { runTicket, terminal, terminalSelection } = prepared; + + if (terminalSelection === "acked") { + const snapshot = this.#terminalState.snapshotRun(terminal!.runId); + const receipt = snapshot?.terminal?.phase === "acked" ? snapshot.terminal.receipt : null; + if (receipt === null) { + throw new Error("Driver run terminal acknowledgement is unavailable."); + } + return { accepted: [receipt] }; + } + + if (terminalSelection === "pending" && this.#runEventTerminalTask !== null) { + if (this.#runEventTerminalTask.ticket !== runTicket) { + throw new Error("Driver active run changed during terminal delivery."); + } + return { accepted: [await this.#runEventTerminalTask.task] }; + } + + const task = this.#enqueueDelivery(() => this.#deliverEventPush(prepared), input.signal); + const terminalTask = + terminal === null || runTicket === null + ? null + : { + task: task.then((result) => { + if (result.accepted.length !== prepared.events.length) { + throw new Error("Driver run terminal batch was not fully acknowledged."); + } + const receipt = result.accepted.at(-1); + if (receipt === undefined) { + throw new Error("Driver run terminal receipt is missing."); + } + this.#terminalState.ackRunTerminal(runTicket, receipt); + return receipt; + }), + ticket: runTicket, + }; + + if (terminalTask !== null) { + this.#runEventTerminalTask = terminalTask; + void terminalTask.task.catch(() => {}); + } + + try { + const result = await task; + await terminalTask?.task; + return result; + } finally { + if (this.#runEventTerminalTask === terminalTask) { + this.#runEventTerminalTask = null; + } + } + } + + registerRunTerminalBarrier(barrier: DriverRunTerminalBarrier): () => void { + if (this.#runTerminalBarrier !== null) { + throw new Error("Driver run terminal barrier is already registered."); + } + + this.#runTerminalBarrier = barrier; + return () => { + if (this.#runTerminalBarrier === barrier) { + this.#runTerminalBarrier = null; + } + }; + } + + #prepareEventPush(input: { + events: DriverEventInput[]; + signal?: AbortSignal; + }): PreparedEventPush { input.signal?.throwIfAborted(); const maxBatchSize = this.#eventBatchMaxSize; @@ -285,28 +421,131 @@ export class DriverInstanceSocket { throw new Error("Driver hello must complete before events are pushed."); } - const events = input.events.flatMap((event) => - toDriverEventEnvelopes(this.payload, event, this.#activeRunId), - ); - const accepted: DriverEventBatchOutput["accepted"][number][] = []; + const runTicket = this.#activeRunTicket; + const activeRunId = runTicket?.runId ?? null; + const selectedTerminal = + runTicket === null ? null : this.#terminalState.snapshotRun(runTicket.runId)?.terminal?.value; + const events = structuredClone(input.events) + .map((event) => + selectedTerminal !== null && + selectedTerminal !== undefined && + event.sourceEventId === undefined && + (event.kind === "run.cancelled" || + event.kind === "run.completed" || + event.kind === "run.failed") + ? { ...event, sourceEventId: selectedTerminal.sourceEventId } + : event, + ) + .flatMap((event) => toDriverEventEnvelopes(this.payload, event, activeRunId)); const delivery = events[0]?.event.delivery; + let terminal: DriverRunTerminalIdentity | null = null; + let terminalIndex = -1; + let hasRunScopedEvent = false; if (events.some((envelope) => envelope.event.delivery !== delivery)) { throw new Error("Driver event batches cannot mix lossless and best-effort delivery."); } - const rpcOptions = this.#rpcOptions(input.signal); + + for (const [index, { event }] of events.entries()) { + if (event.runId !== undefined) { + if (event.runId !== activeRunId) { + throw new Error("Driver event must target the active run."); + } + hasRunScopedEvent = true; + } + + const status = + event.kind === "run.cancelled" + ? "cancelled" + : event.kind === "run.completed" + ? "completed" + : event.kind === "run.failed" + ? "failed" + : null; + + if (status === null) { + continue; + } + if (event.runId === undefined) { + throw new Error("Driver run terminal must target a run."); + } + if (terminal !== null) { + throw new Error("Driver event batch cannot contain multiple run terminals."); + } + + terminal = { + event: { + kind: event.kind, + payload: structuredClone(event.payload), + sourceEventId: event.sourceEventId ?? event.id, + }, + runId: event.runId, + sourceEventId: event.sourceEventId ?? event.id, + status, + }; + terminalIndex = index; + } + + if (terminalIndex >= 0 && terminalIndex !== events.length - 1) { + throw new Error("Driver run terminal must be the final event in its batch."); + } + if (terminal !== null && delivery === "best_effort") { + throw new Error("Driver run terminal must use lossless delivery."); + } + + let terminalSelection: PreparedEventPush["terminalSelection"] = null; + if (terminal !== null) { + if (runTicket === null) { + throw new Error("Driver run terminal must target the active run."); + } + const selection = this.#terminalState.selectRunTerminal(runTicket, terminal); + if (selection === "cancelled") { + throw new Error("Driver completed terminal lost the cancellation race."); + } + terminalSelection = selection; + if (terminalSelection !== "selected" && events.length !== 1) { + throw new Error("A selected driver run terminal can only be retried by itself."); + } + } else if (hasRunScopedEvent && this.#terminalState.snapshotRun()?.terminal !== null) { + throw new Error("Driver event cannot target a terminated run."); + } + + return { + client: this.#requireClient(), + delivery, + events, + generation: this.#connectionGeneration, + hasRunScopedEvent, + maxBatchSize, + runTicket, + signal: input.signal, + terminal, + terminalSelection, + }; + } + + async #deliverEventPush(prepared: PreparedEventPush): Promise { + const { client, delivery, events, generation, maxBatchSize, signal } = prepared; + const accepted: DriverEventBatchOutput["accepted"][number][] = []; + this.#assertConnection(client, generation, "event delivery"); + this.#assertRunTicket(prepared); + const rpcOptions = this.#rpcOptions(signal); for (let index = 0; index < events.length; index += maxBatchSize) { let remaining = events.slice(index, index + maxBatchSize); while (remaining.length > 0) { - const result = await this.#requireClient().driver.pushEvents( - { - driverInstanceId: this.payload.driverInstanceId, - events: remaining, - }, - rpcOptions, + const result = driverRuntimeRpcSchemas.driver.pushEvents.output.parse( + await client.driver.pushEvents( + { + driverInstanceId: this.payload.driverInstanceId, + events: remaining, + }, + rpcOptions, + ), ); + this.#assertConnection(client, generation, "event delivery"); + this.#assertRunTicket(prepared); assertDriverEventReceiptPrefix( remaining.map((envelope) => envelope.event), @@ -322,19 +561,6 @@ export class DriverInstanceSocket { } accepted.push(...result.accepted); - for (const { event } of remaining.slice(0, result.accepted.length)) { - const status = - event.kind === "run.cancelled" - ? "cancelled" - : event.kind === "run.completed" - ? "completed" - : event.kind === "run.failed" - ? "failed" - : null; - if (status !== null && event.runId !== undefined) { - this.#runEventTerminal = { runId: event.runId, status }; - } - } if (delivery === "best_effort" && result.accepted.length < remaining.length) { return { accepted }; @@ -348,23 +574,27 @@ export class DriverInstanceSocket { } async pushLogs(input: Omit): Promise { - await this.#requireClient().driver.pushLogs( - { - driverInstanceId: this.payload.driverInstanceId, - logs: input.logs, - }, - this.#rpcOptions(), + driverRuntimeRpcSchemas.driver.pushLogs.output.parse( + await this.#requireClient().driver.pushLogs( + { + driverInstanceId: this.payload.driverInstanceId, + logs: input.logs, + }, + this.#rpcOptions(), + ), ); } async ready(input: Omit): Promise { - await this.#requireClient().driver.ready( - { - at: input.at, - driverInstanceId: this.payload.driverInstanceId, - pid: process.pid, - }, - this.#rpcOptions(), + driverRuntimeRpcSchemas.driver.ready.output.parse( + await this.#requireClient().driver.ready( + { + at: input.at, + driverInstanceId: this.payload.driverInstanceId, + pid: process.pid, + }, + this.#rpcOptions(), + ), ); } @@ -374,11 +604,13 @@ export class DriverInstanceSocket { let result: Awaited>; try { - result = await this.#requireClient().driverInstance.nextCommand( - { - driverInstanceId: this.payload.driverInstanceId, - }, - this.#rpcOptions(signal, timeoutSignal), + result = driverRuntimeRpcSchemas.driverInstance.nextCommand.output.parse( + await this.#requireClient().driverInstance.nextCommand( + { + driverInstanceId: this.payload.driverInstanceId, + }, + this.#rpcOptions(signal, timeoutSignal), + ), ); } catch (error) { if ( @@ -393,20 +625,7 @@ export class DriverInstanceSocket { throw error; } - return result.command === null ? null : parseRuntimeCommand(result.command); - } - - async markExternalToolEffectUnknown( - input: { commandId: string }, - signal: AbortSignal, - ): Promise { - await this.#requireClient().driver.markExternalToolEffectUnknown( - { - commandId: input.commandId, - driverInstanceId: this.payload.driverInstanceId, - }, - this.#rpcOptions(signal), - ); + return result.command; } #deliverRunTerminal( @@ -414,80 +633,110 @@ export class DriverInstanceSocket { error?: DriverFailureInput["error"], signal?: AbortSignal, ): Promise { - let terminal = this.#runTerminal; - - if (terminal === null) { - if (status === "failed" && error === undefined) { - return Promise.reject(new Error("Failed run terminal requires an error.")); - } - - terminal = - status === "completed" - ? { delivered: false, status } - : { delivered: false, error: structuredClone(error!), status }; - this.#runTerminal = terminal; + if (status === "failed" && error === undefined) { + return Promise.reject(new Error("Failed run terminal requires an error.")); } - if (terminal.status !== status) { - return Promise.resolve(); + const runId = this.#terminalState.terminalRunId(this.payload.execution.configRevision.runId); + if (runId === null) { + return Promise.reject(new Error("Driver run terminal requires an exact run ID.")); } - if ( - terminal.status === "failed" && - (error === undefined || !isDeepStrictEqual(terminal.error, error)) - ) { - return Promise.reject(new Error("Failed run terminal was retried with a different error.")); + + const terminal: DriverInstanceTerminal = + status === "completed" + ? { runId, status } + : { error: normalizeDurableRunError(structuredClone(error!)), runId, status }; + let selection: "acked" | "pending" | "selected"; + try { + selection = this.#terminalState.selectInstanceTerminal(terminal); + } catch (selectionError) { + return Promise.reject(selectionError); } - if (terminal.delivered) { + + if (selection === "acked") { return Promise.resolve(); } - if (terminal.task !== undefined) { - return terminal.task; + if (selection === "pending" && this.#instanceTerminalTask !== null) { + return this.#instanceTerminalTask; } const client = this.#client; const generation = this.#connectionGeneration; - const options = this.#rpcOptions(signal); - const task = Promise.resolve().then(async () => { + const task = this.#enqueueDelivery(async () => { if (client === null) { throw new Error("Driver instance socket is not connected."); } + this.#assertConnection(client, generation, "run terminal delivery"); + this.#assertInstanceTerminal(terminal); + const options = this.#rpcOptions(signal); if (terminal.status === "completed") { - await client.driver.completeRun( - { driverInstanceId: this.payload.driverInstanceId }, - options, + driverRuntimeRpcSchemas.driver.completeRun.output.parse( + await client.driver.completeRun( + { driverInstanceId: this.payload.driverInstanceId, runId: terminal.runId }, + options, + ), ); } else { - await client.driver.failRun( - { - driverInstanceId: this.payload.driverInstanceId, - error: structuredClone(terminal.error), - }, - options, + driverRuntimeRpcSchemas.driver.failRun.output.parse( + await client.driver.failRun( + { + driverInstanceId: this.payload.driverInstanceId, + error: structuredClone(terminal.error), + runId: terminal.runId, + }, + options, + ), ); } - if (generation !== this.#connectionGeneration || client !== this.#client) { - throw new Error("Driver socket connection changed during run terminal delivery."); - } - }); - terminal.task = task; + this.#assertConnection(client, generation, "run terminal delivery"); + this.#assertInstanceTerminal(terminal); + this.#terminalState.ackInstanceTerminal(terminal); + }, signal); + this.#instanceTerminalTask = task; void task.then( () => { - if (terminal.task === task) { - terminal.delivered = true; - delete terminal.task; + if (this.#instanceTerminalTask === task) { + this.#instanceTerminalTask = null; } }, () => { - if (terminal.task === task) { - delete terminal.task; + if (this.#instanceTerminalTask === task) { + this.#instanceTerminalTask = null; } }, ); return task; } + #assertConnection(client: DriverRuntimeClient, generation: number, operation: string): void { + if (generation !== this.#connectionGeneration || client !== this.#client) { + throw new Error(`Driver socket connection changed during ${operation}.`); + } + } + + #assertRunTicket(prepared: PreparedEventPush): void { + if ( + prepared.hasRunScopedEvent && + (prepared.runTicket === null || + this.#terminalState.snapshotRun()?.revision !== prepared.runTicket.revision) + ) { + throw new Error("Driver active run changed during event delivery."); + } + } + + #assertInstanceTerminal(terminal: DriverInstanceTerminal): void { + this.#terminalState.selectInstanceTerminal(terminal); + } + + #enqueueDelivery(operation: () => Promise, signal?: AbortSignal): Promise { + const predecessor = this.#deliveryTail; + const task = raceWithAbort(predecessor, signal).then(operation); + this.#deliveryTail = Promise.allSettled([predecessor, task]).then(() => {}); + return task; + } + #requireClient(): DriverRuntimeClient { if (!this.#client) { throw new Error("Driver instance socket is not connected."); @@ -508,4 +757,10 @@ export class DriverInstanceSocket { ]), }; } + + #effectSettlementRpcOptions(signal: AbortSignal): DriverRpcOptions { + return { + signal: AbortSignal.any([signal, AbortSignal.timeout(DRIVER_RPC_TIMEOUT_MS)]), + }; + } } diff --git a/src/models/index.ts b/src/models/index.ts deleted file mode 100644 index 4179bc3..0000000 --- a/src/models/index.ts +++ /dev/null @@ -1 +0,0 @@ -export const OPENAI_DEFAULT_MODEL_ID = "gpt-5.6-luna"; diff --git a/src/observability/driver-debug-events.ts b/src/observability/driver-debug-events.ts index e01c4d6..71ad4fe 100644 --- a/src/observability/driver-debug-events.ts +++ b/src/observability/driver-debug-events.ts @@ -71,10 +71,14 @@ function addDriverEventToSummary(summary: DriverEventBatchSummary, event: Driver if (event.kind === "tool.call.updated") { const payload = isUnknownRecord(event.payload) ? event.payload : {}; - if (typeof payload["rawInput"] === "string") { + if (typeof payload["rawInput"] === "string" || typeof payload["rawInputDelta"] === "string") { summary.toolCallArgsCount += 1; } - if (typeof payload["rawOutput"] === "string" || typeof payload["content"] === "string") { + if ( + typeof payload["rawOutput"] === "string" || + typeof payload["rawOutputDelta"] === "string" || + typeof payload["content"] === "string" + ) { summary.toolCallResultCount += 1; } } diff --git a/src/observability/driver-debug-paths.ts b/src/observability/driver-debug-paths.ts index fda7b20..9953469 100644 --- a/src/observability/driver-debug-paths.ts +++ b/src/observability/driver-debug-paths.ts @@ -101,7 +101,6 @@ export function summarizeDriverBootPayload(payload: DriverBootPayload): Record { +export function summarizeDriverPermissionRequest( + input: DriverPermissionRequest, +): Record { return { + agentId: input.agentId, + blockedPath: summarizeTextDigest(input.blockedPath ?? null), + decisionReason: summarizeTextDigest(input.decisionReason ?? null), + description: summarizeTextDigest(input.description ?? null), + matchedAskRule: + input.matchedAskRule === undefined + ? undefined + : { + ruleContent: summarizeTextDigest(input.matchedAskRule.ruleContent ?? null), + source: input.matchedAskRule.source, + toolName: input.matchedAskRule.toolName, + }, rawInput: summarizeTextDigest(input.rawInput), requestId: input.requestId, title: summarizeTextDigest(input.title), diff --git a/src/observability/index.ts b/src/observability/index.ts index a0c5dff..7e1ba08 100644 --- a/src/observability/index.ts +++ b/src/observability/index.ts @@ -348,6 +348,10 @@ function createBaseLogger(options: BaseLoggerOptions): Logger { return createLogger(config); } +export function createDisabledLogger(): Logger { + return createLogger({ enabled: false, transports: [] }); +} + export function createBufferedSinkLogger(options: CreateBufferedSinkLoggerOptions): Logger { const logger = createBaseLogger(options); const level = options.level ?? "info"; diff --git a/src/projections/cma/inbound.ts b/src/projections/cma/inbound.ts index 380764d..3fdaeec 100644 --- a/src/projections/cma/inbound.ts +++ b/src/projections/cma/inbound.ts @@ -1,5 +1,11 @@ import type { RuntimeCommand } from "../../runtime-command"; +export type CmaProjectedDriverCommand = + | Extract + | Omit, "runId"> + | Omit, "runId"> + | Omit, "runId">; + type CmaInboundType = | "user.custom_tool_result" | "user.interrupt" @@ -184,7 +190,7 @@ export function parseCmaInboundEvent(input: unknown): CmaInboundEvent { } } -export function projectCmaInboundToDriverCommand(input: unknown): RuntimeCommand { +export function projectCmaInboundToDriverCommand(input: unknown): CmaProjectedDriverCommand { const event = parseCmaInboundEvent(input); switch (event.type) { diff --git a/src/projections/cma/index.ts b/src/projections/cma/index.ts index 47165bb..736a7b2 100644 --- a/src/projections/cma/index.ts +++ b/src/projections/cma/index.ts @@ -4,6 +4,7 @@ export { parseCmaInboundEvent, projectCmaInboundToDriverCommand, type CmaInboundEvent, + type CmaProjectedDriverCommand, type CmaUserCustomToolResultEvent, type CmaUserInterruptEvent, type CmaUserMessageEvent, diff --git a/src/projections/cma/outbound.ts b/src/projections/cma/outbound.ts index 6e4d35d..3c96a15 100644 --- a/src/projections/cma/outbound.ts +++ b/src/projections/cma/outbound.ts @@ -61,10 +61,13 @@ export function projectDriverEventToCma(event: DriverEventInput): CmaOutboundEve switch (event.kind) { case "message.added": + case "message.cancelled": case "message.completed": case "message.delta": + case "message.failed": case "message.started": return [{ message: payload, sourceEventKind: event.kind, type: "agent.message" }]; + case "thought.cancelled": case "thought.completed": case "thought.delta": case "thought.started": @@ -75,7 +78,20 @@ export function projectDriverEventToCma(event: DriverEventInput): CmaOutboundEve return [ { requiresAction: { + ...(payload["agentId"] === undefined ? {} : { agentId: payload["agentId"] }), + ...(payload["blockedPath"] === undefined + ? {} + : { blockedPath: payload["blockedPath"] }), + ...(payload["decisionReason"] === undefined + ? {} + : { decisionReason: payload["decisionReason"] }), details: payload["details"], + ...(payload["description"] === undefined + ? {} + : { description: payload["description"] }), + ...(payload["matchedAskRule"] === undefined + ? {} + : { matchedAskRule: payload["matchedAskRule"] }), requestId: payload["requestId"], targetItemId: payload["targetItemId"], title: payload["title"], diff --git a/src/protocol/boot/host-snapshot.ts b/src/protocol/boot/host-snapshot.ts index d1588b3..5de18bf 100644 --- a/src/protocol/boot/host-snapshot.ts +++ b/src/protocol/boot/host-snapshot.ts @@ -1,4 +1,7 @@ -import type { DriverId, SessionId, RunId } from "../id"; +import { z } from "zod"; + +import type { DriverId, RunId, SessionId } from "../id"; +import { DRIVER_ID_INPUT_PATTERN, normalizeDriverId } from "../id"; import type { AccountId, AgentDeploymentVersionId, @@ -8,108 +11,76 @@ import type { SandboxId, SandboxSessionId, } from "./host-ids"; -import { parseId, parseNullableId, readNonEmptyString, readNumber, readRecord } from "./readers"; -export interface DriverOrigin { - readonly callerUserId: AccountId; - readonly entrypoint: "api" | "chat"; - readonly executionOwnerUserId: AccountId; - readonly type: "agent"; -} +const driverIdInputPattern = new RegExp(DRIVER_ID_INPUT_PATTERN, "u"); +const nonEmptyStringSchema = z.string().min(1); -export interface DriverExecutionSessionContext { - readonly homePath: string; - readonly origin: DriverOrigin; - readonly sandboxId: SandboxId; - readonly sandboxKind: string; - readonly sandboxSessionId: SandboxSessionId; - readonly sandboxSubjectId: DriverId; - readonly sandboxSubjectKind: string; - readonly sessionOrganizationPath: string; +export function ownObjectSchema(shape: Shape) { + const keys = Object.keys(shape); + return z.preprocess( + (value) => + value !== null && typeof value === "object" && !Array.isArray(value) + ? Object.fromEntries( + keys.flatMap((key) => + Object.hasOwn(value, key) ? [[key, (value as Record)[key]]] : [], + ), + ) + : value, + z.object(shape), + ); } -export interface DriverConfigRevision { - readonly agentId: AgentId; - readonly deploymentVersionId: AgentDeploymentVersionId | null; - readonly deploymentVersionNumber: number | null; - readonly environmentId: EnvironmentId; - readonly environmentRevisionId: EnvironmentRevisionId; - readonly runId: RunId | null; - readonly sessionId: SessionId; -} +export function ownArraySchema(element: Element) { + return z.preprocess((value) => { + if (!Array.isArray(value)) { + return value; + } -function readOrigin(value: unknown): DriverOrigin { - const record = readRecord(value, "execution.session.context.origin"); - const entrypoint = readNonEmptyString(record, "entrypoint", "execution.session.context.origin"); - const type = readNonEmptyString(record, "type", "execution.session.context.origin"); + const copy = Array.from({ length: value.length }); + for (let index = 0; index < value.length; index += 1) { + copy[index] = Object.hasOwn(value, index) ? value[index] : undefined; + } + return copy; + }, z.array(element)); +} - if (entrypoint !== "api" && entrypoint !== "chat") { - throw new TypeError("execution.session.context.origin.entrypoint must be api or chat."); - } +export function createDriverIdSchema() { + return z + .string() + .regex(driverIdInputPattern, "must be a valid ULID") + .transform((value) => normalizeDriverId(value) as Id); +} - if (type !== "agent") { - throw new TypeError("execution.session.context.origin.type must be agent."); - } +export const driverOriginSchema = ownObjectSchema({ + callerUserId: createDriverIdSchema(), + entrypoint: z.enum(["api", "chat"]), + executionOwnerUserId: createDriverIdSchema(), + type: z.literal("agent"), +}); - return { - callerUserId: parseId(record["callerUserId"], "Driver origin caller user ID") as AccountId, - entrypoint, - executionOwnerUserId: parseId( - record["executionOwnerUserId"], - "Driver origin execution owner user ID", - ) as AccountId, - type, - }; -} +export type DriverOrigin = z.infer; -export function readConfigRevision(value: unknown): DriverConfigRevision { - const record = readRecord(value, "execution.configRevision"); +export const driverExecutionSessionContextSchema = ownObjectSchema({ + homePath: nonEmptyStringSchema, + origin: driverOriginSchema, + sandboxId: createDriverIdSchema(), + sandboxKind: nonEmptyStringSchema, + sandboxSessionId: createDriverIdSchema(), + sandboxSubjectId: createDriverIdSchema(), + sandboxSubjectKind: nonEmptyStringSchema, + sessionOrganizationPath: nonEmptyStringSchema, +}); - return { - agentId: parseId(record["agentId"], "Driver config agent ID") as AgentId, - deploymentVersionId: parseNullableId( - record["deploymentVersionId"], - "Driver config deployment version ID", - ) as AgentDeploymentVersionId | null, - deploymentVersionNumber: - record["deploymentVersionNumber"] === null - ? null - : readNumber(record, "deploymentVersionNumber", "execution.configRevision"), - environmentId: parseId( - record["environmentId"], - "Driver config environment ID", - ) as EnvironmentId, - environmentRevisionId: parseId( - record["environmentRevisionId"], - "Driver config environment revision ID", - ) as EnvironmentRevisionId, - runId: parseNullableId(record["runId"], "Driver config run ID") as RunId | null, - sessionId: parseId(record["sessionId"], "Driver config session ID") as SessionId, - }; -} +export type DriverExecutionSessionContext = z.infer; -export function readExecutionSessionContext(value: unknown): DriverExecutionSessionContext { - const record = readRecord(value, "execution.session.context"); +export const driverConfigRevisionSchema = ownObjectSchema({ + agentId: createDriverIdSchema(), + deploymentVersionId: createDriverIdSchema().nullable(), + deploymentVersionNumber: z.number().finite().nullable(), + environmentId: createDriverIdSchema(), + environmentRevisionId: createDriverIdSchema(), + runId: createDriverIdSchema().nullable(), + sessionId: createDriverIdSchema(), +}); - return { - homePath: readNonEmptyString(record, "homePath", "execution.session.context"), - origin: readOrigin(record["origin"]), - sandboxId: parseId(record["sandboxId"], "Driver execution sandbox ID") as SandboxId, - sandboxKind: readNonEmptyString(record, "sandboxKind", "execution.session.context"), - sandboxSessionId: parseId( - record["sandboxSessionId"], - "Driver execution sandbox session ID", - ) as SandboxSessionId, - sandboxSubjectId: parseId(record["sandboxSubjectId"], "Driver execution sandbox subject ID"), - sandboxSubjectKind: readNonEmptyString( - record, - "sandboxSubjectKind", - "execution.session.context", - ), - sessionOrganizationPath: readNonEmptyString( - record, - "sessionOrganizationPath", - "execution.session.context", - ), - }; -} +export type DriverConfigRevision = z.infer; diff --git a/src/protocol/boot/index.ts b/src/protocol/boot/index.ts index 0f14f47..fbc1c6e 100644 --- a/src/protocol/boot/index.ts +++ b/src/protocol/boot/index.ts @@ -1,28 +1,26 @@ +import { z } from "zod"; +import { parseTraceparent } from "vestig"; + import type { DriverInstanceId } from "../id"; import type { JsonObject } from "../json"; import { readJsonObject } from "../json"; -import type { DriverNativeRuntimeRef, DriverRuntime, DriverRuntimeTransport } from "../runtime"; +import type { DriverNativeRuntimeRef } from "../runtime"; import { isSupportedDriverRuntime, isSupportedDriverRuntimeTransport, + parseDriverNativeRuntimeRef, SUPPORTED_DRIVER_NATIVE_RUNTIME_REF_KINDS, SUPPORTED_DRIVER_RUNTIMES, SUPPORTED_DRIVER_RUNTIME_TRANSPORTS, } from "../runtime"; import type { CredentialId, McpServerId, SandboxId, SkillId, SkillSnapshotId } from "./host-ids"; -import type { DriverConfigRevision, DriverExecutionSessionContext } from "./host-snapshot"; -import { readConfigRevision, readExecutionSessionContext } from "./host-snapshot"; import { - parseId, - readArray, - readInteger, - readNonEmptyString, - readNumber, - readOptionalNullableString, - readRecord, - readString, - readStringArray, -} from "./readers"; + createDriverIdSchema, + driverConfigRevisionSchema, + driverExecutionSessionContextSchema, + ownArraySchema, + ownObjectSchema, +} from "./host-snapshot"; export type { AccountId, @@ -44,11 +42,11 @@ export type { } from "./host-snapshot"; /** - * Version 2 requires the durable external-tool-effect RPCs. Refusing an older + * Version 3 requires the durable external-tool-effect RPCs. Refusing an older * Driver is safer than letting it invoke an MCP tool without the persistence * fence during a rolling deployment. */ -export const DRIVER_PROTOCOL_VERSION = 2; +export const DRIVER_PROTOCOL_VERSION = 3; export const DRIVER_CONTROL_PORT_MIN = 20_000; export const DRIVER_CONTROL_PORT_MAX = 59_999; export const DRIVER_BOOT_PAYLOAD_ENV_NAME = "MOSOO_DRIVER_BOOT_PAYLOAD"; @@ -68,104 +66,216 @@ export type { DriverRuntimeTransport, } from "../runtime"; -export interface DriverExecutionEnvironment { - readonly paths?: { - readonly executable: string[]; - readonly node: string[]; - readonly python: string[]; - }; - readonly variables: Record; -} +const nonEmptyStringSchema = z.string().min(1); +const nullableOptionalStringSchema = z.string().nullable().optional(); +const resolutionModeSchema = z.enum(["auto", "explicit", "tombstone"]); +const runtimeTransportByRuntime = { + "acp-fallback": "acp-fallback", + "claude-agent-sdk": "claude-agent-sdk", + "openai-runtime": "openai-app-server", +} as const; +const controlUrlSchema = z + .url() + .refine((value) => /^(?:https?|wss?):/iu.test(value), "must use http, https, ws, or wss"); +const traceparentPattern = /^00-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}$/u; + +export function parseDriverTraceparent(value: string): { traceId: string; spanId: string } { + const parsed = parseTraceparent(value); -export type DriverBuiltInToolName = - | "bash" - | "edit" - | "glob" - | "grep" - | "read" - | "web_fetch" - | "web_search" - | "write"; - -export interface DriverBuiltInToolConfig { - readonly enabled: boolean; - readonly name: DriverBuiltInToolName; -} - -function readBoolean(value: unknown, label: string): boolean { - if (typeof value !== "boolean") { - throw new TypeError(`${label} must be a boolean.`); + if ( + parsed === null || + !traceparentPattern.test(value) || + parsed.traceId === "0".repeat(32) || + parsed.spanId === "0".repeat(16) + ) { + throw new TypeError("traceparent must be a valid W3C traceparent header"); } - return value; + return parsed; } -export interface DriverSkillCatalogFrontmatterSummary { - readonly author: string | null; - readonly description: string | null; - readonly version: string | null; -} - -export interface DriverSkillCatalogEntry { - readonly frontmatter: DriverSkillCatalogFrontmatterSummary; - readonly mountPath: string; - readonly resolutionMode: "auto" | "explicit" | "tombstone"; - readonly skillId: SkillId; - readonly skillName: string; -} - -export interface DriverResolvedSkill { - readonly archiveFormat: "zip"; - readonly blobSha256: string; - readonly compression: "deflate"; - readonly downloadUrl: string; - readonly materializationStatus: "failed" | "pending" | "ready" | "skipped"; - readonly mountPath: string; - readonly resolutionMode: "auto" | "explicit" | "tombstone"; - readonly skillId: SkillId; - readonly skillName: string; - readonly snapshotId?: SkillSnapshotId | null | undefined; - readonly warningCode?: string | null | undefined; -} +const traceparentSchema = nonEmptyStringSchema.refine((value) => { + try { + parseDriverTraceparent(value); + return true; + } catch { + return false; + } +}, "must be a valid W3C traceparent header"); -export interface AuthorizedDriverBootMcpServer { - readonly authType: string; - readonly authorizationState: "active"; - readonly credentialId: CredentialId; - readonly credentialScope: string; - readonly credentialStatus: string; - readonly name: string; - readonly proxyGrantId: string; - readonly proxyUrl: string; - readonly serverId: McpServerId; - readonly subjectLabel?: string | null | undefined; +function omitUndefinedProperties>(value: Value): Value { + return Object.fromEntries( + Object.entries(value).filter(([, entry]) => entry !== undefined), + ) as Value; } -export interface UnavailableDriverBootMcpServer { - readonly authType: string; - readonly authorizationState: "authorization_required" | "disabled" | "expired" | "revoked"; - readonly credentialScope: string; - readonly credentialStatus: string; - readonly name: string; - readonly serverId: McpServerId; - readonly subjectLabel?: string | null | undefined; -} +const absolutePathSchema = nonEmptyStringSchema.refine( + (path) => path.startsWith("/") && !path.includes("\0") && !path.includes(":"), + "must be an absolute path without null bytes or path delimiters", +); + +const environmentEntrySchema = z.tuple([ + z + .string() + .refine( + (name) => name.length > 0 && !name.includes("=") && !name.includes("\0"), + "must be a valid environment entry", + ), + z.string().refine((value) => !value.includes("\0"), "must be a valid environment entry"), +]); + +const environmentVariablesSchema = z + .unknown() + .refine( + (value) => typeof value === "object" && value !== null && !Array.isArray(value), + "must be an object", + ) + .transform((value) => Object.entries(value as Record)) + .pipe(z.array(environmentEntrySchema)) + .transform((entries) => Object.fromEntries(entries)); + +const jsonObjectSchema = z.unknown().transform((value, context): JsonObject => { + try { + return readJsonObject(value, "execution.providerOptions"); + } catch (error) { + context.addIssue({ + code: "custom", + message: error instanceof Error ? error.message : "must be a JSON object", + }); + return z.NEVER; + } +}); + +const driverExecutionEnvironmentSchema = ownObjectSchema({ + paths: ownObjectSchema({ + executable: ownArraySchema(absolutePathSchema), + node: ownArraySchema(absolutePathSchema), + python: ownArraySchema(absolutePathSchema), + }).optional(), + variables: environmentVariablesSchema, +}).transform(omitUndefinedProperties); + +export type DriverExecutionEnvironment = z.infer; + +const driverBuiltInToolNameSchema = z.enum([ + "bash", + "edit", + "glob", + "grep", + "read", + "web_fetch", + "web_search", + "write", +]); + +export type DriverBuiltInToolName = z.infer; + +const driverBuiltInToolConfigSchema = ownObjectSchema({ + enabled: z.boolean(), + name: driverBuiltInToolNameSchema, +}); + +export type DriverBuiltInToolConfig = z.infer; + +const driverSkillCatalogFrontmatterSummarySchema = ownObjectSchema({ + author: nullableOptionalStringSchema.transform((value) => value ?? null), + description: nullableOptionalStringSchema.transform((value) => value ?? null), + version: nullableOptionalStringSchema.transform((value) => value ?? null), +}); + +export type DriverSkillCatalogFrontmatterSummary = z.infer< + typeof driverSkillCatalogFrontmatterSummarySchema +>; + +const driverSkillCatalogEntrySchema = ownObjectSchema({ + frontmatter: driverSkillCatalogFrontmatterSummarySchema, + mountPath: nonEmptyStringSchema, + resolutionMode: resolutionModeSchema, + skillId: createDriverIdSchema(), + skillName: nonEmptyStringSchema, +}); + +export type DriverSkillCatalogEntry = z.infer; + +const driverResolvedSkillSchema = ownObjectSchema({ + archiveFormat: z.literal("zip"), + blobSha256: nonEmptyStringSchema, + compression: z.literal("deflate"), + downloadUrl: nonEmptyStringSchema, + materializationStatus: z.enum(["failed", "pending", "ready", "skipped"]), + mountPath: nonEmptyStringSchema, + resolutionMode: resolutionModeSchema, + skillId: createDriverIdSchema(), + skillName: nonEmptyStringSchema, + snapshotId: createDriverIdSchema().nullable().optional(), + warningCode: nullableOptionalStringSchema, +}).transform(omitUndefinedProperties); + +export type DriverResolvedSkill = z.infer; + +const bootMcpServerCommonShape = { + authType: nonEmptyStringSchema, + credentialScope: nonEmptyStringSchema, + credentialStatus: nonEmptyStringSchema, + name: nonEmptyStringSchema, + serverId: createDriverIdSchema(), + subjectLabel: nullableOptionalStringSchema, +}; -export type DriverBootMcpServer = AuthorizedDriverBootMcpServer | UnavailableDriverBootMcpServer; +const authorizedDriverBootMcpServerSchema = ownObjectSchema({ + ...bootMcpServerCommonShape, + authorizationState: z.literal("active"), + credentialId: createDriverIdSchema(), + proxyGrantId: nonEmptyStringSchema, + proxyUrl: nonEmptyStringSchema, +}); + +export type AuthorizedDriverBootMcpServer = z.infer; + +const unavailableDriverBootMcpServerSchema = ownObjectSchema({ + ...bootMcpServerCommonShape, + authorizationState: z.enum(["authorization_required", "disabled", "expired", "revoked"]), +}); + +export type UnavailableDriverBootMcpServer = z.infer; + +const driverBootMcpServerSchema = z + .union([authorizedDriverBootMcpServerSchema, unavailableDriverBootMcpServerSchema]) + .transform(omitUndefinedProperties); + +export type DriverBootMcpServer = z.infer; + +const driverRecoveryMessageSchema = ownObjectSchema({ + content: nonEmptyStringSchema, + role: z.enum(["assistant", "user"]), +}); + +export type DriverRecoveryMessage = z.infer; + +const driverNativeRuntimeRefSchema = z + .unknown() + .transform((value, context): DriverNativeRuntimeRef => { + try { + return parseDriverNativeRuntimeRef(value); + } catch (error) { + context.addIssue({ + code: "custom", + message: error instanceof Error ? error.message : "must be a valid native runtime ref", + }); + return z.NEVER; + } + }); -export interface DriverRecoveryMessage { - readonly content: string; - readonly role: "assistant" | "user"; -} +const driverExecutionSessionSpecSchema = ownObjectSchema({ + additionalDirectories: ownArraySchema(nonEmptyStringSchema), + context: driverExecutionSessionContextSchema, + cwd: nonEmptyStringSchema, + mcpServers: ownArraySchema(driverBootMcpServerSchema), + nativeResumeRef: driverNativeRuntimeRefSchema.nullable(), + recoveryMessages: ownArraySchema(driverRecoveryMessageSchema).default([]), +}); -export interface DriverExecutionSessionSpec { - readonly additionalDirectories: string[]; - readonly context: DriverExecutionSessionContext; - readonly cwd: string; - readonly mcpServers: DriverBootMcpServer[]; - readonly nativeResumeRef: DriverNativeRuntimeRef | null; - readonly recoveryMessages: DriverRecoveryMessage[]; -} +export type DriverExecutionSessionSpec = z.infer; /** * How the driver mediates tool-permission requests. @@ -177,377 +287,83 @@ export interface DriverExecutionSessionSpec { * the control plane for an interactive allow/deny decision. Providers may * still auto-approve actions they classify as trusted or read-only. */ -export type DriverPermissionPolicy = "full_access" | "supervised"; - -export const DEFAULT_DRIVER_PERMISSION_POLICY = "full_access" satisfies DriverPermissionPolicy; - -export interface DriverExecutionSpec { - readonly builtInTools: DriverBuiltInToolConfig[]; - readonly configRevision: DriverConfigRevision; - readonly environment: DriverExecutionEnvironment; - readonly model: string; - readonly permissionPolicy: DriverPermissionPolicy; - readonly profilePrompt: string; - readonly provider: string; - readonly providerOptions: JsonObject; - readonly session: DriverExecutionSessionSpec; - readonly skillCatalog: DriverSkillCatalogEntry[]; - readonly skills: DriverResolvedSkill[]; -} - -export interface DriverBootPayload { - readonly bootToken: string; - readonly controlUrl: string; - readonly driverControlPort: number; - readonly driverGeneration: number; - readonly driverInstanceId: DriverInstanceId; - readonly execution: DriverExecutionSpec; - readonly heartbeatIntervalMs: number; - readonly protocolVersion: typeof DRIVER_PROTOCOL_VERSION; - readonly runtime: DriverRuntime; - readonly runtimeTransport: DriverRuntimeTransport; - readonly sandboxId: SandboxId; - readonly traceparent: string; -} - -function readVariables(value: unknown): Record { - const record = readRecord(value, "execution.environment.variables"); - const variables: Record = {}; - - for (const [key, entry] of Object.entries(record)) { - if (typeof entry !== "string") { - throw new TypeError(`execution.environment.variables.${key} must be a string.`); - } - - variables[key] = entry; - } - - return variables; -} +const driverPermissionPolicySchema = z + .enum(["full_access", "supervised"]) + .nullish() + .transform((value) => value ?? "full_access"); -function readAbsolutePathArray(record: Record, field: string): string[] { - const label = "execution.environment.paths"; - return readStringArray(record, field, label).map((path, index) => { - if (!path.startsWith("/") || path.includes("\0")) { - throw new TypeError( - `${label}.${field}[${index}] must be an absolute path without null bytes.`, - ); - } - - return path; - }); -} +export type DriverPermissionPolicy = z.infer; -function readEnvironmentPaths(value: unknown): NonNullable { - const paths = readRecord(value, "execution.environment.paths"); - return { - executable: readAbsolutePathArray(paths, "executable"), - node: readAbsolutePathArray(paths, "node"), - python: readAbsolutePathArray(paths, "python"), - }; -} - -function readNativeRuntimeRef(value: unknown): DriverNativeRuntimeRef | null { - if (value === null) { - return null; - } - - const record = readRecord(value, "execution.session.nativeResumeRef"); - const kind = readNonEmptyString(record, "kind", "execution.session.nativeResumeRef"); - const runtimeId = readNonEmptyString(record, "runtimeId", "execution.session.nativeResumeRef"); - - if (kind !== "openai_thread_id" && kind !== "claude_session_id" && kind !== "acp_session_id") { - throw new TypeError("execution.session.nativeResumeRef.kind is unsupported."); - } - - if (!isSupportedDriverRuntime(runtimeId)) { - throw new TypeError("execution.session.nativeResumeRef.runtimeId is unsupported."); - } - - return { - kind, - runtimeId, - value: readNonEmptyString(record, "value", "execution.session.nativeResumeRef"), - }; -} - -function readSkillFrontmatter(value: unknown, label: string): DriverSkillCatalogFrontmatterSummary { - const record = readRecord(value, `${label}.frontmatter`); - - return { - author: readOptionalNullableString(record, "author", `${label}.frontmatter`) ?? null, - description: readOptionalNullableString(record, "description", `${label}.frontmatter`) ?? null, - version: readOptionalNullableString(record, "version", `${label}.frontmatter`) ?? null, - }; -} - -function readResolutionMode(value: unknown, label: string): DriverResolvedSkill["resolutionMode"] { - if (value === "auto" || value === "explicit" || value === "tombstone") { - return value; - } - - throw new TypeError(`${label}.resolutionMode is unsupported.`); -} - -function readSkillCatalogEntry(value: unknown, index: number): DriverSkillCatalogEntry { - const label = `execution.skillCatalog[${index}]`; - const record = readRecord(value, label); - - return { - frontmatter: readSkillFrontmatter(record["frontmatter"], label), - mountPath: readNonEmptyString(record, "mountPath", label), - resolutionMode: readResolutionMode(record["resolutionMode"], label), - skillId: parseId(record["skillId"], `${label}.skillId`) as SkillId, - skillName: readNonEmptyString(record, "skillName", label), - }; -} - -function readResolvedSkill(value: unknown, index: number): DriverResolvedSkill { - const label = `execution.skills[${index}]`; - const record = readRecord(value, label); - const archiveFormat = readNonEmptyString(record, "archiveFormat", label); - const compression = readNonEmptyString(record, "compression", label); - const materializationStatus = readNonEmptyString(record, "materializationStatus", label); - const snapshotId = readOptionalNullableString(record, "snapshotId", label); - const warningCode = readOptionalNullableString(record, "warningCode", label); - - if (archiveFormat !== "zip") { - throw new TypeError(`${label}.archiveFormat must be zip.`); - } - - if (compression !== "deflate") { - throw new TypeError(`${label}.compression must be deflate.`); - } - - if ( - materializationStatus !== "failed" && - materializationStatus !== "pending" && - materializationStatus !== "ready" && - materializationStatus !== "skipped" - ) { - throw new TypeError(`${label}.materializationStatus is unsupported.`); - } - - return { - archiveFormat, - blobSha256: readNonEmptyString(record, "blobSha256", label), - compression, - downloadUrl: readNonEmptyString(record, "downloadUrl", label), - materializationStatus, - mountPath: readNonEmptyString(record, "mountPath", label), - resolutionMode: readResolutionMode(record["resolutionMode"], label), - skillId: parseId(record["skillId"], `${label}.skillId`) as SkillId, - skillName: readNonEmptyString(record, "skillName", label), - ...(snapshotId === undefined - ? {} - : { - snapshotId: - snapshotId === null - ? null - : (parseId(snapshotId, `${label}.snapshotId`) as SkillSnapshotId), - }), - ...(warningCode === undefined ? {} : { warningCode }), - }; -} - -function readBuiltInToolName(value: unknown, label: string): DriverBuiltInToolName { - if ( - value === "bash" || - value === "edit" || - value === "glob" || - value === "grep" || - value === "read" || - value === "web_fetch" || - value === "web_search" || - value === "write" - ) { - return value; - } - - throw new TypeError(`${label} is unsupported.`); -} - -function readBuiltInTool(value: unknown, index: number): DriverBuiltInToolConfig { - const label = `execution.builtInTools[${index}]`; - const record = readRecord(value, label); - - return { - enabled: readBoolean(record["enabled"], `${label}.enabled`), - name: readBuiltInToolName(record["name"], `${label}.name`), - }; -} - -function readBootMcpServer(value: unknown, index: number): DriverBootMcpServer { - const label = `execution.session.mcpServers[${index}]`; - const record = readRecord(value, label); - const authorizationState = readNonEmptyString(record, "authorizationState", label); - const subjectLabel = readOptionalNullableString(record, "subjectLabel", label); - const common = { - authType: readNonEmptyString(record, "authType", label), - credentialScope: readNonEmptyString(record, "credentialScope", label), - credentialStatus: readNonEmptyString(record, "credentialStatus", label), - name: readNonEmptyString(record, "name", label), - serverId: parseId(record["serverId"], `${label}.serverId`) as McpServerId, - ...(subjectLabel === undefined ? {} : { subjectLabel }), - }; - - if (authorizationState === "active") { - return { - ...common, - authorizationState, - credentialId: parseId(record["credentialId"], `${label}.credentialId`) as CredentialId, - proxyGrantId: readNonEmptyString(record, "proxyGrantId", label), - proxyUrl: readNonEmptyString(record, "proxyUrl", label), - }; - } +export const DEFAULT_DRIVER_PERMISSION_POLICY = "full_access" satisfies DriverPermissionPolicy; - if ( - authorizationState !== "authorization_required" && - authorizationState !== "disabled" && - authorizationState !== "expired" && - authorizationState !== "revoked" - ) { - throw new TypeError(`${label}.authorizationState is unsupported.`); +const driverExecutionSpecSchema = ownObjectSchema({ + builtInTools: ownArraySchema(driverBuiltInToolConfigSchema), + configRevision: driverConfigRevisionSchema, + environment: driverExecutionEnvironmentSchema, + model: nonEmptyStringSchema, + permissionPolicy: driverPermissionPolicySchema, + profilePrompt: z.string(), + provider: nonEmptyStringSchema, + providerOptions: jsonObjectSchema.default({}), + session: driverExecutionSessionSpecSchema, + skillCatalog: ownArraySchema(driverSkillCatalogEntrySchema), + skills: ownArraySchema(driverResolvedSkillSchema), +}); + +export type DriverExecutionSpec = z.infer; + +const driverBootPayloadSchema = ownObjectSchema({ + bootToken: nonEmptyStringSchema, + controlUrl: controlUrlSchema, + driverControlPort: z.number().int().min(DRIVER_CONTROL_PORT_MIN).max(DRIVER_CONTROL_PORT_MAX), + driverGeneration: z.number().int().nonnegative(), + driverInstanceId: createDriverIdSchema(), + execution: driverExecutionSpecSchema, + heartbeatIntervalMs: z.number().finite().min(250), + protocolVersion: z.literal(DRIVER_PROTOCOL_VERSION, { + error: `protocolVersion must be ${DRIVER_PROTOCOL_VERSION}`, + }), + runtime: z.enum(SUPPORTED_DRIVER_RUNTIMES), + runtimeTransport: z.enum(SUPPORTED_DRIVER_RUNTIME_TRANSPORTS), + sandboxId: createDriverIdSchema(), + traceparent: traceparentSchema, +}).superRefine((payload, context) => { + if (payload.sandboxId !== payload.execution.session.context.sandboxId) { + context.addIssue({ + code: "custom", + message: "Driver boot payload sandbox IDs must match", + path: ["sandboxId"], + }); } - return { - ...common, - authorizationState, - }; -} - -function readExecutionSession(value: unknown): DriverExecutionSessionSpec { - const record = readRecord(value, "execution.session"); - const recoveryMessages = - record["recoveryMessages"] === undefined - ? [] - : readArray(record["recoveryMessages"], "execution.session.recoveryMessages").map( - (entry, index): DriverRecoveryMessage => { - const label = `execution.session.recoveryMessages[${index}]`; - const message = readRecord(entry, label); - const role = readNonEmptyString(message, "role", label); - - if (role !== "assistant" && role !== "user") { - throw new TypeError(`${label}.role is unsupported.`); - } - - return { - content: readNonEmptyString(message, "content", label), - role, - }; - }, - ); - - return { - additionalDirectories: readStringArray(record, "additionalDirectories", "execution.session"), - context: readExecutionSessionContext(record["context"]), - cwd: readNonEmptyString(record, "cwd", "execution.session"), - mcpServers: readArray(record["mcpServers"], "execution.session.mcpServers").map( - readBootMcpServer, - ), - nativeResumeRef: readNativeRuntimeRef(record["nativeResumeRef"]), - recoveryMessages, - }; -} - -function readPermissionPolicy(value: unknown): DriverPermissionPolicy { - if (value === undefined || value === null) { - return DEFAULT_DRIVER_PERMISSION_POLICY; + if (payload.runtimeTransport !== runtimeTransportByRuntime[payload.runtime]) { + context.addIssue({ + code: "custom", + message: `runtime ${payload.runtime} does not match transport ${payload.runtimeTransport}`, + path: ["runtimeTransport"], + }); } - if (value === "full_access" || value === "supervised") { - return value; + const nativeResumeRef = payload.execution.session.nativeResumeRef; + if (nativeResumeRef !== null && nativeResumeRef.runtimeId !== payload.runtime) { + context.addIssue({ + code: "custom", + message: `native resume runtime ${nativeResumeRef.runtimeId} does not match runtime ${payload.runtime}`, + path: ["execution", "session", "nativeResumeRef", "runtimeId"], + }); } +}); - throw new TypeError("execution.permissionPolicy is unsupported."); -} - -function readExecution(value: unknown): DriverExecutionSpec { - const record = readRecord(value, "execution"); - const environment = readRecord(record["environment"], "execution.environment"); - - return { - builtInTools: readArray(record["builtInTools"], "execution.builtInTools").map(readBuiltInTool), - configRevision: readConfigRevision(record["configRevision"]), - environment: { - ...(environment["paths"] === undefined - ? {} - : { paths: readEnvironmentPaths(environment["paths"]) }), - variables: readVariables(environment["variables"]), - }, - model: readNonEmptyString(record, "model", "execution"), - permissionPolicy: readPermissionPolicy(record["permissionPolicy"]), - profilePrompt: readString(record, "profilePrompt", "execution"), - provider: readNonEmptyString(record, "provider", "execution"), - providerOptions: - record["providerOptions"] === undefined - ? {} - : readJsonObject(record["providerOptions"], "execution.providerOptions"), - session: readExecutionSession(record["session"]), - skillCatalog: readArray(record["skillCatalog"], "execution.skillCatalog").map( - readSkillCatalogEntry, - ), - skills: readArray(record["skills"], "execution.skills").map(readResolvedSkill), - }; -} +export type DriverBootPayload = z.infer; export function parseDriverBootPayload(value: unknown): DriverBootPayload { - const record = readRecord(value, "Driver boot payload"); - const driverControlPort = readInteger(record, "driverControlPort", "Driver boot payload"); - const driverGeneration = readInteger(record, "driverGeneration", "Driver boot payload"); - const heartbeatIntervalMs = readNumber(record, "heartbeatIntervalMs", "Driver boot payload"); - const protocolVersion = readInteger(record, "protocolVersion", "Driver boot payload"); - const runtime = readNonEmptyString(record, "runtime", "Driver boot payload"); - const runtimeTransport = readNonEmptyString(record, "runtimeTransport", "Driver boot payload"); - - if (driverControlPort < DRIVER_CONTROL_PORT_MIN || driverControlPort > DRIVER_CONTROL_PORT_MAX) { - throw new TypeError( - `Driver boot payload.driverControlPort must be between ${DRIVER_CONTROL_PORT_MIN} and ${DRIVER_CONTROL_PORT_MAX}.`, - ); - } + const result = driverBootPayloadSchema.safeParse(value); - if (driverGeneration < 0) { - throw new TypeError("Driver boot payload.driverGeneration must be non-negative."); - } - - if (heartbeatIntervalMs < 250) { - throw new TypeError("Driver boot payload.heartbeatIntervalMs must be at least 250."); - } - - if (protocolVersion !== DRIVER_PROTOCOL_VERSION) { - throw new TypeError(`Driver boot payload.protocolVersion must be ${DRIVER_PROTOCOL_VERSION}.`); - } - - if (!isSupportedDriverRuntime(runtime)) { - throw new TypeError(`Unsupported runtime: ${runtime}.`); - } - - if (!isSupportedDriverRuntimeTransport(runtimeTransport)) { - throw new TypeError(`Unsupported runtime transport: ${runtimeTransport}.`); - } - - const controlUrl = readNonEmptyString(record, "controlUrl", "Driver boot payload"); - - try { - void new URL(controlUrl); - } catch { - throw new TypeError("Driver boot payload.controlUrl must be an absolute URL."); + if (!result.success) { + throw new TypeError(z.prettifyError(result.error)); } - return { - bootToken: readNonEmptyString(record, "bootToken", "Driver boot payload"), - controlUrl, - driverControlPort, - driverGeneration, - driverInstanceId: parseId(record["driverInstanceId"], "Driver instance ID") as DriverInstanceId, - execution: readExecution(record["execution"]), - heartbeatIntervalMs, - protocolVersion, - runtime, - runtimeTransport, - sandboxId: parseId(record["sandboxId"], "Driver sandbox ID") as SandboxId, - traceparent: readNonEmptyString(record, "traceparent", "Driver boot payload"), - }; + return result.data; } export function parseDriverBootPayloadJson(raw: string): DriverBootPayload { diff --git a/src/protocol/boot/readers.ts b/src/protocol/boot/readers.ts deleted file mode 100644 index 3df3d8a..0000000 --- a/src/protocol/boot/readers.ts +++ /dev/null @@ -1,120 +0,0 @@ -import type { DriverId } from "../id"; -import { parseDriverId } from "../id"; - -export function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -export function readRecord(value: unknown, label: string): Record { - if (!isRecord(value)) { - throw new TypeError(`${label} must be an object.`); - } - - return value; -} - -export function readString(record: Record, field: string, label: string): string { - const value = record[field]; - - if (typeof value !== "string") { - throw new TypeError(`${label}.${field} must be a string.`); - } - - return value; -} - -export function readNonEmptyString( - record: Record, - field: string, - label: string, -): string { - const value = readString(record, field, label); - - if (value.length === 0) { - throw new TypeError(`${label}.${field} must be non-empty.`); - } - - return value; -} - -export function readOptionalNullableString( - record: Record, - field: string, - label: string, -): string | null | undefined { - const value = record[field]; - - if (value === undefined || value === null) { - return value; - } - - if (typeof value !== "string") { - throw new TypeError(`${label}.${field} must be a string, null, or undefined.`); - } - - return value; -} - -export function readNumber(record: Record, field: string, label: string): number { - const value = record[field]; - - if (typeof value !== "number" || !Number.isFinite(value)) { - throw new TypeError(`${label}.${field} must be a finite number.`); - } - - return value; -} - -export function readBoolean( - record: Record, - field: string, - label: string, -): boolean { - const value = record[field]; - - if (typeof value !== "boolean") { - throw new TypeError(`${label}.${field} must be a boolean.`); - } - - return value; -} - -export function readInteger(record: Record, field: string, label: string): number { - const value = readNumber(record, field, label); - - if (!Number.isInteger(value)) { - throw new TypeError(`${label}.${field} must be an integer.`); - } - - return value; -} - -export function readArray(value: unknown, label: string): unknown[] { - if (!Array.isArray(value)) { - throw new TypeError(`${label} must be an array.`); - } - - return value; -} - -export function readStringArray( - record: Record, - field: string, - label: string, -): string[] { - return readArray(record[field], `${label}.${field}`).map((entry, index) => { - if (typeof entry !== "string" || entry.length === 0) { - throw new TypeError(`${label}.${field}[${index}] must be a non-empty string.`); - } - - return entry; - }); -} - -export function parseId(value: unknown, label: string): DriverId { - return parseDriverId(value, label); -} - -export function parseNullableId(value: unknown, label: string): DriverId | null { - return value === null ? null : parseId(value, label); -} diff --git a/src/protocol/boot/testing.ts b/src/protocol/boot/testing.ts deleted file mode 100644 index cd8c7e2..0000000 --- a/src/protocol/boot/testing.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { DriverId, DriverInstanceId, EventId, MessageId, RunId, SessionId } from "../id"; -import { normalizeDriverId } from "../id"; -import type { - AccountId, - AgentDeploymentVersionId, - AgentId, - EnvironmentId, - EnvironmentRevisionId, - SandboxId, - SkillId, -} from "./host-ids"; - -function fixture(value: string): DriverId { - return normalizeDriverId(value, "Driver ID fixture"); -} - -export const DRIVER_ID_FIXTURES = { - account: fixture("01J00000000000000000000001") as AccountId, - agent: fixture("01J00000000000000000000002") as AgentId, - agentDeploymentVersion: fixture("01J00000000000000000000006") as AgentDeploymentVersionId, - driverInstance: fixture("01J00000000000000000000008") as DriverInstanceId, - environment: fixture("01J0000000000000000000000A") as EnvironmentId, - environmentRevision: fixture("01J0000000000000000000000B") as EnvironmentRevisionId, - event: fixture("01J0000000000000000000000G") as EventId, - message: fixture("01J0000000000000000000000M") as MessageId, - run: fixture("01J0000000000000000000000N") as RunId, - sandbox: fixture("01J0000000000000000000000J") as SandboxId, - session: fixture("01J0000000000000000000000K") as SessionId, - skill: fixture("01J0000000000000000000000P") as SkillId, -} as const; diff --git a/src/protocol/events/index.ts b/src/protocol/events/index.ts index e96405f..f1a17d1 100644 --- a/src/protocol/events/index.ts +++ b/src/protocol/events/index.ts @@ -1,9 +1,22 @@ +import { createHash } from "node:crypto"; + import { timestampSchema } from "../../contract/common"; import { parseRuntimeEventEnvelope } from "./runtime-events"; import type { RuntimeEventEnvelope, RuntimeEventInputDraft } from "./runtime-events"; +import { requireExactKeys } from "./runtime-event-validation"; +import type { RunId } from "../id"; + +export { + RUNTIME_EVENT_KINDS, + RUNTIME_EVENT_SCHEMA_VERSION, + toRuntimeEventInput, +} from "./runtime-events"; export type DriverEvent = RuntimeEventEnvelope; -export type DriverEventInput = RuntimeEventEnvelope | RuntimeEventInputDraft; +type DriverEventInputDraft = Omit & { + readonly runId?: RunId | null | undefined; +}; +export type DriverEventInput = RuntimeEventEnvelope | DriverEventInputDraft; export interface DriverEventEnvelope { readonly event: DriverEvent; @@ -11,6 +24,38 @@ export interface DriverEventEnvelope { readonly occurredAt?: string | null | undefined; } +export interface McpExecuteFailedEventIdentityInput { + readonly commandId: string; + readonly rawInput: string; + readonly rawOutput: string; + readonly title: string; + readonly toolCallId: string; +} + +export function createMcpExecuteFailedEventIdentity({ + commandId, + rawInput, + rawOutput, + title, + toolCallId, +}: McpExecuteFailedEventIdentityInput) { + const payload = { + kind: "mcp", + rawInput, + rawOutput, + status: "failed", + title, + toolCallId, + } as const; + + return { + payload, + sourceEventId: `mcp.execute.failed:${createHash("sha256") + .update(JSON.stringify([commandId, payload])) + .digest("hex")}`, + } as const; +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -19,6 +64,7 @@ export function parseDriverEventEnvelope(input: unknown): DriverEventEnvelope { if (!isRecord(input)) { throw new TypeError("Driver event envelope must be an object."); } + requireExactKeys(input, new Set(["event", "eventId", "occurredAt"]), "Driver event envelope"); const eventId = input["eventId"]; const occurredAt = input["occurredAt"]; diff --git a/src/protocol/events/runtime-event-types.ts b/src/protocol/events/runtime-event-types.ts index b2813d7..3a0fb85 100644 --- a/src/protocol/events/runtime-event-types.ts +++ b/src/protocol/events/runtime-event-types.ts @@ -1,11 +1,12 @@ import type { DriverInstanceId, EventId, RunId, SessionId } from "../id"; -export const RUNTIME_EVENT_SCHEMA_VERSION = "2026-05-26"; +export const RUNTIME_EVENT_SCHEMA_VERSION = "2026-08-29"; export const RUNTIME_EVENT_KINDS = [ "account.limits.updated", "account.updated", "agent.task.updated", + "agent.tasks.replaced", "auth.methods.updated", "auth.session.updated", "catalog.updated", @@ -23,7 +24,6 @@ export const RUNTIME_EVENT_KINDS = [ "file.indexed", "hook.completed", "hook.started", - "image.updated", "item.completed", "item.started", "item.updated", @@ -31,8 +31,10 @@ export const RUNTIME_EVENT_KINDS = [ "mcp.server.updated", "mcp.tool.updated", "message.added", + "message.cancelled", "message.completed", "message.delta", + "message.failed", "message.started", "model.routing.updated", "model.verification.updated", @@ -94,6 +96,7 @@ export const RUNTIME_EVENT_KINDS = [ "terminal.output.delta", "terminal.released", "thought.completed", + "thought.cancelled", "thought.delta", "thought.started", "tool.call.updated", @@ -121,6 +124,23 @@ export type RuntimeTimingStage = export type RuntimeEventRecord = Record; +type RuntimeToolCallInput = + | { readonly rawInput?: undefined; readonly rawInputDelta?: undefined } + | { readonly rawInput: string; readonly rawInputDelta?: undefined } + | { readonly rawInput?: undefined; readonly rawInputDelta: string }; + +type RuntimeToolCallOutput = + | { readonly rawOutput?: undefined; readonly rawOutputDelta?: undefined } + | { readonly rawOutput: string; readonly rawOutputDelta?: undefined } + | { readonly rawOutput?: undefined; readonly rawOutputDelta: string }; + +export type RuntimeToolCallUpdatedPayload = RuntimeEventRecord & + RuntimeToolCallInput & + RuntimeToolCallOutput & { + readonly status: "cancelled" | "completed" | "failed" | "running"; + readonly toolCallId: string; + }; + export interface RuntimeEventNativeRef { readonly eventName?: string | undefined; readonly itemId?: string | undefined; diff --git a/src/protocol/events/runtime-event-validation.ts b/src/protocol/events/runtime-event-validation.ts index f0885c9..c2bb3ba 100644 --- a/src/protocol/events/runtime-event-validation.ts +++ b/src/protocol/events/runtime-event-validation.ts @@ -16,11 +16,38 @@ const payloadIdentityFields = new Set([ "traceId", ]); +export function requireExactKeys( + value: RuntimeEventRecord, + allowedKeys: ReadonlySet, + label: string, +): void { + const unexpected = Object.keys(value).find((key) => !allowedKeys.has(key)); + + if (unexpected !== undefined) { + throw new Error(`${label} ${unexpected} is not allowed.`); + } +} + export function isRuntimeEventRecord(value: unknown): value is RuntimeEventRecord { return typeof value === "object" && value !== null && !Array.isArray(value); } export function parseNativeRef(value: RuntimeEventRecord): RuntimeEventNativeRef { + requireExactKeys( + value, + new Set([ + "eventName", + "itemId", + "protocolVersion", + "provider", + "requestId", + "sequence", + "threadId", + "turnId", + ]), + "Runtime event native reference", + ); + return { ...(readOptionalString(value, "eventName", "Runtime event native reference") === undefined ? {} @@ -90,15 +117,9 @@ export function hasRunStartedAt(record: RuntimeEventRecord): boolean { } export function omitPayloadIdentity(payload: RuntimeEventRecord): RuntimeEventRecord { - const result: RuntimeEventRecord = {}; - - for (const [key, value] of Object.entries(payload)) { - if (!payloadIdentityFields.has(key)) { - result[key] = value; - } - } - - return result; + return Object.fromEntries( + Object.entries(payload).filter(([key]) => !payloadIdentityFields.has(key)), + ); } export function requirePayloadRecord( @@ -172,6 +193,20 @@ export function requireOptionalString( requireString(value, field, label); } +export function requireOptionalBoolean( + value: RuntimeEventRecord, + field: string, + label: RuntimeEventKind | string, +): void { + if (!(field in value) || value[field] === undefined) { + return; + } + + if (typeof value[field] !== "boolean") { + throw new Error(`${label} ${field} must be a boolean.`); + } +} + export function requireOptionalContentString( value: RuntimeEventRecord, field: string, @@ -312,23 +347,26 @@ export function assertTimestamp(value: string, label: string): void { export function readPrimitiveRecord( value: unknown, + label: string, ): Record { - if (!isRuntimeEventRecord(value)) { + if (value === undefined) { return {}; } - const result: Record = {}; + if (!isRuntimeEventRecord(value)) { + throw new Error(`${label} must be an object.`); + } - for (const [key, entry] of Object.entries(value)) { + for (const [field, entry] of Object.entries(value)) { if ( - entry === null || - typeof entry === "string" || - typeof entry === "number" || - typeof entry === "boolean" + entry !== null && + typeof entry !== "string" && + typeof entry !== "number" && + typeof entry !== "boolean" ) { - result[key] = entry; + throw new Error(`${label}.${field} must be a primitive JSON value.`); } } - return result; + return value as Record; } diff --git a/src/protocol/events/runtime-events.ts b/src/protocol/events/runtime-events.ts index c3a9877..db18cfd 100644 --- a/src/protocol/events/runtime-events.ts +++ b/src/protocol/events/runtime-events.ts @@ -1,3 +1,5 @@ +import { jsonValueSchema } from "../../contract/common"; +import { z } from "zod"; import type { DriverInstanceId, EventId, SessionId, RunId } from "../id"; import { RUNTIME_EVENT_KINDS, @@ -19,6 +21,7 @@ import { type RuntimeTimingPhase, type RuntimeTimingSource, type RuntimeTimingStage, + type RuntimeToolCallUpdatedPayload, } from "./runtime-event-types"; import { assertTimestamp, @@ -31,9 +34,11 @@ import { readOptionalDriverId, readOptionalString, readPrimitiveRecord, + requireExactKeys, requireEnumValue, requireNonNegativeInt, requireNullableTimestamp, + requireOptionalBoolean, requireOptionalContentString, requireOptionalEnumValue, requireOptionalNullableString, @@ -44,10 +49,54 @@ import { requireString, requireTimestamp, } from "./runtime-event-validation"; - export * from "./runtime-event-types"; const runtimeEventKindSet = new Set(RUNTIME_EVENT_KINDS); +const MAX_AGENT_TASKS_REPLACED_PAYLOAD_BYTES = 1_020 * 1_024; +const utf8Encoder = new TextEncoder(); +const agentTasksReplacedPayloadSchema = z + .object({ + tasks: z + .array( + z + .object({ + taskId: z + .string() + .min(1) + .refine((value) => utf8Encoder.encode(value).byteLength <= 256), + taskType: z.string().min(1).max(4_096).optional(), + title: z.string().min(1).max(4_096).optional(), + }) + .strict(), + ) + .max(256) + .superRefine((tasks, context) => { + const taskIds = new Set(); + for (const [index, task] of tasks.entries()) { + if (taskIds.has(task.taskId)) { + context.addIssue({ + code: "custom", + message: "Task IDs must be unique.", + path: [index, "taskId"], + }); + } + taskIds.add(task.taskId); + } + }), + }) + .strict() + .superRefine((payload, context) => { + if ( + utf8Encoder.encode(JSON.stringify(payload)).byteLength > + MAX_AGENT_TASKS_REPLACED_PAYLOAD_BYTES + ) { + context.addIssue({ + code: "custom", + message: "Task snapshot exceeds the durable event capacity.", + path: ["tasks"], + }); + } + }); const runtimeEventActors = new Set(["agent", "api", "driver", "system", "tool", "user"]); const runtimeEventOrigins = new Set([ "api", @@ -102,11 +151,29 @@ const runStatuses = new Set([ "running", "waiting_input", ]); -const toolStatuses = new Set(["completed", "failed", "running"]); +const toolStatuses = new Set(["cancelled", "completed", "failed", "running"]); const fileChangeKinds = new Set(["delete", "upsert"]); -export { isRuntimeEventRecord } from "./runtime-event-validation"; - -export function createRuntimeEvent( +const runtimeEventEnvelopeKeys = new Set([ + "actor", + "correlationId", + "delivery", + "driverInstanceId", + "id", + "kind", + "native", + "occurredAt", + "origin", + "payload", + "receivedAt", + "runId", + "runtimeId", + "schemaVersion", + "sessionId", + "sourceEventId", + "traceId", + "visibility", +]); +function createRuntimeEvent( draft: RuntimeEventDraft, ): RuntimeEventEnvelope { return { @@ -135,6 +202,7 @@ export function parseRuntimeEventEnvelope(value: unknown): RuntimeEventEnvelope if (!isRuntimeEventRecord(value)) { throw new Error("Runtime event must be an object."); } + requireExactKeys(value, runtimeEventEnvelopeKeys, "Runtime event"); if (value["schemaVersion"] !== RUNTIME_EVENT_SCHEMA_VERSION) { throw new Error("Runtime event schema version is unsupported."); @@ -197,14 +265,25 @@ export function parseRuntimeEventEnvelope(value: unknown): RuntimeEventEnvelope const payload = admitRuntimeEventPayload( { ...(driverInstanceId === undefined ? {} : { driverInstanceId }), + delivery, kind, ...(runId === undefined ? {} : { runId }), sessionId, ...(traceId === undefined ? {} : { traceId }), + visibility, }, value["payload"], ); + let native; + + if ("native" in value && value["native"] !== undefined) { + if (!isRuntimeEventRecord(value["native"])) { + throw new Error("Runtime event native reference must be an object."); + } + native = parseNativeRef(value["native"]); + } + return { actor, ...(correlationId === undefined ? {} : { correlationId }), @@ -212,7 +291,7 @@ export function parseRuntimeEventEnvelope(value: unknown): RuntimeEventEnvelope ...(driverInstanceId === undefined ? {} : { driverInstanceId }), id, kind, - ...(isRuntimeEventRecord(value["native"]) ? { native: parseNativeRef(value["native"]) } : {}), + ...(native === undefined ? {} : { native }), occurredAt, origin, payload, @@ -327,17 +406,55 @@ function defaultVisibility(kind: RuntimeEventKind): RuntimeEventVisibility { function admitRuntimeEventPayload( context: { + readonly delivery: RuntimeEventDelivery; readonly driverInstanceId?: DriverInstanceId | undefined; readonly kind: RuntimeEventKind; readonly runId?: RunId | undefined; readonly sessionId: SessionId; readonly traceId?: string | undefined; + readonly visibility: RuntimeEventVisibility; }, payload: unknown, ): unknown { + let canonicalPayload: unknown; + + try { + canonicalPayload = structuredClone(payload); + } catch { + throw new Error(`Runtime event ${context.kind} payload must be JSON-serializable.`); + } + + if (!jsonValueSchema.safeParse(canonicalPayload).success) { + throw new Error(`Runtime event ${context.kind} payload must be JSON-serializable.`); + } + switch (context.kind) { + case "agent.task.updated": { + const record = requirePayloadRecord(context.kind, canonicalPayload); + requireString(record, "taskId", context.kind); + requireOptionalBoolean(record, "active", context.kind); + requireOptionalEnumValue(record, "status", toolStatuses, context.kind); + for (const field of ["activityKind", "agentId", "agentPath", "taskType", "title"]) { + requireOptionalString(record, field, context.kind); + } + return omitPayloadIdentity(record); + } + case "agent.tasks.replaced": { + if (context.delivery !== "lossless" || context.visibility !== "participant") { + throw new Error(`Runtime event ${context.kind} must be lossless and participant-visible.`); + } + if (context.driverInstanceId === undefined || context.runId === undefined) { + throw new Error(`Runtime event ${context.kind} requires a run ID and driver instance ID.`); + } + const record = requirePayloadRecord(context.kind, canonicalPayload); + const parsed = agentTasksReplacedPayloadSchema.safeParse(omitPayloadIdentity(record)); + if (!parsed.success) { + throw new Error(`Runtime event ${context.kind} payload is invalid.`); + } + return parsed.data; + } case "diagnostic.reported": { - const record = requirePayloadRecord(context.kind, payload); + const record = requirePayloadRecord(context.kind, canonicalPayload); requireOptionalString(record, "code", context.kind); requireOptionalString(record, "message", context.kind); requireOptionalString(record, "severity", context.kind); @@ -345,7 +462,7 @@ function admitRuntimeEventPayload( } case "file.change.updated": case "file.changed": { - const record = requirePayloadRecord(context.kind, payload); + const record = requirePayloadRecord(context.kind, canonicalPayload); const changes = Array.isArray(record["changes"]) ? record["changes"] : [record]; if (changes.length === 0) { @@ -362,34 +479,59 @@ function admitRuntimeEventPayload( } case "message.added": case "message.delta": { - const record = requirePayloadRecord(context.kind, payload); + const record = requirePayloadRecord(context.kind, canonicalPayload); if (!hasTextContent(record)) { throw new Error(`Runtime event ${context.kind} payload must include text content.`); } - requireOptionalString(record, "messageId", context.kind); + requireString(record, "messageId", context.kind); + requireOptionalEnumValue( + record, + "level", + new Set(["info", "notice", "suggestion", "warning"]), + context.kind, + ); + requireOptionalBoolean(record, "preventContinuation", context.kind); requireOptionalEnumValue(record, "role", new Set(["agent", "user"]), context.kind); + requireOptionalString(record, "subtype", context.kind); + requireOptionalString(record, "toolCallId", context.kind); + + requireOptionalEnumValue(record, "phase", new Set(["commentary", "final"]), context.kind); return omitPayloadIdentity(record); } + case "message.cancelled": case "message.completed": - case "message.started": + case "message.started": { + const record = requirePayloadRecord(context.kind, canonicalPayload); + requireString(record, "messageId", context.kind); + requireOptionalEnumValue(record, "role", new Set(["agent", "user"]), context.kind); + return omitPayloadIdentity(record); + } + case "thought.cancelled": case "thought.completed": case "thought.started": { - const record = requirePayloadRecord(context.kind, payload); - requireOptionalString(record, "messageId", context.kind); - requireOptionalString(record, "thoughtId", context.kind); + const record = requirePayloadRecord(context.kind, canonicalPayload); + requireString(record, "thoughtId", context.kind); requireOptionalEnumValue(record, "role", new Set(["agent", "user"]), context.kind); return omitPayloadIdentity(record); } + case "message.failed": { + const record = requirePayloadRecord(context.kind, canonicalPayload); + requireString(record, "messageId", context.kind); + requireOptionalEnumValue(record, "role", new Set(["agent", "user"]), context.kind); + const admitted = omitPayloadIdentity(record); + admitted["error"] = readRunError(context.kind, record["error"], "error"); + return admitted; + } case "thought.delta": { - const record = requirePayloadRecord(context.kind, payload); + const record = requirePayloadRecord(context.kind, canonicalPayload); if (!hasTextContent(record)) { throw new Error("Runtime event thought.delta payload must include text content."); } - requireOptionalString(record, "thoughtId", context.kind); + requireString(record, "thoughtId", context.kind); return omitPayloadIdentity(record); } case "permission.requested": { @@ -401,12 +543,27 @@ function admitRuntimeEventPayload( throw new Error("Runtime event permission.requested requires a run ID."); } - const record = requirePayloadRecord(context.kind, payload); + const record = requirePayloadRecord(context.kind, canonicalPayload); requireString(record, "requestId", context.kind); requireString(record, "title", context.kind); + requireOptionalString(record, "agentId", context.kind); + requireOptionalString(record, "blockedPath", context.kind); + requireOptionalString(record, "decisionReason", context.kind); + requireOptionalString(record, "description", context.kind); requireOptionalNullableString(record, "details", context.kind); requireOptionalNullableString(record, "targetItemId", context.kind); + if (record["matchedAskRule"] !== undefined) { + const matchedAskRule = requirePayloadRecord( + context.kind, + record["matchedAskRule"], + "matchedAskRule", + ); + requireString(matchedAskRule, "source", context.kind); + requireString(matchedAskRule, "toolName", context.kind); + requireOptionalString(matchedAskRule, "ruleContent", context.kind); + } + if ( "options" in record && record["options"] !== undefined && @@ -424,13 +581,21 @@ function admitRuntimeEventPayload( return omitPayloadIdentity(record); } case "permission.resolved": { - const record = requirePayloadRecord(context.kind, payload); + const record = requirePayloadRecord(context.kind, canonicalPayload); requireString(record, "requestId", context.kind); requireString(record, "outcome", context.kind); requireOptionalString(record, "optionId", context.kind); requireOptionalString(record, "optionKind", context.kind); return omitPayloadIdentity(record); } + case "session.info.updated": { + const record = requirePayloadRecord(context.kind, canonicalPayload); + requireOptionalNullableString(record, "title", context.kind); + if (record["updatedAt"] !== undefined) { + requireNullableTimestamp(record, "updatedAt", context.kind, "updatedAt"); + } + return omitPayloadIdentity(record); + } case "run.cancel.requested": case "run.cancelled": case "run.completed": @@ -440,14 +605,14 @@ function admitRuntimeEventPayload( case "run.started": case "run.steered": case "run.waiting": { - return readRunPayload(context, payload); + return readRunPayload(context, canonicalPayload); } case "runtime.config.updated": case "runtime.driver.updated": case "runtime.provisioning.updated": case "runtime.sandbox.updated": case "runtime.transport.updated": { - const record = requirePayloadRecord(context.kind, payload); + const record = requirePayloadRecord(context.kind, canonicalPayload); requireString(record, "status", context.kind); if (context.kind === "runtime.transport.updated") { @@ -459,23 +624,43 @@ function admitRuntimeEventPayload( return omitPayloadIdentity(record); } case "runtime.timing.recorded": { - return readTimingPayload(context, payload); + return readTimingPayload(context, canonicalPayload); } case "tool.call.updated": { - const record = requirePayloadRecord(context.kind, payload); + const record = requirePayloadRecord(context.kind, canonicalPayload); requireEnumValue(record, "status", toolStatuses, context.kind); requireString(record, "toolCallId", context.kind); requireOptionalContentString(record, "content", context.kind); + requireOptionalString(record, "agentId", context.kind); + requireOptionalString(record, "decisionReason", context.kind); + requireOptionalString(record, "decisionReasonType", context.kind); requireOptionalString(record, "kind", context.kind); requireOptionalString(record, "messageId", context.kind); + requireOptionalString(record, "name", context.kind); + requireOptionalString(record, "nonExecutionKind", context.kind); requireOptionalString(record, "parentMessageId", context.kind); requireOptionalString(record, "rawInput", context.kind); + requireOptionalString(record, "rawInputDelta", context.kind); requireOptionalString(record, "rawOutput", context.kind); + requireOptionalString(record, "rawOutputDelta", context.kind); requireOptionalNullableString(record, "title", context.kind); - return omitPayloadIdentity(record); + requireOptionalString(record, "userFeedback", context.kind); + if (record["rawInput"] !== undefined && record["rawInputDelta"] !== undefined) { + throw new Error( + "Runtime event tool.call.updated payload cannot contain both rawInput and rawInputDelta.", + ); + } + if (record["rawOutput"] !== undefined && record["rawOutputDelta"] !== undefined) { + throw new Error( + "Runtime event tool.call.updated payload cannot contain both rawOutput and rawOutputDelta.", + ); + } + return omitPayloadIdentity(record) as RuntimeToolCallUpdatedPayload; } default: { - return isRuntimeEventRecord(payload) ? omitPayloadIdentity(payload) : payload; + return isRuntimeEventRecord(canonicalPayload) + ? omitPayloadIdentity(canonicalPayload) + : canonicalPayload; } } } @@ -495,7 +680,13 @@ function readRunPayload( } requireOptionalEnumValue(record, "lifecycle", runLifecycleStatuses, context.kind); - requireOptionalEnumValue(record, "status", runStatuses, context.kind); + if ("status" in record && record["status"] !== undefined) { + const status = requireEnumValue(record, "status", runStatuses, context.kind); + + if (!isRunStatusAllowedForKind(context.kind, status)) { + throw new Error(`Runtime event ${context.kind} payload status is inconsistent.`); + } + } requireOptionalString(record, "inputSummary", context.kind); requireOptionalString(record, "reason", context.kind); requireOptionalString(record, "requestedBy", context.kind); @@ -506,6 +697,16 @@ function readRunPayload( requireOptionalTimestamp(record, "completedAt", context.kind); requireOptionalTimestamp(record, "startedAt", context.kind); + if (context.kind === "run.completed") { + if ("finalMessageId" in record) { + requireString(record, "finalMessageId", context.kind); + } + + if ("finalMessageText" in record) { + throw new Error("Runtime event run.completed payload finalMessageText is unsupported."); + } + } + const admitted = omitPayloadIdentity(record); if ("run" in record && record["run"] !== undefined) { @@ -524,6 +725,21 @@ function readRunPayload( throw new Error("Runtime event run.failed payload must include an error."); } + if (context.kind === "run.failed") { + const recoverable = record["recoverable"]; + const error = admitted["error"] as RuntimeEventRecord; + + if (typeof recoverable !== "boolean") { + throw new Error("Runtime event run.failed payload recoverable must be a boolean."); + } + + if (error["retryable"] !== recoverable) { + throw new Error( + "Runtime event run.failed payload recoverable must agree with error.retryable.", + ); + } + } + return admitted; } @@ -536,7 +752,11 @@ function readRunView( value: unknown, ): RuntimeEventRecord { const record = requirePayloadRecord(context.kind, value, "run"); - requireEnumValue(record, "status", runStatuses, context.kind); + const status = requireEnumValue(record, "status", runStatuses, context.kind); + + if (!isRunStatusAllowedForKind(context.kind, status)) { + throw new Error(`Runtime event ${context.kind} payload run.status is inconsistent.`); + } return { completedAt: requireNullableTimestamp(record, "completedAt", context.kind, "run.completedAt"), @@ -544,34 +764,48 @@ function readRunView( record["error"] === null ? null : readRunError(context.kind, record["error"], "run.error"), id: context.runId ?? null, startedAt: requireNullableTimestamp(record, "startedAt", context.kind, "run.startedAt"), - status: record["status"], + status, traceId: context.traceId ?? null, }; } +function isRunStatusAllowedForKind(kind: RuntimeEventKind, status: string): boolean { + switch (kind) { + case "run.cancel.requested": + case "run.dispatched": + case "run.started": + case "run.steered": + case "run.waiting": + return status === "booting" || status === "running" || status === "waiting_input"; + case "run.cancelled": + return status === "cancelled" || status === "expired"; + case "run.completed": + return status === "completed"; + case "run.failed": + return status === "failed"; + case "run.queued": + return status === "queued"; + default: + return false; + } +} + function readRunError(kind: RuntimeEventKind, value: unknown, label: string): RuntimeEventRecord { const record = requirePayloadRecord(kind, value, label); - const details = record["details"]; - const recoverable = record["recoverable"]; const retryable = record["retryable"]; - if (details !== undefined && !isRuntimeEventRecord(details)) { - throw new Error(`Runtime event ${kind} payload ${label}.details must be an object.`); - } - - if (recoverable !== undefined && typeof recoverable !== "boolean") { - throw new Error(`Runtime event ${kind} payload ${label}.recoverable must be a boolean.`); - } - - if (retryable !== undefined && typeof retryable !== "boolean") { + if (typeof retryable !== "boolean") { throw new Error(`Runtime event ${kind} payload ${label}.retryable must be a boolean.`); } return { code: requireString(record, "code", kind), - details: readPrimitiveRecord(details), + details: readPrimitiveRecord( + record["details"], + `Runtime event ${kind} payload ${label}.details`, + ), message: requireString(record, "message", kind), - retryable: retryable === true || recoverable === true, + retryable, }; } diff --git a/src/protocol/execution.ts b/src/protocol/execution.ts index 629a91e..dd57655 100644 --- a/src/protocol/execution.ts +++ b/src/protocol/execution.ts @@ -1,6 +1,7 @@ import type { DriverBootMcpServer, DriverExecutionEnvironment, + DriverExecutionSessionContext, DriverExecutionSpec, DriverNativeRuntimeRef, DriverPermissionPolicy, @@ -17,6 +18,7 @@ export interface DriverExecutionRunInput { export interface DriverExecutionSessionInput { readonly additionalDirectories: string[]; + readonly context: DriverExecutionSessionContext; readonly cwd: string; readonly homePath: string; readonly mcpServers: DriverBootMcpServer[]; @@ -55,6 +57,7 @@ export function createDriverExecutionInputFromBootExecution( }, session: { additionalDirectories: execution.session.additionalDirectories, + context: execution.session.context, cwd: execution.session.cwd, homePath: execution.session.context.homePath, mcpServers: execution.session.mcpServers, diff --git a/src/protocol/host-integration.ts b/src/protocol/host-integration.ts deleted file mode 100644 index 8115f34..0000000 --- a/src/protocol/host-integration.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { - DriverConfigRevision, - DriverExecutionSessionContext, - DriverExecutionSpec, -} from "./boot"; - -export interface DriverHostIntegrationSnapshot { - readonly configRevision: DriverConfigRevision; - readonly sessionContext: DriverExecutionSessionContext; -} - -export function createDriverHostIntegrationSnapshotFromBootExecution( - execution: DriverExecutionSpec, -): DriverHostIntegrationSnapshot { - return { - configRevision: execution.configRevision, - sessionContext: execution.session.context, - }; -} diff --git a/src/protocol/id/index.ts b/src/protocol/id/index.ts index c55bb40..7c0d516 100644 --- a/src/protocol/id/index.ts +++ b/src/protocol/id/index.ts @@ -12,14 +12,15 @@ export type SessionId = SemanticDriverId<"SessionId">; export type MessageId = SemanticDriverId<"MessageId">; export type RunId = SemanticDriverId<"RunId">; -export const DRIVER_ID_PATTERN = "^[0-9A-HJKMNP-TV-Z]{26}$"; -export const DRIVER_ID_INPUT_PATTERN = "^[0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{26}$"; +export const DRIVER_ID_PATTERN = "^[0-7][0-9A-HJKMNP-TV-Z]{25}$"; +export const DRIVER_ID_INPUT_PATTERN = "^[0-7][0-9A-HJKMNP-TV-Za-hjkmnp-tv-z]{25}$"; const canonicalDriverIdPattern = new RegExp(DRIVER_ID_PATTERN, "u"); const inputDriverIdPattern = new RegExp(DRIVER_ID_INPUT_PATTERN, "u"); const driverIdAlphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; const driverIdRandomLength = 16; const driverIdTimeLength = 10; +const driverIdLength = driverIdTimeLength + driverIdRandomLength; const maxDriverIdTimeMs = 2 ** 48 - 1; let lastDriverIdTimeMs = -1; @@ -58,6 +59,10 @@ export function parseDriverId(value: unknown, label?: string): DriverId { return normalizeDriverId(value, label); } +export function parseRunId(value: unknown): RunId { + return parseDriverId(value, "Run ID") as RunId; +} + function readRandomByte(): number { const crypto = (globalThis as typeof globalThis & { readonly crypto?: DriverIdCrypto }).crypto; @@ -140,6 +145,32 @@ function incrementCrockfordBase32(value: string): string { return chars.join(""); } +export function createDriverIdFromBytes(bytes: Uint8Array): DriverId { + if (bytes.byteLength !== 16) { + throw new RangeError("Driver ID source must contain exactly 16 bytes."); + } + + let value = 0n; + for (const byte of bytes) { + value = (value << 8n) | BigInt(byte); + } + + let encoded = ""; + for (let index = 0; index < driverIdLength; index += 1) { + encoded = driverIdAlphabet.charAt(Number(value & 31n)) + encoded; + value >>= 5n; + } + return brandDriverId(encoded); +} + +export function driverIdTimeMs(id: DriverId): number { + let timeMs = 0; + for (const character of id.slice(0, driverIdTimeLength)) { + timeMs = timeMs * driverIdAlphabet.length + driverIdAlphabet.indexOf(character); + } + return timeMs; +} + export function createDriverId(): DriverId { const requestedTimeMs = assertDriverIdTimeMs(Date.now()); diff --git a/src/protocol/json.ts b/src/protocol/json.ts index cb1d193..9c66829 100644 --- a/src/protocol/json.ts +++ b/src/protocol/json.ts @@ -5,10 +5,19 @@ export interface JsonObject { } export function isJsonObject(value: unknown): value is JsonObject { - return value !== null && typeof value === "object" && !Array.isArray(value); + if (value === null || typeof value !== "object" || Array.isArray(value)) { + return false; + } + + const prototype = Object.getPrototypeOf(value); + return prototype === Object.prototype || prototype === null; } -function assertJsonValue(value: unknown, label: string): asserts value is JsonValue { +function assertJsonValue( + value: unknown, + label: string, + ancestors = new WeakSet(), +): asserts value is JsonValue { if ( value === null || typeof value === "string" || @@ -19,16 +28,31 @@ function assertJsonValue(value: unknown, label: string): asserts value is JsonVa } if (Array.isArray(value)) { - value.forEach((entry, index) => { - assertJsonValue(entry, `${label}[${index}]`); - }); + if (ancestors.has(value)) { + throw new TypeError(`${label} must be JSON-serializable.`); + } + + ancestors.add(value); + for (let index = 0; index < value.length; index += 1) { + if (!Object.hasOwn(value, index)) { + throw new TypeError(`${label}[${index}] must be JSON-serializable.`); + } + assertJsonValue(value[index], `${label}[${index}]`, ancestors); + } + ancestors.delete(value); return; } if (isJsonObject(value)) { + if (ancestors.has(value)) { + throw new TypeError(`${label} must be JSON-serializable.`); + } + + ancestors.add(value); for (const [key, entry] of Object.entries(value)) { - assertJsonValue(entry, `${label}.${key}`); + assertJsonValue(entry, `${label}.${key}`, ancestors); } + ancestors.delete(value); return; } diff --git a/src/protocol/orpc/index.ts b/src/protocol/orpc/index.ts index 3d0b2c3..22931c6 100644 --- a/src/protocol/orpc/index.ts +++ b/src/protocol/orpc/index.ts @@ -1,650 +1,513 @@ -import type { - DriverCapability, - DriverCapabilityId, - McpExecuteCommandResult, - McpExternalToolEffectClaim, - RuntimeCommand, - RuntimeCommandResult, - RuntimeCommandStatus, +import { z } from "zod"; + +import { + DURABLE_RUN_ERROR_MAX_UTF8_BYTES, + DRIVER_CAPABILITY_IDS, + RUNTIME_COMMAND_TERMINAL_PAYLOAD_MAX_UTF8_BYTES, + measureRuntimeCommandJson, + parseRuntimeCommand, } from "../../runtime-command"; -import type { DriverBootPayload } from "../boot"; -import { parseDriverEventEnvelope, type DriverEventEnvelope } from "../events"; -import { isSupportedDriverRuntime } from "../runtime"; -import type { DriverRuntime } from "../runtime"; - -export interface DriverHelloInput { - readonly capabilities: readonly DriverCapability[]; - readonly driverVersion: string; - readonly pid: number; - readonly protocolVersion: DriverBootPayload["protocolVersion"]; - readonly runtime: DriverRuntime; - readonly startedAt: string; -} - -export interface DriverHelloOutput { - readonly acceptedCapabilities: readonly DriverCapability[]; - readonly connectionId: string; - readonly driverInstanceId: string; - readonly heartbeatIntervalMs: number; - readonly runConfig: { - readonly commandLeaseMs: number; - readonly envPolicy: "strict"; - readonly eventBatchMaxSize: number; - readonly organizationPath: string; - }; - readonly runId: string | null; -} - -export interface DriverHeartbeatInput { - readonly at: string; - readonly pid: number; - readonly reason: "interval" | "ping"; -} - -export interface DriverHeartbeatOutput { - readonly heartbeatCount: number; - readonly ok: true; -} - -export interface DriverReadyInput { - readonly at: string; - readonly driverInstanceId: string; - readonly pid: number; -} - -export interface DriverLogContext { - parentSpanId?: string | undefined; - requestId?: string | undefined; - sandboxId?: string | undefined; - sessionId?: string | undefined; - spanId?: string | undefined; - traceId?: string | undefined; -} - -export interface DriverLogError { - readonly code?: number | string | undefined; - readonly message: string; - readonly name: string; - readonly stack?: string | null | undefined; -} - -export interface DriverLogEntry { - readonly context?: DriverLogContext | undefined; - readonly error?: DriverLogError | undefined; - readonly fields?: Record | undefined; - readonly level: "debug" | "error" | "info" | "trace" | "warn"; - readonly message: string; - readonly namespace?: string | null | undefined; - readonly seq: number; - readonly timestamp: string; -} - -export interface DriverLogBatchInput { - readonly driverInstanceId: string; - readonly logs: readonly DriverLogEntry[]; -} - -export interface DriverLogBatchOutput { - readonly ok: true; -} - -export interface DriverFailureInput { - readonly driverInstanceId: string; - readonly error: { - readonly code: string; - readonly details: Record; - readonly message: string; - readonly retryable: boolean; - }; -} - -export interface DriverCommandUpdateInput { - readonly commandId: string; - readonly driverInstanceId: string; - readonly error?: DriverFailureInput["error"] | undefined; - readonly result?: RuntimeCommandResult | undefined; - readonly status: RuntimeCommandStatus; -} - -export interface DriverExternalToolEffectClaimInput { - readonly commandId: string; - readonly driverInstanceId: string; -} - -export type DriverExternalToolEffectClaimOutput = McpExternalToolEffectClaim; +import { DRIVER_PROTOCOL_VERSION } from "../boot"; +import { RUNTIME_EVENT_KINDS, parseDriverEventEnvelope } from "../events"; +import { SUPPORTED_DRIVER_RUNTIMES } from "../runtime"; + +const nonEmptyStringSchema = z.string().min(1); +const lowercaseUuidV4Schema = z.uuidv4().regex(/^[0-9a-f-]+$/u, "UUID must be lowercase."); +const primitiveSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]); +const okSchema = z.strictObject({ ok: z.literal(true) }); +const driverRpcBatchMaxSize = 64; + +function omitUndefined>(value: T): T { + return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)) as T; +} + +function positiveSafeIntegerSchema(label: string, minimum = 1) { + return z.number().refine((value) => Number.isSafeInteger(value) && value >= minimum, { + error: + minimum === 1 + ? `${label} must be a positive safe integer.` + : `${label} must be an integer of at least ${String(minimum)}.`, + }); +} + +function nonNegativeSafeIntegerSchema(label: string) { + return z.number().refine((value) => Number.isSafeInteger(value) && value >= 0, { + error: `${label} must be a non-negative safe integer.`, + }); +} + +function parsedSchema(parser: (value: unknown) => Output) { + return z.unknown().transform((value, ctx) => { + try { + return parser(value); + } catch (error) { + if (error instanceof z.ZodError) { + for (const issue of error.issues) { + ctx.addIssue({ code: "custom", message: issue.message, path: issue.path }); + } + } else { + ctx.addIssue({ + code: "custom", + message: error instanceof Error ? error.message : "Invalid value.", + }); + } + + return z.NEVER; + } + }); +} + +function primitiveRecordSchema(label: string) { + return z + .custom>( + (value) => typeof value === "object" && value !== null && !Array.isArray(value), + { error: `${label} must be an object.` }, + ) + .superRefine((record, ctx) => { + for (const [field, value] of Object.entries(record)) { + if (!primitiveSchema.safeParse(value).success) { + ctx.addIssue({ + code: "custom", + message: `${label}.${field} must be a primitive value.`, + path: [field], + }); + } + } + }) + .transform( + (record) => + Object.fromEntries(Object.entries(record)) as Record< + string, + string | number | boolean | null + >, + ); +} + +const driverCapabilitySchema = z + .strictObject({ + details: z.string().optional(), + id: z.enum(DRIVER_CAPABILITY_IDS, { error: "capability id is unsupported." }), + status: z.enum(["supported", "unsupported"], { + error: "capability status must be supported or unsupported.", + }), + version: z.literal(1, { error: "capability version must be 1." }), + }) + .transform(omitUndefined); + +const driverCapabilitiesSchema = z + .array(driverCapabilitySchema) + .superRefine((capabilities, ctx) => { + if (new Set(capabilities.map(({ id }) => id)).size !== capabilities.length) { + ctx.addIssue({ + code: "custom", + message: "capabilities must not contain duplicate ids.", + }); + } + }); + +const durableRunErrorSchema = z + .strictObject({ + code: nonEmptyStringSchema, + details: primitiveRecordSchema("driver failure details"), + message: nonEmptyStringSchema, + retryable: z.boolean(), + }) + .refine((error) => measureRuntimeCommandJson(error) <= DURABLE_RUN_ERROR_MAX_UTF8_BYTES, { + error: `Driver error must not exceed ${String(DURABLE_RUN_ERROR_MAX_UTF8_BYTES)} UTF-8 bytes.`, + }); + +const inputStartCommandResultSchema = z.strictObject({ + requestId: nonEmptyStringSchema, +}); + +const mcpExecuteCommandResultSchema = z + .strictObject({ + isError: z.boolean().optional(), + outputText: z.string(), + requestId: nonEmptyStringSchema, + serverId: nonEmptyStringSchema, + toolName: nonEmptyStringSchema, + }) + .transform(omitUndefined) + .refine( + (result) => + measureRuntimeCommandJson(result) <= RUNTIME_COMMAND_TERMINAL_PAYLOAD_MAX_UTF8_BYTES, + { + error: `MCP command result must not exceed ${String(RUNTIME_COMMAND_TERMINAL_PAYLOAD_MAX_UTF8_BYTES)} UTF-8 bytes.`, + }, + ); -export interface DriverExternalToolEffectCompleteInput { - readonly commandId: string; - readonly driverInstanceId: string; - readonly providerReceiptJson?: string | null | undefined; - readonly result: McpExecuteCommandResult; -} +const runtimeCommandResultSchema = z + .union([inputStartCommandResultSchema, mcpExecuteCommandResultSchema]) + .refine( + (result) => + measureRuntimeCommandJson(result) <= RUNTIME_COMMAND_TERMINAL_PAYLOAD_MAX_UTF8_BYTES, + { + error: `Driver command result must not exceed ${String(RUNTIME_COMMAND_TERMINAL_PAYLOAD_MAX_UTF8_BYTES)} UTF-8 bytes.`, + }, + ); -export interface DriverExternalToolEffectUnknownInput { - readonly commandId: string; - readonly driverInstanceId: string; -} +const driverHelloInputSchema = z.strictObject({ + capabilities: driverCapabilitiesSchema, + driverVersion: nonEmptyStringSchema, + pid: positiveSafeIntegerSchema("pid"), + protocolVersion: z.literal(DRIVER_PROTOCOL_VERSION, { + error: `protocolVersion must be ${String(DRIVER_PROTOCOL_VERSION)}.`, + }), + runtime: z.enum(SUPPORTED_DRIVER_RUNTIMES, { error: "Unsupported driver runtime." }), + startedAt: nonEmptyStringSchema, +}); + +const driverHelloOutputSchema = z.strictObject({ + acceptedCapabilities: driverCapabilitiesSchema, + connectionId: nonEmptyStringSchema, + driverInstanceId: nonEmptyStringSchema, + heartbeatIntervalMs: positiveSafeIntegerSchema("Driver heartbeat interval", 250), + runConfig: z.strictObject({ + commandLeaseMs: nonNegativeSafeIntegerSchema("Driver command lease"), + envPolicy: z.literal("strict"), + eventBatchMaxSize: positiveSafeIntegerSchema("Driver event batch max size").refine( + (value) => value <= driverRpcBatchMaxSize, + { + error: `Driver event batch max size must not exceed ${String(driverRpcBatchMaxSize)}.`, + }, + ), + organizationPath: nonEmptyStringSchema, + }), + runId: nonEmptyStringSchema.nullable(), +}); + +const driverHeartbeatInputSchema = z.strictObject({ + at: nonEmptyStringSchema, + pid: positiveSafeIntegerSchema("pid"), + reason: z.enum(["interval", "ping"], { error: "reason must be interval or ping." }), +}); + +const driverHeartbeatOutputSchema = z.strictObject({ + heartbeatCount: nonNegativeSafeIntegerSchema("heartbeatCount"), + ok: z.literal(true), +}); + +const driverReadyInputSchema = z.strictObject({ + at: nonEmptyStringSchema, + driverInstanceId: nonEmptyStringSchema, + pid: positiveSafeIntegerSchema("pid"), +}); + +const driverLogContextSchema = z + .strictObject({ + parentSpanId: z.string().optional(), + requestId: z.string().optional(), + sandboxId: z.string().optional(), + sessionId: z.string().optional(), + spanId: z.string().optional(), + traceId: z.string().optional(), + }) + .transform(omitUndefined); + +const driverLogErrorSchema = z + .strictObject({ + code: z.union([z.string(), z.number()]).optional(), + message: z.string(), + name: z.string(), + stack: z.string().nullable().optional(), + }) + .transform(omitUndefined); + +const driverLogEntrySchema = z + .strictObject({ + context: driverLogContextSchema.optional(), + error: driverLogErrorSchema.optional(), + fields: primitiveRecordSchema("driver log fields").optional(), + level: z.enum(["debug", "error", "info", "trace", "warn"], { + error: "driver log level is unsupported.", + }), + message: z.string(), + namespace: z.string().nullable().optional(), + seq: nonNegativeSafeIntegerSchema("seq"), + timestamp: nonEmptyStringSchema, + }) + .transform(omitUndefined); + +const driverLogBatchInputSchema = z.strictObject({ + driverInstanceId: nonEmptyStringSchema, + logs: z.array(driverLogEntrySchema).max(driverRpcBatchMaxSize), +}); + +const driverFailureInputSchema = z.strictObject({ + driverInstanceId: nonEmptyStringSchema, + error: durableRunErrorSchema, + runId: nonEmptyStringSchema, +}); + +const driverCommandIdentitySchema = { + commandId: nonEmptyStringSchema, + driverInstanceId: nonEmptyStringSchema, +} as const; +const driverCommandUpdateInputSchema = z.discriminatedUnion("status", [ + z.strictObject({ ...driverCommandIdentitySchema, status: z.literal("accepted") }), + z.strictObject({ ...driverCommandIdentitySchema, status: z.literal("cancelled") }), + z.strictObject({ + ...driverCommandIdentitySchema, + result: runtimeCommandResultSchema.optional(), + status: z.literal("completed"), + }), + z.strictObject({ + ...driverCommandIdentitySchema, + error: durableRunErrorSchema, + status: z.literal("failed"), + }), +]); -export interface DriverEventBatchInput { - readonly driverInstanceId: string; - readonly events: readonly DriverEventEnvelope[]; -} +const driverExternalToolEffectObserveInputSchema = z.strictObject(driverCommandIdentitySchema); + +const driverExternalToolEffectIntentSchema = z.strictObject({ + effectId: nonEmptyStringSchema, + kind: z.literal("intent"), +}); + +const driverExternalToolEffectClaimedSchema = z.strictObject({ + attempt: positiveSafeIntegerSchema("attempt"), + effectId: nonEmptyStringSchema, + idempotencyKey: nonEmptyStringSchema, + kind: z.literal("claimed"), +}); + +const driverExternalToolEffectSucceededSchema = z.strictObject({ + effectId: nonEmptyStringSchema, + kind: z.literal("succeeded"), + result: mcpExecuteCommandResultSchema, +}); + +const driverExternalToolEffectUnknownSchema = z.strictObject({ + effectId: nonEmptyStringSchema, + kind: z.literal("unknown"), +}); + +const driverExternalToolEffectStateSchema = z.discriminatedUnion("kind", [ + driverExternalToolEffectIntentSchema, + driverExternalToolEffectClaimedSchema, + driverExternalToolEffectSucceededSchema, + driverExternalToolEffectUnknownSchema, +]); -export interface DriverEventReceipt { - readonly eventId?: string | undefined; - readonly seq: number; - readonly type: string; -} +const driverExternalToolEffectClaimInputSchema = z.strictObject({ + ...driverCommandIdentitySchema, + claimToken: lowercaseUuidV4Schema, +}); -export interface DriverEventBatchOutput { - readonly accepted: readonly DriverEventReceipt[]; -} - -export interface DriverNextCommandInput { - readonly driverInstanceId: string; -} +const driverExternalToolEffectClaimOutputSchema = z.discriminatedUnion("kind", [ + driverExternalToolEffectClaimedSchema, + driverExternalToolEffectSucceededSchema, + driverExternalToolEffectUnknownSchema, +]); -export interface DriverNextCommandOutput { - readonly command: RuntimeCommand | null; -} +const driverExternalToolEffectSettlementSchema = z + .discriminatedUnion("kind", [ + z.strictObject({ + kind: z.literal("succeeded"), + providerReceiptJson: z.string().nullable().optional(), + result: mcpExecuteCommandResultSchema, + }), + z.strictObject({ kind: z.literal("unknown") }), + ]) + .refine( + (settlement) => + measureRuntimeCommandJson(settlement) <= RUNTIME_COMMAND_TERMINAL_PAYLOAD_MAX_UTF8_BYTES, + { + error: `MCP settlement must not exceed ${String(RUNTIME_COMMAND_TERMINAL_PAYLOAD_MAX_UTF8_BYTES)} UTF-8 bytes.`, + }, + ); -export interface DriverCompletionInput { - readonly driverInstanceId: string; -} +const driverExternalToolEffectSettleInputSchema = z.strictObject({ + ...driverCommandIdentitySchema, + claimToken: lowercaseUuidV4Schema, + effectId: nonEmptyStringSchema, + settlement: driverExternalToolEffectSettlementSchema, +}); + +const driverEventBatchInputSchema = z.strictObject({ + driverInstanceId: nonEmptyStringSchema, + events: z.array(parsedSchema(parseDriverEventEnvelope)).max(driverRpcBatchMaxSize), +}); + +const driverEventReceiptSchema = z.strictObject({ + eventId: nonEmptyStringSchema, + seq: nonNegativeSafeIntegerSchema("Driver event receipt seq"), + type: z.enum(RUNTIME_EVENT_KINDS), +}); + +const driverEventBatchOutputSchema = z.strictObject({ + accepted: z.array(driverEventReceiptSchema), +}); + +const driverInstanceInputSchema = z.strictObject({ + driverInstanceId: nonEmptyStringSchema, +}); + +const driverCompletionInputSchema = z.strictObject({ + driverInstanceId: nonEmptyStringSchema, + runId: nonEmptyStringSchema, +}); + +const driverNextCommandOutputSchema = z.strictObject({ + command: parsedSchema(parseRuntimeCommand).nullable(), +}); + +interface RpcMethodSchema { + readonly input: z.ZodType; + readonly output: z.ZodType; +} + +type RpcSchemaMap = Record>; + +type DeepReadonly = Value extends + | string + | number + | boolean + | bigint + | symbol + | null + | undefined + ? Value + : Value extends readonly (infer Entry)[] + ? readonly DeepReadonly[] + : Value extends object + ? { readonly [Key in keyof Value]: DeepReadonly } + : Value; + +type SchemaValue = DeepReadonly>; + +export const driverRuntimeRpcSchemas = { + driver: { + observeExternalToolEffect: { + input: driverExternalToolEffectObserveInputSchema, + output: driverExternalToolEffectStateSchema, + }, + claimExternalToolEffect: { + input: driverExternalToolEffectClaimInputSchema, + output: driverExternalToolEffectClaimOutputSchema, + }, + commandUpdate: { input: driverCommandUpdateInputSchema, output: okSchema }, + completeRun: { input: driverCompletionInputSchema, output: okSchema }, + failRun: { input: driverFailureInputSchema, output: okSchema }, + heartbeat: { input: driverHeartbeatInputSchema, output: driverHeartbeatOutputSchema }, + hello: { input: driverHelloInputSchema, output: driverHelloOutputSchema }, + settleExternalToolEffect: { + input: driverExternalToolEffectSettleInputSchema, + output: driverExternalToolEffectStateSchema, + }, + pushEvents: { input: driverEventBatchInputSchema, output: driverEventBatchOutputSchema }, + pushLogs: { input: driverLogBatchInputSchema, output: okSchema }, + ready: { input: driverReadyInputSchema, output: okSchema }, + }, + driverInstance: { + nextCommand: { input: driverInstanceInputSchema, output: driverNextCommandOutputSchema }, + }, +} as const satisfies RpcSchemaMap; export interface DriverRpcOptions { readonly signal?: AbortSignal; } -export interface DriverRuntimeClient { - readonly driver: { - commandUpdate( - input: DriverCommandUpdateInput, - options?: DriverRpcOptions, - ): Promise<{ ok: true }>; - claimExternalToolEffect( - input: DriverExternalToolEffectClaimInput, - options?: DriverRpcOptions, - ): Promise; - completeExternalToolEffect( - input: DriverExternalToolEffectCompleteInput, - options?: DriverRpcOptions, - ): Promise<{ ok: true }>; - completeRun(input: DriverCompletionInput, options?: DriverRpcOptions): Promise<{ ok: true }>; - failRun(input: DriverFailureInput, options?: DriverRpcOptions): Promise<{ ok: true }>; - heartbeat( - input: DriverHeartbeatInput, - options?: DriverRpcOptions, - ): Promise; - hello(input: DriverHelloInput, options?: DriverRpcOptions): Promise; - pushEvents( - input: DriverEventBatchInput, - options?: DriverRpcOptions, - ): Promise; - pushLogs(input: DriverLogBatchInput, options?: DriverRpcOptions): Promise; - ready(input: DriverReadyInput, options?: DriverRpcOptions): Promise<{ ok: true }>; - markExternalToolEffectUnknown( - input: DriverExternalToolEffectUnknownInput, - options?: DriverRpcOptions, - ): Promise<{ ok: true }>; - }; - readonly driverInstance: { - nextCommand( - input: DriverNextCommandInput, - options?: DriverRpcOptions, - ): Promise; +type RpcClient = { + readonly [Group in keyof Schema]: { + readonly [Method in keyof Schema[Group]]: Schema[Group][Method] extends RpcMethodSchema + ? ( + input: SchemaValue, + options?: DriverRpcOptions, + ) => Promise> + : never; }; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readRecord(value: unknown, label: string): Record { - if (!isRecord(value)) { - throw new TypeError(`${label} must be an object.`); - } - - return value; -} - -function readString(record: Record, field: string): string { - const value = record[field]; - - if (typeof value !== "string") { - throw new TypeError(`${field} must be a string.`); - } - - return value; -} - -function readNonEmptyString(record: Record, field: string): string { - const value = readString(record, field); - - if (value.length === 0) { - throw new TypeError(`${field} must be a non-empty string.`); - } - - return value; -} - -function readOptionalString(record: Record, field: string): string | undefined { - const value = record[field]; - - if (value === undefined) { - return undefined; - } - - if (typeof value !== "string") { - throw new TypeError(`${field} must be a string.`); - } - - return value; -} - -function readNumber(record: Record, field: string): number { - const value = record[field]; - - if (typeof value !== "number" || !Number.isFinite(value)) { - throw new TypeError(`${field} must be a finite number.`); - } - - return value; -} - -function readPositiveInteger(record: Record, field: string): number { - const value = readNumber(record, field); - - if (!Number.isSafeInteger(value) || value < 1) { - throw new TypeError(`${field} must be a positive safe integer.`); - } - - return value; -} - -function readNonNegativeInteger(record: Record, field: string): number { - const value = readNumber(record, field); - - if (!Number.isSafeInteger(value) || value < 0) { - throw new TypeError(`${field} must be a non-negative safe integer.`); - } - - return value; -} - -function readBoolean(record: Record, field: string): boolean { - const value = record[field]; - - if (typeof value !== "boolean") { - throw new TypeError(`${field} must be a boolean.`); - } - - return value; -} - -function readProtocolVersion( - record: Record, -): DriverBootPayload["protocolVersion"] { - const value = record["protocolVersion"]; - - if (value !== 2) { - throw new TypeError("protocolVersion must be 2."); - } - - return value; -} - -function readDriverRuntime(record: Record): DriverRuntime { - const runtime = readNonEmptyString(record, "runtime"); - - if (!isSupportedDriverRuntime(runtime)) { - throw new TypeError(`Unsupported driver runtime: ${runtime}.`); - } - - return runtime; -} - -function readHeartbeatReason(value: unknown): DriverHeartbeatInput["reason"] { - if (value === "interval" || value === "ping") { - return value; - } - - throw new TypeError("reason must be interval or ping."); -} - -const DRIVER_CAPABILITY_IDS = new Set([ - "custom_tool_execute", - "file_change", - "input_start", - "mcp_execute", - "native_resume", - "permission_request", - "session_stop", - "text_stream", - "thinking_stream", - "tool_stream", - "turn_cancel", - "usage", - "visible_activity", -]); - -function readDriverCapabilityId(value: unknown): DriverCapabilityId { - if (typeof value === "string" && DRIVER_CAPABILITY_IDS.has(value as DriverCapabilityId)) { - return value as DriverCapabilityId; - } - - throw new TypeError("capability id is unsupported."); -} - -function readDriverCapabilityStatus(value: unknown): DriverCapability["status"] { - if (value === "supported" || value === "unsupported") { - return value; - } - - throw new TypeError("capability status must be supported or unsupported."); -} - -function readDriverCapability(value: unknown): DriverCapability { - const record = readRecord(value, "capability"); - const details = readOptionalString(record, "details"); - - return { - ...(details === undefined ? {} : { details }), - id: readDriverCapabilityId(record["id"]), - status: readDriverCapabilityStatus(record["status"]), - version: readDriverCapabilityVersion(record["version"]), - }; -} - -function readDriverCapabilityVersion(value: unknown): 1 { - if (value !== 1) { - throw new TypeError("capability version must be 1."); - } - - return value; -} - -function readDriverCapabilities(record: Record): DriverCapability[] { - const value = record["capabilities"]; - - if (!Array.isArray(value)) { - throw new TypeError("capabilities must be an array."); - } - - const capabilities = value.map(readDriverCapability); - - if (new Set(capabilities.map(({ id }) => id)).size !== capabilities.length) { - throw new TypeError("capabilities must not contain duplicate ids."); - } - - return capabilities; -} +}; + +export type DriverRuntimeClient = RpcClient; +export type DriverHelloInput = SchemaValue; +export type DriverHelloOutput = SchemaValue; +export type DriverHeartbeatInput = SchemaValue; +export type DriverHeartbeatOutput = SchemaValue; +export type DriverReadyInput = SchemaValue; +export type DriverLogContext = z.output; +export type DriverLogError = SchemaValue; +export type DriverLogEntry = Omit, "context"> & { + readonly context?: DriverLogContext | undefined; +}; +export type DriverLogBatchInput = Omit, "logs"> & { + readonly logs: readonly DriverLogEntry[]; +}; +export type DriverLogBatchOutput = SchemaValue; +export type DriverFailureInput = SchemaValue; +export type DriverCommandUpdateInput = SchemaValue; +export type DriverExternalToolEffectObserveInput = SchemaValue< + typeof driverExternalToolEffectObserveInputSchema +>; +export type DriverExternalToolEffectState = SchemaValue; +export type DriverExternalToolEffectClaimInput = SchemaValue< + typeof driverExternalToolEffectClaimInputSchema +>; +export type DriverExternalToolEffectClaimOutput = SchemaValue< + typeof driverExternalToolEffectClaimOutputSchema +>; +export type DriverExternalToolEffectSettleInput = SchemaValue< + typeof driverExternalToolEffectSettleInputSchema +>; +export type DriverEventBatchInput = SchemaValue; +export type DriverEventReceipt = SchemaValue; +export type DriverEventBatchOutput = SchemaValue; +export type DriverNextCommandInput = SchemaValue; +export type DriverNextCommandOutput = SchemaValue; +export type DriverCompletionInput = SchemaValue; export function parseDriverHelloInput(value: unknown): DriverHelloInput { - const record = readRecord(value, "driver hello input"); - - return { - capabilities: readDriverCapabilities(record), - driverVersion: readNonEmptyString(record, "driverVersion"), - pid: readPositiveInteger(record, "pid"), - protocolVersion: readProtocolVersion(record), - runtime: readDriverRuntime(record), - startedAt: readNonEmptyString(record, "startedAt"), - }; + return driverHelloInputSchema.parse(value); } export function parseDriverHeartbeatInput(value: unknown): DriverHeartbeatInput { - const record = readRecord(value, "driver heartbeat input"); - - return { - at: readNonEmptyString(record, "at"), - pid: readPositiveInteger(record, "pid"), - reason: readHeartbeatReason(record["reason"]), - }; + return driverHeartbeatInputSchema.parse(value); } export function parseDriverReadyInput(value: unknown): DriverReadyInput { - const record = readRecord(value, "driver ready input"); - - return { - at: readNonEmptyString(record, "at"), - driverInstanceId: readNonEmptyString(record, "driverInstanceId"), - pid: readPositiveInteger(record, "pid"), - }; + return driverReadyInputSchema.parse(value); } -const DRIVER_COMMAND_STATUSES = new Set([ - "accepted", - "cancelled", - "completed", - "delivered", - "expired", - "failed", - "queued", -]); - -function readDriverCommandStatus(value: unknown): RuntimeCommandStatus { - if (typeof value === "string" && DRIVER_COMMAND_STATUSES.has(value as RuntimeCommandStatus)) { - return value as RuntimeCommandStatus; - } - - throw new TypeError("status is not a supported runtime command status."); +export function parseDriverCommandUpdateInput(value: unknown): DriverCommandUpdateInput { + return driverCommandUpdateInputSchema.parse(value); } -function readPrimitiveRecord( +export function parseDriverExternalToolEffectObserveInput( value: unknown, - label: string, -): Record { - const record = readRecord(value, label); - - for (const [field, entry] of Object.entries(record)) { - if ( - entry !== null && - typeof entry !== "string" && - typeof entry !== "boolean" && - (typeof entry !== "number" || !Number.isFinite(entry)) - ) { - throw new TypeError(`${label}.${field} must be a primitive value.`); - } - } - - return { ...record } as Record; -} - -function readDriverFailure(value: unknown): DriverFailureInput["error"] { - const record = readRecord(value, "driver failure"); - - return { - code: readNonEmptyString(record, "code"), - details: readPrimitiveRecord(record["details"], "driver failure details"), - message: readString(record, "message"), - retryable: readBoolean(record, "retryable"), - }; +): DriverExternalToolEffectObserveInput { + return driverExternalToolEffectObserveInputSchema.parse(value); } -function readRuntimeCommandResult(value: unknown): RuntimeCommandResult { - if (value === null) { - return null; - } - - const record = readRecord(value, "runtime command result"); - const requestId = readNonEmptyString(record, "requestId"); - - if ( - record["outputText"] !== undefined || - record["serverId"] !== undefined || - record["toolName"] !== undefined - ) { - const isError = record["isError"] === undefined ? undefined : readBoolean(record, "isError"); - - return { - ...(isError === undefined ? {} : { isError }), - outputText: readString(record, "outputText"), - requestId, - serverId: readNonEmptyString(record, "serverId"), - toolName: readNonEmptyString(record, "toolName"), - }; - } - - return { requestId }; +export function parseDriverExternalToolEffectClaimInput( + value: unknown, +): DriverExternalToolEffectClaimInput { + return driverExternalToolEffectClaimInputSchema.parse(value); } -export function parseDriverCommandUpdateInput(value: unknown): DriverCommandUpdateInput { - const record = readRecord(value, "driver command update input"); - const error = record["error"] === undefined ? undefined : readDriverFailure(record["error"]); - const result = - record["result"] === undefined ? undefined : readRuntimeCommandResult(record["result"]); - - return { - commandId: readNonEmptyString(record, "commandId"), - driverInstanceId: readNonEmptyString(record, "driverInstanceId"), - ...(error === undefined ? {} : { error }), - ...(result === undefined ? {} : { result }), - status: readDriverCommandStatus(record["status"]), - }; +export function parseDriverExternalToolEffectSettleInput( + value: unknown, +): DriverExternalToolEffectSettleInput { + return driverExternalToolEffectSettleInputSchema.parse(value); } export function parseDriverCompletionInput(value: unknown): DriverCompletionInput { - const record = readRecord(value, "driver completion input"); - return { driverInstanceId: readNonEmptyString(record, "driverInstanceId") }; + return driverCompletionInputSchema.parse(value); } export function parseDriverFailureInput(value: unknown): DriverFailureInput { - const record = readRecord(value, "driver failure input"); - - return { - driverInstanceId: readNonEmptyString(record, "driverInstanceId"), - error: readDriverFailure(record["error"]), - }; -} - -function readOptionalLogContext(value: unknown): DriverLogContext | undefined { - if (value === undefined) { - return undefined; - } - - const record = readRecord(value, "driver log context"); - const context = { - parentSpanId: readOptionalString(record, "parentSpanId"), - requestId: readOptionalString(record, "requestId"), - sandboxId: readOptionalString(record, "sandboxId"), - sessionId: readOptionalString(record, "sessionId"), - spanId: readOptionalString(record, "spanId"), - traceId: readOptionalString(record, "traceId"), - }; - - return Object.fromEntries( - Object.entries(context).filter((entry): entry is [string, string] => entry[1] !== undefined), - ); -} - -function readOptionalLogError(value: unknown): DriverLogError | undefined { - if (value === undefined) { - return undefined; - } - - const record = readRecord(value, "driver log error"); - const code = record["code"]; - const stack = record["stack"]; - - if ( - code !== undefined && - typeof code !== "string" && - (typeof code !== "number" || !Number.isFinite(code)) - ) { - throw new TypeError("driver log error.code must be a string or finite number."); - } - - if (stack !== undefined && stack !== null && typeof stack !== "string") { - throw new TypeError("driver log error.stack must be a string or null."); - } - - return { - ...(code === undefined ? {} : { code }), - message: readString(record, "message"), - name: readString(record, "name"), - ...(stack === undefined ? {} : { stack }), - }; -} - -function readOptionalNullableString( - record: Record, - field: string, -): string | null | undefined { - const value = record[field]; - - if (value === undefined || value === null || typeof value === "string") { - return value; - } - - throw new TypeError(`${field} must be a string or null.`); -} - -function readDriverLogLevel(value: unknown): DriverLogEntry["level"] { - if ( - value === "debug" || - value === "error" || - value === "info" || - value === "trace" || - value === "warn" - ) { - return value; - } - - throw new TypeError("driver log level is unsupported."); -} - -function readDriverLogEntry(value: unknown): DriverLogEntry { - const record = readRecord(value, "driver log entry"); - const context = readOptionalLogContext(record["context"]); - const error = readOptionalLogError(record["error"]); - const fields = - record["fields"] === undefined - ? undefined - : readPrimitiveRecord(record["fields"], "driver log fields"); - const namespace = readOptionalNullableString(record, "namespace"); - - return { - ...(context === undefined ? {} : { context }), - ...(error === undefined ? {} : { error }), - ...(fields === undefined ? {} : { fields }), - level: readDriverLogLevel(record["level"]), - message: readString(record, "message"), - ...(namespace === undefined ? {} : { namespace }), - seq: readNonNegativeInteger(record, "seq"), - timestamp: readNonEmptyString(record, "timestamp"), - }; + return driverFailureInputSchema.parse(value); } export function parseDriverLogBatchInput(value: unknown): DriverLogBatchInput { - const record = readRecord(value, "driver log batch input"); - const logs = record["logs"]; - - if (!Array.isArray(logs)) { - throw new TypeError("logs must be an array."); - } - - return { - driverInstanceId: readNonEmptyString(record, "driverInstanceId"), - logs: logs.map(readDriverLogEntry), - }; + return driverLogBatchInputSchema.parse(value); } export function parseDriverEventBatchInput(value: unknown): DriverEventBatchInput { - const record = readRecord(value, "driver event batch input"); - const events = record["events"]; - - if (!Array.isArray(events)) { - throw new TypeError("events must be an array."); - } - - return { - driverInstanceId: readNonEmptyString(record, "driverInstanceId"), - events: events.map(parseDriverEventEnvelope), - }; + return driverEventBatchInputSchema.parse(value); } export function parseDriverNextCommandInput(value: unknown): DriverNextCommandInput { - const record = readRecord(value, "driver next command input"); - return { driverInstanceId: readNonEmptyString(record, "driverInstanceId") }; + return driverInstanceInputSchema.parse(value); } diff --git a/src/protocol/paths/index.ts b/src/protocol/paths/index.ts index c9af444..2932219 100644 --- a/src/protocol/paths/index.ts +++ b/src/protocol/paths/index.ts @@ -6,8 +6,7 @@ export const SANDBOX_SESSION_STATE_DIR = ".state"; export const SANDBOX_SESSION_ROOT = `${SANDBOX_WORKSPACE_ROOT}/se`; const SESSION_RESOURCE_MOUNT_DIR = "session-files"; - -export type SandboxFileBrowserPathPurpose = "content" | "tree"; +const SESSION_RESOURCE_BACKING_ROOT = `${SANDBOX_WORKSPACE_ROOT}/.mosoo/session-files`; export function getSessionWorkspacePath(sessionId: string): string { return `${SANDBOX_SESSION_ROOT}/${sessionId}`; @@ -25,6 +24,10 @@ export function getSessionResourceRootPath(sessionId: string): string { return `${getSessionWorkspacePath(sessionId)}/${SESSION_RESOURCE_MOUNT_DIR}`; } +export function getSessionResourceBackingPath(sessionId: string): string { + return `${SESSION_RESOURCE_BACKING_ROOT}/${sessionId}`; +} + export function getSessionRuntimeStatePath(sessionId: string, runtimeId: string): string { return `${getSessionStateRootPath(sessionId)}/${runtimeId}`; } @@ -61,6 +64,10 @@ export function isSandboxSessionStatePath(path: string): boolean { ); } +export function isSandboxSessionResourceBackingPath(path: string): boolean { + return hasConcreteChildPath(path, SESSION_RESOURCE_BACKING_ROOT); +} + function hasControlCharacter(value: string): boolean { for (const character of value) { const codePoint = character.codePointAt(0); @@ -135,10 +142,7 @@ function isAllowedSandboxFileBrowserPath(path: string): boolean { ); } -export function normalizeSandboxFileBrowserPath( - rawPath: string, - _purpose: SandboxFileBrowserPathPurpose, -): string { +export function normalizeSandboxFileBrowserPath(rawPath: string): string { const path = readSandboxFileBrowserPathOriginal(rawPath); if (isSandboxCachePath(path)) { @@ -149,6 +153,10 @@ export function normalizeSandboxFileBrowserPath( throw new Error("Session runtime state is not visible in the Agent File Browser."); } + if (isSandboxSessionResourceBackingPath(path)) { + throw new Error("Session resource backing is not visible in the Agent File Browser."); + } + if (!isAllowedSandboxFileBrowserPath(path)) { throw new Error("Sandbox browser path is outside the Agent home."); } diff --git a/src/protocol/runtime.ts b/src/protocol/runtime.ts index 23400dc..0160f27 100644 --- a/src/protocol/runtime.ts +++ b/src/protocol/runtime.ts @@ -1,3 +1,5 @@ +import { z } from "zod"; + export const SUPPORTED_DRIVER_RUNTIMES = [ "openai-runtime", "claude-agent-sdk", @@ -20,12 +22,6 @@ export type DriverRuntime = (typeof SUPPORTED_DRIVER_RUNTIMES)[number]; export type DriverRuntimeTransport = (typeof SUPPORTED_DRIVER_RUNTIME_TRANSPORTS)[number]; export type DriverNativeRuntimeRefKind = (typeof SUPPORTED_DRIVER_NATIVE_RUNTIME_REF_KINDS)[number]; -export interface DriverNativeRuntimeRef { - readonly kind: DriverNativeRuntimeRefKind; - readonly runtimeId: DriverRuntime; - readonly value: string; -} - export function isSupportedDriverRuntime(value: string): value is DriverRuntime { return (SUPPORTED_DRIVER_RUNTIMES as readonly string[]).includes(value); } @@ -50,42 +46,53 @@ export function getExpectedDriverNativeRuntimeRefKind( } } -function readRecord(value: unknown, label: string): Record { - if (typeof value !== "object" || value === null || Array.isArray(value)) { - throw new TypeError(`${label} must be an object.`); - } - - return value as Record; -} - -function readNonEmptyString(record: Record, field: string, label: string): string { - const value = record[field]; - - if (typeof value !== "string" || value.length === 0) { - throw new TypeError(`${label}.${field} must be a non-empty string.`); - } +const nativeRuntimeRefKeys = ["kind", "runtimeId", "value"] as const; +const driverNativeRuntimeRefSchema = z.preprocess( + (value) => { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return value; + } - return value; -} + const record = value as Record; + return Object.fromEntries( + nativeRuntimeRefKeys + .filter((key) => Object.hasOwn(record, key)) + .map((key) => [key, record[key]]), + ); + }, + z + .object({ + kind: z.enum(SUPPORTED_DRIVER_NATIVE_RUNTIME_REF_KINDS), + runtimeId: z.enum(SUPPORTED_DRIVER_RUNTIMES), + value: z.string().min(1), + }) + .superRefine((reference, context) => { + if (reference.kind !== getExpectedDriverNativeRuntimeRefKind(reference.runtimeId)) { + context.addIssue({ + code: "custom", + message: `kind ${reference.kind} does not match runtime ${reference.runtimeId}`, + path: ["kind"], + }); + } + }), +); + +export type DriverNativeRuntimeRef = Readonly>; export function parseDriverNativeRuntimeRef(value: unknown): DriverNativeRuntimeRef { - const record = readRecord(value, "Driver native runtime ref"); - const runtimeId = readNonEmptyString(record, "runtimeId", "Driver native runtime ref"); - const kind = readNonEmptyString(record, "kind", "Driver native runtime ref"); + try { + const result = driverNativeRuntimeRefSchema.safeParse(value); - if (!isSupportedDriverRuntime(runtimeId)) { - throw new TypeError(`Unsupported native runtime ref runtime: ${runtimeId}.`); - } + if (!result.success) { + throw new TypeError(z.prettifyError(result.error)); + } - const expectedKind = getExpectedDriverNativeRuntimeRefKind(runtimeId); + return result.data; + } catch (error) { + if (error instanceof TypeError) { + throw error; + } - if (kind !== expectedKind) { - throw new TypeError(`Native runtime ref kind ${kind} does not match runtime ${runtimeId}.`); + throw new TypeError("Driver native runtime ref could not be read.", { cause: error }); } - - return { - kind, - runtimeId, - value: readNonEmptyString(record, "value", "Driver native runtime ref"), - }; } diff --git a/src/protocol/start.ts b/src/protocol/start.ts index db5f6db..80d9904 100644 --- a/src/protocol/start.ts +++ b/src/protocol/start.ts @@ -6,6 +6,7 @@ import type { DriverInstanceId } from "./id"; import type { DriverRuntime, DriverRuntimeTransport } from "./runtime"; export interface DriverStartInput { + readonly driverGeneration: number; readonly driverInstanceId: DriverInstanceId; readonly execution: DriverExecutionInput; readonly runtime: DriverRuntime; @@ -17,10 +18,11 @@ export function createDriverStartInputFromBootPayload( payload: DriverBootPayload, ): DriverStartInput { return { + driverGeneration: payload.driverGeneration, driverInstanceId: payload.driverInstanceId, execution: createDriverExecutionInputFromBootExecution(payload.execution), runtime: payload.runtime, runtimeTransport: payload.runtimeTransport, - sandboxId: payload.sandboxId, + sandboxId: payload.execution.session.context.sandboxId, }; } diff --git a/src/runtime-command/index.ts b/src/runtime-command/index.ts index 55414d2..9d6d356 100644 --- a/src/runtime-command/index.ts +++ b/src/runtime-command/index.ts @@ -1,6 +1,22 @@ export type PrimitiveValue = string | number | boolean | null; export type PrimitiveRecord = Record; +const D1_TABLE_ROW_MAX_UTF8_BYTES = 2_000_000; +const RUNTIME_COMMAND_ROW_RESERVED_UTF8_BYTES = 128 * 1_024; + +export const DURABLE_RUN_ERROR_MAX_UTF8_BYTES = 1_020 * 1_024; +export const RUNTIME_COMMAND_TERMINAL_PAYLOAD_MAX_UTF8_BYTES = DURABLE_RUN_ERROR_MAX_UTF8_BYTES; +export const RUNTIME_COMMAND_MAX_UTF8_BYTES = + D1_TABLE_ROW_MAX_UTF8_BYTES - + RUNTIME_COMMAND_TERMINAL_PAYLOAD_MAX_UTF8_BYTES - + RUNTIME_COMMAND_ROW_RESERVED_UTF8_BYTES; + +const runtimeCommandEncoder = new TextEncoder(); + +export function measureRuntimeCommandJson(value: unknown): number { + return runtimeCommandEncoder.encode(JSON.stringify(value)).byteLength; +} + export interface RunError { readonly code: string; readonly details: PrimitiveRecord; @@ -8,16 +24,19 @@ export interface RunError { readonly retryable: boolean; } -export type RuntimeCommandStatus = - | "accepted" - | "cancelled" - | "completed" - | "delivered" - | "expired" - | "failed" - | "queued"; +export const RUNTIME_COMMAND_STATUSES = [ + "accepted", + "cancelled", + "completed", + "delivered", + "expired", + "failed", + "queued", +] as const; +export type RuntimeCommandStatus = (typeof RUNTIME_COMMAND_STATUSES)[number]; export interface RuntimeCommandInput { + readonly attachmentIds?: readonly string[] | undefined; readonly text: string; } @@ -25,6 +44,7 @@ export interface TurnCancelCommand { readonly commandId: string; readonly kind: "turn.cancel"; readonly reason?: string | undefined; + readonly runId: string; } export interface InputStartCommand { @@ -46,16 +66,54 @@ export interface McpExecuteCommand { readonly commandId: string; readonly kind: "mcp.execute"; readonly requestId: string; + readonly runId: string; readonly serverId: string; readonly toolCallId: string; readonly toolName: string; } +type McpEffectErrorCommand = Pick< + McpExecuteCommand, + "commandId" | "requestId" | "runId" | "serverId" | "toolName" +>; + +export function createMcpUnknownEffectRunError( + command: McpEffectErrorCommand, + effectId: string, +): RunError { + return { + code: "driver.external_tool_effect_unknown", + details: { + commandId: command.commandId, + effectId, + requestId: command.requestId, + runId: command.runId, + serverId: command.serverId, + toolName: command.toolName, + }, + message: `External effect ${effectId} for MCP tool ${command.toolName} has an unknown outcome and will not be replayed.`, + retryable: false, + }; +} + +export function createMcpUnsettledEffectRunError( + command: Pick, + effectId: string, +): RunError { + return { + code: "driver.command_failed.mcp.execute", + details: { commandId: command.commandId, commandKind: "mcp.execute" }, + message: `External effect ${effectId} for MCP tool ${command.toolName} requires server-side repair.`, + retryable: false, + }; +} + export interface PermissionResolveCommand { readonly commandId: string; readonly decision: "allow_once" | "reject_once"; readonly kind: "permission.resolve"; readonly requestId: string; + readonly runId: string; } export type RuntimeCommand = @@ -85,26 +143,22 @@ export interface McpExternalToolExecutionResult extends McpExecuteCommandResult readonly providerReceiptJson?: string | null | undefined; } -/** - * The API-owned decision made immediately before the Driver invokes an - * external MCP provider. The key survives Driver replacement and is conveyed - * to providers through MCP request metadata. - */ export interface McpExternalToolEffectExecution { + readonly attempt: number; readonly effectId: string; readonly idempotencyKey: string; + readonly kind: "claimed"; } -export type McpExternalToolEffectClaim = +export type McpExternalToolEffectState = | { - readonly attempt: number; readonly effectId: string; - readonly idempotencyKey: string; - readonly kind: "execute"; + readonly kind: "intent"; } + | McpExternalToolEffectExecution | { readonly effectId: string; - readonly kind: "completed"; + readonly kind: "succeeded"; readonly result: McpExecuteCommandResult; } | { @@ -112,22 +166,53 @@ export type McpExternalToolEffectClaim = readonly kind: "unknown"; }; +export type McpExternalToolEffectClaim = Exclude< + McpExternalToolEffectState, + { readonly kind: "intent" } +>; + +export type McpExternalToolEffectSettlement = + | { + readonly kind: "succeeded"; + readonly providerReceiptJson?: string | null | undefined; + readonly result: McpExecuteCommandResult; + } + | { readonly kind: "unknown" }; + export type RuntimeCommandResult = InputStartCommandResult | McpExecuteCommandResult | null; -export type DriverCapabilityId = - | "custom_tool_execute" - | "file_change" - | "input_start" - | "mcp_execute" - | "native_resume" - | "permission_request" - | "session_stop" - | "text_stream" - | "thinking_stream" - | "tool_stream" - | "turn_cancel" - | "usage" - | "visible_activity"; +export type DriverCommandUpdate = + | { + readonly commandId: string; + readonly status: "accepted" | "cancelled"; + } + | { + readonly commandId: string; + readonly result?: Exclude | undefined; + readonly status: "completed"; + } + | { + readonly commandId: string; + readonly error: RunError; + readonly status: "failed"; + }; + +export const DRIVER_CAPABILITY_IDS = [ + "custom_tool_execute", + "file_change", + "input_start", + "mcp_execute", + "native_resume", + "permission_request", + "session_stop", + "text_stream", + "thinking_stream", + "tool_stream", + "turn_cancel", + "usage", + "visible_activity", +] as const; +export type DriverCapabilityId = (typeof DRIVER_CAPABILITY_IDS)[number]; export interface DriverCapability { readonly details?: string | undefined; @@ -148,6 +233,19 @@ function readRecord(value: unknown, label: string): Record { return value; } +function requireExactKeys( + record: Record, + allowedKeys: readonly string[], + label: string, +): void { + const allowed = new Set(allowedKeys); + const unexpected = Object.keys(record).find((key) => !allowed.has(key)); + + if (unexpected !== undefined) { + throw new TypeError(`${label}.${unexpected} is not allowed.`); + } +} + function readNonEmptyString(record: Record, field: string): string { const value = record[field]; @@ -182,21 +280,52 @@ function readOptionalString(record: Record, field: string): str return value; } +export function normalizeDurableRunError(error: RunError): RunError { + const byteLength = measureRuntimeCommandJson(error); + + return byteLength <= DURABLE_RUN_ERROR_MAX_UTF8_BYTES + ? error + : { + code: "driver.error_oversized", + details: { originalBytes: byteLength }, + message: `Driver error exceeded ${String(DURABLE_RUN_ERROR_MAX_UTF8_BYTES)} UTF-8 bytes and was omitted.`, + retryable: false, + }; +} + +function requireDurableRuntimeCommand( + command: Command, + storedCommand: unknown = command, +): Command { + const byteLength = measureRuntimeCommandJson(storedCommand); + + if (byteLength > RUNTIME_COMMAND_MAX_UTF8_BYTES) { + throw new RangeError( + `Runtime command exceeds ${String(RUNTIME_COMMAND_MAX_UTF8_BYTES)} UTF-8 bytes.`, + ); + } + + return command; +} + function readRuntimeCommandInput(value: unknown): RuntimeCommandInput { const record = readRecord(value, "input"); + requireExactKeys(record, ["attachmentIds", "text"], "input"); const attachmentIds = record["attachmentIds"]; + const text = readNonEmptyString(record, "text"); + + if (attachmentIds === undefined) { + return { text }; + } if ( - attachmentIds !== undefined && - (!Array.isArray(attachmentIds) || - attachmentIds.some((id) => typeof id !== "string" || id.length === 0)) + !Array.isArray(attachmentIds) || + attachmentIds.some((id) => typeof id !== "string" || id.length === 0) ) { throw new TypeError("input.attachmentIds must be an array of non-empty strings."); } - return { - text: readNonEmptyString(record, "text"), - }; + return { attachmentIds: [...attachmentIds], text }; } function readPermissionDecision(value: unknown): PermissionResolveCommand["decision"] { @@ -213,45 +342,77 @@ export function parseRuntimeCommand(value: unknown): RuntimeCommand { switch (kind) { case "input.start": { - return { + requireExactKeys( + record, + ["commandId", "input", "kind", "requestId", "runId"], + "runtime command", + ); + const input = readRuntimeCommandInput(record["input"]); + const command = { commandId: readNonEmptyString(record, "commandId"), - input: readRuntimeCommandInput(record["input"]), + input, kind, requestId: readNonEmptyString(record, "requestId"), runId: readNonEmptyString(record, "runId"), }; + + return requireDurableRuntimeCommand(command); } case "mcp.execute": - return { + requireExactKeys( + record, + [ + "argumentsJson", + "commandId", + "kind", + "requestId", + "runId", + "serverId", + "toolCallId", + "toolName", + ], + "runtime command", + ); + return requireDurableRuntimeCommand({ argumentsJson: readString(record, "argumentsJson"), commandId: readNonEmptyString(record, "commandId"), kind, requestId: readNonEmptyString(record, "requestId"), + runId: readNonEmptyString(record, "runId"), serverId: readNonEmptyString(record, "serverId"), toolCallId: readNonEmptyString(record, "toolCallId"), toolName: readNonEmptyString(record, "toolName"), - }; + }); case "permission.resolve": - return { + requireExactKeys( + record, + ["commandId", "decision", "kind", "requestId", "runId"], + "runtime command", + ); + return requireDurableRuntimeCommand({ commandId: readNonEmptyString(record, "commandId"), decision: readPermissionDecision(record["decision"]), kind, requestId: readNonEmptyString(record, "requestId"), - }; + runId: readNonEmptyString(record, "runId"), + }); case "session.stop": - return { + requireExactKeys(record, ["commandId", "kind", "reason"], "runtime command"); + return requireDurableRuntimeCommand({ commandId: readNonEmptyString(record, "commandId"), kind, reason: readNonEmptyString(record, "reason"), - }; + }); case "turn.cancel": { + requireExactKeys(record, ["commandId", "kind", "reason", "runId"], "runtime command"); const reason = readOptionalString(record, "reason"); - return { + return requireDurableRuntimeCommand({ commandId: readNonEmptyString(record, "commandId"), kind, ...(reason === undefined ? {} : { reason }), - }; + runId: readNonEmptyString(record, "runId"), + }); } default: throw new TypeError(`Unsupported runtime command kind: ${kind}.`); diff --git a/src/runtimes/acp/acp-agent-process.ts b/src/runtimes/acp/acp-agent-process.ts index 75b2db0..4b8af86 100644 --- a/src/runtimes/acp/acp-agent-process.ts +++ b/src/runtimes/acp/acp-agent-process.ts @@ -25,7 +25,7 @@ export type AcpAgentProcess = ChildProcessByStdio; const ACP_AGENT_EXIT_TIMEOUT_MS = 1_500; const ACP_AGENT_FORCE_KILL_TIMEOUT_MS = 500; -const agentProcessCloseTasks = new WeakMap>(); +const agentProcessExitTasks = new WeakMap>(); const agentProcessSupervision = new WeakMap(); interface AcpAgentProcessSupervision { @@ -48,7 +48,10 @@ export async function startAcpAgentProcess( env: Record, signal: AbortSignal, supervisionOptions: AcpAgentProcessSupervisionOptions = {}, -): Promise { +): Promise<{ + readonly process: AcpAgentProcess; + readonly ready: Promise; +}> { const command = supervisionOptions.command ?? readFallbackCommand(); const args = supervisionOptions.args ?? readFallbackArgs(); @@ -88,16 +91,17 @@ export async function startAcpAgentProcess( agentProcessSupervision.set(agentProcess, supervision); const onAbort = () => signalAcpAgentProcess(agentProcess, "SIGKILL"); signal.addEventListener("abort", onAbort, { once: true }); - agentProcessCloseTasks.set( - agentProcess, - new Promise((resolve) => - agentProcess.once("close", () => { - signalLinuxProcessMarker(supervision.marker, "SIGKILL"); - signal.removeEventListener("abort", onAbort); - resolve(); - }), - ), - ); + const exited = Promise.withResolvers(); + const onExit = () => { + agentProcess.off("exit", onExit); + agentProcess.off("close", onExit); + signalLinuxProcessMarker(supervision.marker, "SIGKILL"); + signal.removeEventListener("abort", onAbort); + exited.resolve(); + }; + agentProcess.once("exit", onExit); + agentProcess.once("close", onExit); + agentProcessExitTasks.set(agentProcess, exited.promise); agentProcess.stderr.setEncoding("utf8"); agentProcess.stderr.on("data", (chunk: string) => { @@ -123,14 +127,28 @@ export async function startAcpAgentProcess( }); }); + const ready = waitForAcpAgentProcessReady( + context, + agentProcess, + supervision, + signal, + supervisionOptions.spawnWatchdog, + ); + void ready.catch(() => {}); + + return { process: agentProcess, ready }; +} + +async function waitForAcpAgentProcessReady( + context: AgentDriverContext, + agentProcess: AcpAgentProcess, + supervision: AcpAgentProcessSupervision, + signal: AbortSignal, + spawnWatchdog: typeof spawnLinuxProcessTreeWatchdog = spawnLinuxProcessTreeWatchdog, +): Promise { try { supervision.watchdog = - agentProcess.pid === undefined - ? null - : (supervisionOptions.spawnWatchdog ?? spawnLinuxProcessTreeWatchdog)( - agentProcess.pid, - processTree.marker, - ); + agentProcess.pid === undefined ? null : spawnWatchdog(agentProcess.pid, supervision.marker); if (supervision.watchdog !== null) { void supervision.watchdog.cleanup.then( () => { @@ -165,21 +183,8 @@ export async function startAcpAgentProcess( signal.throwIfAborted(); } catch (error) { signalAcpAgentProcess(agentProcess, "SIGKILL"); - try { - await stopAcpAgentProcess( - context, - agentProcess, - "startup.failed", - Date.now() + ACP_AGENT_EXIT_TIMEOUT_MS + ACP_AGENT_FORCE_KILL_TIMEOUT_MS, - signal, - ); - } catch (cleanupError) { - throw new AggregateError([error, cleanupError], "ACP agent process startup cleanup failed."); - } throw error; } - - return agentProcess; } export async function stopAcpAgentProcess( @@ -233,7 +238,7 @@ function releaseAcpAgentProcessSupervision(agentProcess: AcpAgentProcess): void return; } releaseLinuxProcessMarker(supervision.marker); - agentProcessCloseTasks.delete(agentProcess); + agentProcessExitTasks.delete(agentProcess); agentProcessSupervision.delete(agentProcess); } @@ -317,13 +322,16 @@ async function waitForChildProcessExit( process: AcpAgentProcess, timeoutMs: number, ): Promise { - const closed = agentProcessCloseTasks.get(process); + if (process.exitCode !== null || process.signalCode !== null) { + return true; + } + const exited = agentProcessExitTasks.get(process); - if (closed === undefined) { - return process.exitCode !== null || process.signalCode !== null; + if (exited === undefined) { + return false; } - const result = await settlePromiseWithTimeout(closed, { + const result = await settlePromiseWithTimeout(exited, { label: "ACP agent process exit", timeoutMs, }); @@ -333,7 +341,7 @@ async function waitForChildProcessExit( } if (result.status === "timed_out") { - return false; + return process.exitCode !== null || process.signalCode !== null; } throw result.error; diff --git a/src/runtimes/acp/acp-assistant-transcript-state.ts b/src/runtimes/acp/acp-assistant-transcript-state.ts index 3bf37f6..b3d15dd 100644 --- a/src/runtimes/acp/acp-assistant-transcript-state.ts +++ b/src/runtimes/acp/acp-assistant-transcript-state.ts @@ -1,8 +1,13 @@ import type { StopReason } from "@agentclientprotocol/sdk"; import type { DriverEventInput } from "../../protocol/events"; +import { createDriverId } from "../../protocol/id"; import type { RunId } from "../../protocol/id"; -import { RuntimeAssistantMessageIdIndex } from "../runtime-turn-transcript"; +import { + MAX_RUN_TERMINAL_BATCH_BYTES, + MAX_RUN_TERMINAL_BATCH_EVENTS, +} from "../driver-event-admission"; +import { chunkJsonText } from "../provider-json"; import { toPermissionRequest } from "./acp-permission-events"; import type { AcpPermissionTranslation } from "./acp-permission-events"; import { @@ -16,13 +21,37 @@ import { toUsageEvents, } from "./acp-session-events"; import { AcpToolEventState, toRuntimeToolStatus } from "./acp-tool-events"; -import { isRecord, readNonEmptyString, readRecord, readString } from "./acp-types"; +import { + assertBoundedLosslessEvents, + isRecord, + MAX_ACP_LOSSLESS_EVENT_BYTES, + readNonEmptyString, + readRecord, + readString, +} from "./acp-types"; import type { JsonObject } from "./acp-types"; -export interface AcpTurnEventStateInput { +const MAX_ACP_MESSAGE_EVENT_TEXT_BYTES = MAX_ACP_LOSSLESS_EVENT_BYTES - 1_024; +// An open item can require two terminal closures. Reserve four shared-batch +// slots for message/thought/final envelopes. A retained byte (notably a tool +// ID) can occur in both closures, so reserve one quarter of the byte budget for +// envelopes and admit half of the remaining three quarters. Measuring current +// retained state makes replay/no-op traffic free while keeping every possible +// ACP settlement below the shared 1024-event / 1 MiB hard limit. +const MAX_ACP_RETAINED_TURN_BYTES = (MAX_RUN_TERMINAL_BATCH_BYTES * 3) / 8; +const MAX_ACP_RETAINED_TURN_ITEMS = Math.floor((MAX_RUN_TERMINAL_BATCH_EVENTS - 4) / 2); +// Settled native IDs are only a replay-suppression cache. Bound and evict them +// independently; they cannot produce terminal closures. +const MAX_ACP_SETTLED_MESSAGE_HISTORY_BYTES = MAX_ACP_LOSSLESS_EVENT_BYTES; +const MAX_ACP_SETTLED_MESSAGE_HISTORY_ITEMS = 1_024; + +export interface AcpAssistantTranscriptStateInput { readonly messageId: string; readonly runId: RunId; - readonly sessionId: string; +} + +export class AcpTurnStateLimitError extends RangeError { + override readonly name = "AcpTurnStateLimitError"; } interface AcpAssistantMessageState { @@ -38,14 +67,13 @@ interface AcpAssistantMessageStart { export class AcpAssistantTranscriptState { #activeAssistantMessage: AcpAssistantMessageState | null = null; - readonly #assistantMessageIds = new RuntimeAssistantMessageIdIndex(); - #lastCompletedAssistantMessage: Pick | null = null; + #lastCompletedAssistantMessage: { readonly hasVisibleText: boolean; readonly id: string } | null = + null; #promptMessageId: string | null = null; #runId: RunId | null = null; #sequence = 0; - #sessionId: string | null = null; readonly #settledAssistantNativeMessageIds = new Set(); - #unidentifiedAssistantMessageSequence = 0; + #settledAssistantNativeMessageIdBytes = 0; #thoughtCompleted = false; #thoughtFallbackText = ""; #thoughtFallbackNativeMessageId: string | null = null; @@ -57,34 +85,21 @@ export class AcpAssistantTranscriptState { return this.#runId; } - begin(input: AcpTurnEventStateInput): void { - this.#activeAssistantMessage = null; - this.#assistantMessageIds.reset(); - this.#lastCompletedAssistantMessage = null; + begin(input: AcpAssistantTranscriptStateInput): void { + this.clear(); this.#promptMessageId = input.messageId; this.#runId = input.runId; - this.#sequence = 0; - this.#sessionId = input.sessionId; - this.#settledAssistantNativeMessageIds.clear(); - this.#unidentifiedAssistantMessageSequence = 0; - this.#thoughtCompleted = false; - this.#thoughtFallbackText = ""; - this.#thoughtFallbackNativeMessageId = null; this.#thoughtId = `${input.messageId}:thought`; - this.#thoughtStarted = false; - this.#tools.clear(); } clear(): void { this.#activeAssistantMessage = null; - this.#assistantMessageIds.reset(); this.#lastCompletedAssistantMessage = null; this.#promptMessageId = null; this.#runId = null; this.#sequence = 0; - this.#sessionId = null; this.#settledAssistantNativeMessageIds.clear(); - this.#unidentifiedAssistantMessageSequence = 0; + this.#settledAssistantNativeMessageIdBytes = 0; this.#thoughtCompleted = false; this.#thoughtFallbackText = ""; this.#thoughtFallbackNativeMessageId = null; @@ -93,33 +108,83 @@ export class AcpAssistantTranscriptState { this.#tools.clear(); } - completePrompt(stopReason: StopReason, usage: unknown): DriverEventInput[] { + checkpoint(): () => void { + const activeAssistantMessage = + this.#activeAssistantMessage === null ? null : { ...this.#activeAssistantMessage }; + const lastCompletedAssistantMessage = + this.#lastCompletedAssistantMessage === null + ? null + : { ...this.#lastCompletedAssistantMessage }; + const sequence = this.#sequence; + const settledAssistantNativeMessageIds = new Set(this.#settledAssistantNativeMessageIds); + const settledAssistantNativeMessageIdBytes = this.#settledAssistantNativeMessageIdBytes; + const thoughtCompleted = this.#thoughtCompleted; + const thoughtFallbackNativeMessageId = this.#thoughtFallbackNativeMessageId; + const thoughtFallbackText = this.#thoughtFallbackText; + const thoughtStarted = this.#thoughtStarted; + const restoreTools = this.#tools.checkpoint(); + + return () => { + this.#activeAssistantMessage = activeAssistantMessage; + this.#lastCompletedAssistantMessage = lastCompletedAssistantMessage; + this.#sequence = sequence; + this.#settledAssistantNativeMessageIds.clear(); + settledAssistantNativeMessageIds.forEach((id) => + this.#settledAssistantNativeMessageIds.add(id), + ); + this.#settledAssistantNativeMessageIdBytes = settledAssistantNativeMessageIdBytes; + this.#thoughtCompleted = thoughtCompleted; + this.#thoughtFallbackNativeMessageId = thoughtFallbackNativeMessageId; + this.#thoughtFallbackText = thoughtFallbackText; + this.#thoughtStarted = thoughtStarted; + restoreTools(); + }; + } + + completePrompt( + stopReason: StopReason, + usage: unknown, + requestedBy: "provider" | "user" = "user", + ): DriverEventInput[] { const events: DriverEventInput[] = []; const runId = this.#requireRunId(); + const terminalError = + stopReason === "end_turn" || stopReason === "cancelled" + ? null + : `ACP prompt stopped with ${stopReason}.`; events.push(...this.#promoteThought()); - events.push(...this.#finishMessage()); + events.push( + ...(stopReason === "end_turn" + ? this.#finishMessage() + : this.#finishMessageWithOutcome(stopReason, terminalError)), + ); if (this.#thoughtStarted && !this.#thoughtCompleted) { this.#thoughtCompleted = true; events.push({ - kind: "thought.completed", + kind: stopReason === "end_turn" ? "thought.completed" : "thought.cancelled", payload: { channel: "summary", + ...(stopReason === "end_turn" + ? {} + : { reason: terminalError ?? "cancelled", stopReason }), thoughtId: this.#requireThoughtId(), }, runId, }); } - const toolStatus = stopReason === "cancelled" ? "failed" : "completed"; - const toolError = - stopReason === "cancelled" ? "Turn cancelled before tool completion." : undefined; events.push( ...this.#tools.completeOpen({ + ...(terminalError === null ? {} : { error: terminalError }), runId, - status: toolStatus, - ...(toolError === undefined ? {} : { error: toolError }), + status: + stopReason === "end_turn" + ? "completed" + : stopReason === "cancelled" + ? "cancelled" + : "failed", }), ); @@ -136,18 +201,32 @@ export class AcpAssistantTranscriptState { const finalMessage = this.#lastCompletedAssistantMessage; const emptyTurn = stopReason === "end_turn" && - (finalMessage === null || finalMessage.text.trim().length === 0) && + (finalMessage === null || !finalMessage.hasVisibleText) && !this.#tools.hasActivity(); if (stopReason === "cancelled") { events.push({ kind: "run.cancelled", payload: { - requestedBy: "user", + requestedBy, stopReason: "cancelled", }, runId, }); + } else if (terminalError !== null) { + events.push({ + kind: "run.failed", + payload: { + error: { + code: `acp.${stopReason}`, + message: terminalError, + retryable: false, + }, + recoverable: false, + stopReason, + }, + runId, + }); } else if (emptyTurn) { events.push({ kind: "run.failed", @@ -155,6 +234,7 @@ export class AcpAssistantTranscriptState { error: { code: "acp.empty_turn", message: "ACP prompt ended without assistant output or tool activity.", + retryable: true, }, recoverable: true, stopReason, @@ -165,11 +245,10 @@ export class AcpAssistantTranscriptState { events.push({ kind: "run.completed", payload: { - ...(finalMessage === null + ...(finalMessage === null || !finalMessage.hasVisibleText ? {} : { finalMessageId: finalMessage.id, - finalMessageText: finalMessage.text, }), stopReason, }, @@ -177,8 +256,7 @@ export class AcpAssistantTranscriptState { }); } - this.clear(); - return events; + return assertBoundedLosslessEvents(events); } failPrompt(error: { code: string; message: string; recoverable?: boolean }): DriverEventInput[] { @@ -189,16 +267,23 @@ export class AcpAssistantTranscriptState { return []; } + const originalMessageUtf8Bytes = Buffer.byteLength(error.message, "utf8"); + const message = + originalMessageUtf8Bytes <= 16 * 1_024 + ? error.message + : `ACP failure exceeded durable event capacity (originalMessageUtf8Bytes=${originalMessageUtf8Bytes}).`; const events: DriverEventInput[] = []; const thoughtId = this.#thoughtId; - events.push(...this.#finishMessage()); + events.push(...this.#failMessage({ ...error, message })); if (this.#thoughtStarted && !this.#thoughtCompleted && thoughtId !== null) { + this.#thoughtCompleted = true; events.push({ - kind: "thought.completed", + kind: "thought.cancelled", payload: { channel: "summary", + reason: message, thoughtId, }, runId, @@ -207,7 +292,6 @@ export class AcpAssistantTranscriptState { events.push( ...this.#tools.completeOpen({ - error: error.message, runId, status: "failed", }), @@ -218,108 +302,153 @@ export class AcpAssistantTranscriptState { payload: { error: { code: error.code, - message: error.message, + ...(message === error.message ? {} : { details: { originalMessageUtf8Bytes } }), + message, + retryable: error.recoverable ?? false, }, recoverable: error.recoverable ?? false, }, runId, }); - this.clear(); - return events; + return assertBoundedLosslessEvents(events); } translateUpdate(params: unknown): DriverEventInput[] { const record = isRecord(params) ? params : {}; const update = readRecord(record, "update"); const sessionUpdate = readString(update, "sessionUpdate"); - - switch (sessionUpdate) { - case "agent_message_chunk": { - return this.#messageChunk(update); - } - case "agent_thought_chunk": { - return this.#thoughtChunk(update); - } - case "available_commands_update": { - return toCommandEvents(update); - } - case "config_option_update": { - return toConfigEvents(update); - } - case "current_mode_update": { - return toModeEvents(update); - } - case "plan": { - return toPlanEvents(update); - } - case "session_info_update": { - return toInfoEvents(update); - } - case "tool_call": - case "tool_call_update": { - return this.#tool(update, sessionUpdate); - } - case "usage_update": { - return toUsageEvents(update); - } - case "user_message_chunk": - case undefined: - case null: { - return []; - } - default: { - return [ - { - kind: "diagnostic.reported", - payload: { - message: `Unsupported ACP session update: ${sessionUpdate}.`, - raw: update, - severity: "info", + const retainsTurnState = + this.#runId !== null && + (sessionUpdate === "agent_message_chunk" || + sessionUpdate === "agent_thought_chunk" || + sessionUpdate === "tool_call" || + sessionUpdate === "tool_call_update"); + const restore = retainsTurnState ? this.checkpoint() : null; + + try { + let events: DriverEventInput[]; + + switch (sessionUpdate) { + case "agent_message_chunk": { + events = this.#messageChunk(update); + break; + } + case "agent_thought_chunk": { + events = this.#thoughtChunk(update); + break; + } + case "available_commands_update": { + events = toCommandEvents(update); + break; + } + case "config_option_update": { + events = toConfigEvents(update); + break; + } + case "current_mode_update": { + events = toModeEvents(update); + break; + } + case "plan": { + events = toPlanEvents(update); + break; + } + case "session_info_update": { + events = toInfoEvents(update); + break; + } + case "tool_call": + case "tool_call_update": { + events = this.#tool(update, sessionUpdate); + break; + } + case "usage_update": { + events = toUsageEvents(update); + break; + } + case "user_message_chunk": + case undefined: + case null: { + events = []; + break; + } + default: { + events = [ + { + delivery: "best_effort", + kind: "diagnostic.reported", + payload: { + message: `Unsupported ACP session update: ${sessionUpdate}.`, + raw: update, + severity: "info", + }, + visibility: "owner_debug", }, - visibility: "owner_debug", - }, - ]; + ]; + break; + } } + + if (retainsTurnState) { + this.#assertRetainedTurnState(); + } + return assertBoundedLosslessEvents(events); + } catch (error) { + restore?.(); + throw error; } } translatePermission(input: { params: unknown; requestId: string }): AcpPermissionTranslation { const runId = this.activeRunId(); - const translation = toPermissionRequest({ - params: input.params, - requestId: input.requestId, - runId, - }); + const restore = runId === null ? null : this.checkpoint(); - if (runId === null || translation.toolCall === null) { - return translation; - } + try { + const translation = toPermissionRequest({ + params: input.params, + requestId: input.requestId, + runId, + }); - this.#tools.patch({ - status: toRuntimeToolStatus(readString(translation.toolCall, "status")), - toolCallId: translation.targetItemId, - update: translation.toolCall, - }); + if (runId === null || translation.toolCall === null) { + return { + ...translation, + events: assertBoundedLosslessEvents(translation.events), + }; + } - return { - ...translation, - events: [ - ...this.#ensureToolParentMessage(translation.targetItemId), + const parentStart = this.#ensureToolParentMessage(translation.targetItemId); + this.#tools.patch({ + status: toRuntimeToolStatus(readString(translation.toolCall, "status")), + toolCallId: translation.targetItemId, + update: translation.toolCall, + }); + const events = [ + ...parentStart, ...this.#tools.ensureStarted({ parentMessageId: this.#toolParentMessageId(), runId, - title: translation.title, + title: translation.request.title, toolCallId: translation.targetItemId, }), ...translation.events, - ], - }; + ]; + this.#assertRetainedTurnState(); + + return { + ...translation, + events: assertBoundedLosslessEvents(events), + }; + } catch (error) { + restore?.(); + throw error; + } } #nextEventId(kind: string): string { this.#sequence += 1; - return `acp:${this.#sessionId ?? "session"}:${this.#runId ?? "run"}:${kind}:${this.#sequence}`; + return `acp:${this.#runId ?? "run"}:${kind}:${this.#sequence}`; } #messageChunk(update: JsonObject | null): DriverEventInput[] { @@ -494,24 +623,7 @@ export class AcpAssistantTranscriptState { } started.message.text += contentDelta; - return [ - ...started.events, - { - delivery: "best_effort", - kind: "message.delta", - payload: { - contentBlock: { - text: contentDelta, - type: "text", - }, - contentDelta, - messageId: started.message.id, - role: "agent", - }, - runId: this.#requireRunId(), - sourceEventId: this.#nextEventId("agent-thought-fallback-message"), - }, - ]; + return started.events; } #clearThoughtFallback(): void { @@ -529,10 +641,10 @@ export class AcpAssistantTranscriptState { this.#activeAssistantMessage = null; if (message.nativeMessageId !== null) { - this.#settledAssistantNativeMessageIds.add(message.nativeMessageId); + this.#rememberSettledAssistantMessage(message.nativeMessageId); this.#lastCompletedAssistantMessage = { + hasVisibleText: message.text.trim().length > 0, id: message.id, - text: message.text, }; } else { // ACP v1 does not give anonymous chunks a stable message boundary. If @@ -541,7 +653,36 @@ export class AcpAssistantTranscriptState { this.#lastCompletedAssistantMessage = null; } + const chunks = + message.text.length === 0 + ? [] + : chunkJsonText(message.text, MAX_ACP_MESSAGE_EVENT_TEXT_BYTES); + return [ + ...(chunks.length === 0 + ? [] + : [ + { + delivery: "lossless" as const, + kind: "message.added" as const, + payload: { + content: chunks[0]!, + messageId: message.id, + role: "agent", + }, + runId: this.#requireRunId(), + }, + ]), + ...chunks.slice(1).map((contentDelta): DriverEventInput => ({ + delivery: "lossless", + kind: "message.delta", + payload: { + contentDelta, + messageId: message.id, + role: "agent", + }, + runId: this.#requireRunId(), + })), { kind: "message.completed", payload: { @@ -553,51 +694,106 @@ export class AcpAssistantTranscriptState { ]; } - #startMessage(nativeMessageId: string): AcpAssistantMessageStart | null { - if (this.#settledAssistantNativeMessageIds.has(nativeMessageId)) { - return null; + #finishMessageWithOutcome(stopReason: StopReason, error: string | null): DriverEventInput[] { + const message = this.#activeAssistantMessage; + + if (message === null) { + return []; } - const active = this.#activeAssistantMessage; + this.#activeAssistantMessage = null; + this.#lastCompletedAssistantMessage = null; - if (active?.nativeMessageId === nativeMessageId) { - return { events: [], message: active }; + if (message.nativeMessageId !== null) { + this.#rememberSettledAssistantMessage(message.nativeMessageId); } - const events = this.#finishMessage(); - const message: AcpAssistantMessageState = { - id: this.#assistantMessageIds.getOrCreate(`native:${nativeMessageId}`), - nativeMessageId, - text: "", - }; + if (stopReason === "cancelled") { + return [ + { + kind: "message.cancelled", + payload: { messageId: message.id, reason: "cancelled", role: "agent", stopReason }, + runId: this.#requireRunId(), + }, + ]; + } - this.#activeAssistantMessage = message; - this.#clearThoughtFallback(); - events.push({ - kind: "message.started", - payload: { - messageId: message.id, - role: "agent", + return [ + { + kind: "message.failed", + payload: { + error: { + code: `acp.${stopReason}`, + message: error ?? `ACP prompt stopped with ${stopReason}.`, + retryable: false, + }, + messageId: message.id, + role: "agent", + stopReason, + }, + runId: this.#requireRunId(), }, - runId: this.#requireRunId(), - }); - return { events, message }; + ]; + } + + #failMessage(error: { + readonly code: string; + readonly message: string; + readonly recoverable?: boolean; + }): DriverEventInput[] { + const message = this.#activeAssistantMessage; + + if (message === null) { + return []; + } + + this.#activeAssistantMessage = null; + this.#lastCompletedAssistantMessage = null; + + if (message.nativeMessageId !== null) { + this.#rememberSettledAssistantMessage(message.nativeMessageId); + } + + return [ + { + kind: "message.failed", + payload: { + error: { + code: error.code, + message: error.message, + retryable: error.recoverable ?? false, + }, + messageId: message.id, + role: "agent", + }, + runId: this.#requireRunId(), + }, + ]; + } + + #startMessage(nativeMessageId: string): AcpAssistantMessageStart | null { + if (this.#settledAssistantNativeMessageIds.has(nativeMessageId)) { + return null; + } + + return this.#startUnsettledMessage(nativeMessageId); } #startAnonymousMessage(): AcpAssistantMessageStart { + return this.#startUnsettledMessage(null); + } + + #startUnsettledMessage(nativeMessageId: string | null): AcpAssistantMessageStart { const active = this.#activeAssistantMessage; - if (active?.nativeMessageId === null) { + if (active?.nativeMessageId === nativeMessageId) { return { events: [], message: active }; } const events = this.#finishMessage(); - this.#unidentifiedAssistantMessageSequence += 1; const message: AcpAssistantMessageState = { - id: this.#assistantMessageIds.getOrCreate( - `fallback:unidentified:${this.#unidentifiedAssistantMessageSequence}`, - ), - nativeMessageId: null, + id: createDriverId(), + nativeMessageId, text: "", }; @@ -652,6 +848,66 @@ export class AcpAssistantTranscriptState { return this.#activeAssistantMessage?.id ?? this.#promptMessageId ?? undefined; } + #assertRetainedTurnState(): void { + this.#tools.compactHistory(); + const assistantItems = + (this.#activeAssistantMessage === null ? 0 : 1) + + (this.#thoughtFallbackNativeMessageId === null ? 0 : 1); + const itemCount = assistantItems + this.#tools.openItemCount(); + + if (itemCount > MAX_ACP_RETAINED_TURN_ITEMS) { + throw new AcpTurnStateLimitError( + `ACP turn state exceeds ${MAX_ACP_RETAINED_TURN_ITEMS} retained open items.`, + ); + } + + const retainedState = { + activeAssistantMessage: this.#activeAssistantMessage, + thoughtFallback: + this.#thoughtFallbackNativeMessageId === null + ? null + : { + nativeMessageId: this.#thoughtFallbackNativeMessageId, + text: this.#thoughtFallbackText, + }, + tools: this.#tools.retainedOpenState(), + }; + const bytes = Buffer.byteLength(JSON.stringify(retainedState), "utf8"); + + if (bytes > MAX_ACP_RETAINED_TURN_BYTES) { + throw new AcpTurnStateLimitError( + `ACP turn state exceeds ${MAX_ACP_RETAINED_TURN_BYTES} retained UTF-8 bytes.`, + ); + } + } + + #rememberSettledAssistantMessage(nativeMessageId: string): void { + if (this.#settledAssistantNativeMessageIds.has(nativeMessageId)) { + return; + } + + this.#settledAssistantNativeMessageIds.add(nativeMessageId); + this.#settledAssistantNativeMessageIdBytes += Buffer.byteLength( + JSON.stringify(nativeMessageId), + "utf8", + ); + + while ( + this.#settledAssistantNativeMessageIds.size > MAX_ACP_SETTLED_MESSAGE_HISTORY_ITEMS || + this.#settledAssistantNativeMessageIdBytes > MAX_ACP_SETTLED_MESSAGE_HISTORY_BYTES + ) { + const oldest = this.#settledAssistantNativeMessageIds.values().next().value; + if (oldest === undefined) { + break; + } + this.#settledAssistantNativeMessageIds.delete(oldest); + this.#settledAssistantNativeMessageIdBytes -= Buffer.byteLength( + JSON.stringify(oldest), + "utf8", + ); + } + } + // Tool calls must be parented to an assistant message: the session event // projection drops tool starts without a parent, and the prompt message id // would attach them to the user's bubble. ACP agents may open a tool call diff --git a/src/runtimes/acp/acp-client-request-handler.ts b/src/runtimes/acp/acp-client-request-handler.ts index 7c6656e..14225f0 100644 --- a/src/runtimes/acp/acp-client-request-handler.ts +++ b/src/runtimes/acp/acp-client-request-handler.ts @@ -21,13 +21,17 @@ import type { import type { DriverEventInput } from "../../protocol/events"; import type { AgentDriverContext } from "../../core/agent-driver-backend"; -import { shouldIgnoreReplay } from "./acp-event-translator"; -import type { AcpPermissionOption, AcpTurnEventState } from "./acp-event-translator"; +import { + AcpTurnStateLimitError, + type AcpAssistantTranscriptState, +} from "./acp-assistant-transcript-state"; import { AcpFileSystem } from "./acp-file-system"; import { AcpPathScope } from "./acp-path-scope"; +import type { AcpPermissionOption, AcpPermissionTranslation } from "./acp-permission-events"; +import { isTurnScopedSessionUpdate } from "./acp-session-events"; import { AcpSessionUpdateInbox, type AcpSessionUpdateScope } from "./acp-session-update-inbox"; import { AcpTerminalManager } from "./acp-terminal-manager"; -import { isRecord, raceWithAbort, readNonEmptyString, stringifyForDisplay } from "./acp-types"; +import { isRecord, raceWithAbort, readNonEmptyString } from "./acp-types"; interface AcpClientRequestHandlerOptions { readonly allowedRoots: readonly string[]; @@ -37,41 +41,57 @@ interface AcpClientRequestHandlerOptions { nativeSessionId(): string | null; onUpdateFailure(error: Error): void; push(context: AgentDriverContext, reason: string, events: DriverEventInput[]): Promise; - readonly turnEvents: AcpTurnEventState; + readonly turnEvents: AcpAssistantTranscriptState; +} + +interface AcpFileWrite { + failure: { readonly error: unknown } | null; + readonly task: Promise; +} + +interface AcpTurnFileWrites { + ingressClosed: boolean; + readonly writes: Set; } export class AcpClientRequestHandler { + readonly #activeFileWrites = new Set(); readonly #fileSystem: AcpFileSystem; readonly #isCancelling: () => boolean; readonly #nativeSessionId: () => string | null; + readonly #onUpdateFailure: (error: Error) => void; + readonly #pathScope: AcpPathScope; readonly #pendingPermissions = new Set>(); readonly #push: AcpClientRequestHandlerOptions["push"]; #permissionIngressClosed = false; #stopping = false; - #turnUpdateIngressClosed = false; + #transcriptTail: Promise = Promise.resolve(); + #turnFileWrites: AcpTurnFileWrites | null = null; + #turnTranscriptIngressClosed = false; readonly #updateInbox: AcpSessionUpdateInbox; readonly #terminalManager: AcpTerminalManager; - readonly #turnEvents: AcpTurnEventState; + readonly #turnEvents: AcpAssistantTranscriptState; constructor(options: AcpClientRequestHandlerOptions) { this.#isCancelling = options.isCancelling; this.#nativeSessionId = options.nativeSessionId; + this.#onUpdateFailure = options.onUpdateFailure; this.#push = options.push; this.#turnEvents = options.turnEvents; - const pathScope = new AcpPathScope({ + this.#pathScope = new AcpPathScope({ allowedRoots: options.allowedRoots, cwd: options.cwd, }); this.#fileSystem = new AcpFileSystem({ allowedRoots: options.allowedRoots, cwd: options.cwd, - pathScope, + pathScope: this.#pathScope, }); this.#terminalManager = new AcpTerminalManager({ allowedRoots: options.allowedRoots, cwd: options.cwd, env: options.env, - pathScope, + pathScope: this.#pathScope, push: options.push, }); this.#updateInbox = new AcpSessionUpdateInbox({ @@ -80,8 +100,16 @@ export class AcpClientRequestHandler { }); } + initializePathScope(): Promise { + return this.#pathScope.initialize(); + } + + closePathScope(): Promise { + return this.#pathScope.close(); + } + enqueueUpdate(context: AgentDriverContext, notification: SessionNotification): Promise { - if (this.#turnUpdateIngressClosed) { + if (this.#turnTranscriptIngressClosed && isTurnScopedSessionUpdate(notification)) { return Promise.resolve(); } @@ -106,7 +134,73 @@ export class AcpClientRequestHandler { signal?: AbortSignal, ): Promise { this.#assertSession("fs/write_text_file", params); - return this.#fileSystem.writeTextFile(context, params, signal); + if (this.#stopping) { + throw new Error("ACP client request handler is stopping."); + } + const turn = this.#turnFileWrites; + if (turn?.ingressClosed) { + throw new Error("ACP file write ingress is closed for the active turn."); + } + + let committed = false; + const operation = this.#fileSystem.writeTextFile(context, params, signal, () => { + committed = true; + }); + let write!: AcpFileWrite; + const task = operation.catch((error: unknown) => { + if (committed) { + write.failure = { error }; + this.#onUpdateFailure( + error instanceof Error + ? error + : new Error("ACP committed file report failed.", { cause: error }), + ); + } + throw error; + }); + write = { failure: null, task }; + this.#activeFileWrites.add(write); + turn?.writes.add(write); + + return task; + } + + beginStop(): void { + this.#stopping = true; + this.closeFileWriteIngress(); + } + + async drainFileWrites(signal?: AbortSignal): Promise { + await this.#drainFileWrites(this.#activeFileWrites, signal); + } + + openFileWriteIngress(): void { + if (this.#stopping) { + throw new Error("ACP client request handler is stopping."); + } + if ( + this.#turnFileWrites !== null && + (!this.#turnFileWrites.ingressClosed || this.#turnFileWrites.writes.size > 0) + ) { + throw new Error("ACP previous turn file writes have not drained."); + } + + this.#turnFileWrites = { ingressClosed: false, writes: new Set() }; + } + + closeFileWriteIngress(): void { + if (this.#turnFileWrites !== null) { + this.#turnFileWrites.ingressClosed = true; + } + } + + async drainTurnFileWrites(signal?: AbortSignal): Promise { + const turn = this.#turnFileWrites; + if (turn === null) { + return; + } + + await this.#drainFileWrites(turn.writes, signal); } async requestPermission( @@ -174,6 +268,10 @@ export class AcpClientRequestHandler { return this.#terminalManager.create(context, params, signal); } + beginTurnTerminals(): number { + return this.#terminalManager.beginTurn(); + } + async killTerminal( context: AgentDriverContext, params: KillTerminalRequest, @@ -210,6 +308,10 @@ export class AcpClientRequestHandler { await this.#terminalManager.stopAll(context); } + async stopTurnTerminals(context: AgentDriverContext, turn: number): Promise { + await this.#terminalManager.stopTurn(context, turn); + } + async closeUpdates(): Promise { this.#stopping = true; await this.#updateInbox.close(); @@ -219,8 +321,8 @@ export class AcpClientRequestHandler { this.#permissionIngressClosed = true; } - closeTurnUpdateIngress(): void { - this.#turnUpdateIngressClosed = true; + closeTurnTranscriptIngress(): void { + this.#turnTranscriptIngressClosed = true; } async drainUpdates(): Promise { @@ -246,8 +348,8 @@ export class AcpClientRequestHandler { this.#permissionIngressClosed = false; } - openTurnUpdateIngress(): void { - this.#turnUpdateIngressClosed = false; + openTurnTranscriptIngress(): void { + this.#turnTranscriptIngressClosed = false; } async withSessionReplay(operation: () => Promise): Promise { @@ -268,6 +370,23 @@ export class AcpClientRequestHandler { ); } + async #drainFileWrites(writes: Set, signal?: AbortSignal): Promise { + while (writes.size > 0) { + const pending = [...writes]; + await raceWithAbort(Promise.allSettled(pending.map(({ task }) => task)), signal); + for (const write of pending) { + writes.delete(write); + this.#activeFileWrites.delete(write); + this.#turnFileWrites?.writes.delete(write); + } + const failure = pending.find(({ failure }) => failure !== null)?.failure; + + if (failure !== null && failure !== undefined) { + throw failure.error; + } + } + } + async #requestPermission( context: AgentDriverContext, requestId: string, @@ -279,25 +398,27 @@ export class AcpClientRequestHandler { return { outcome: { outcome: "cancelled" } }; } - const translation = this.#turnEvents.translatePermission({ - params, - requestId, + const translation = await raceWithAbort( + this.#withTranscriptTransaction(async () => { + signal?.throwIfAborted(); + const translated = this.#turnEvents.translatePermission({ params, requestId }); + if (translated.events.length > 0) { + await track(this.#push(context, "driver.acp.permission.tool", translated.events)); + } + return translated; + }), + signal, + ).catch((error: unknown) => { + if (error instanceof AcpTurnStateLimitError) { + this.#onUpdateFailure(error); + } + throw error; }); - const toolEvents = translation.events.filter((event) => event.kind !== "permission.requested"); - - if (toolEvents.length > 0) { - await raceWithAbort( - track(this.#push(context, "driver.acp.permission.tool", toolEvents)), - signal, - ); - } let chosen: AcpPermissionOption | null = null; if (!this.#isCancelling() && !signal?.aborted) { - const permission = track( - this.#resolvePermission(context, requestId, translation.options, params, signal), - ); + const permission = track(this.#resolvePermission(context, translation, signal)); chosen = await raceWithAbort(permission, signal); } const resolvedOption = this.#isCancelling() || signal?.aborted ? null : chosen; @@ -321,24 +442,40 @@ export class AcpClientRequestHandler { ): Promise { this.#assertSession("session/update", params); - if (scope.suppressed && shouldIgnoreReplay(params)) { - return; - } - if ( - (scope.replaying || this.#turnEvents.activeRunId() === null) && - shouldIgnoreReplay(params) + (scope.suppressed || scope.replaying || this.#turnEvents.activeRunId() === null) && + isTurnScopedSessionUpdate(params) ) { return; } - const events = this.#turnEvents.translateUpdate(params); + await this.#withTranscriptTransaction(async () => { + const events = this.#turnEvents.translateUpdate(params); - if (events.length === 0) { - return; - } + if (events.length === 0) { + return; + } + + await this.#push(context, "driver.acp.session.update", events); + }); + } + + #withTranscriptTransaction(operation: () => Promise): Promise { + const transaction = this.#transcriptTail.then(async () => { + const restore = this.#turnEvents.checkpoint(); - await this.#push(context, "driver.acp.session.update", events); + try { + return await operation(); + } catch (error) { + restore(); + throw error; + } + }); + this.#transcriptTail = transaction.then( + () => {}, + () => {}, + ); + return transaction; } #assertSession(method: string, params: unknown): void { @@ -357,33 +494,17 @@ export class AcpClientRequestHandler { async #resolvePermission( context: AgentDriverContext, - requestId: string, - options: readonly AcpPermissionOption[], - params: unknown, + translation: AcpPermissionTranslation, signal?: AbortSignal, ): Promise { - const allow = options.find((option) => option.kind === "allow_once") ?? null; - const reject = options.find((option) => option.kind === "reject_once") ?? null; + const allow = translation.options.find((option) => option.kind === "allow_once") ?? null; + const reject = translation.options.find((option) => option.kind === "reject_once") ?? null; if (allow === null && reject === null) { return null; } - const record = isRecord(params) ? params : {}; - const toolCall = isRecord(record["toolCall"]) ? record["toolCall"] : {}; - const decision = await context.ports.permission.request( - { - rawInput: stringifyForDisplay(toolCall["rawInput"]), - requestId, - title: - readNonEmptyString(toolCall, "title") ?? - readNonEmptyString(toolCall, "kind") ?? - "Allow tool call?", - toolCallId: readNonEmptyString(toolCall, "toolCallId"), - toolKind: readNonEmptyString(toolCall, "kind"), - }, - signal, - ); + const decision = await context.ports.permission.request(translation.request, signal); return decision === "allow_once" ? allow : reject; } diff --git a/src/runtimes/acp/acp-configuration.ts b/src/runtimes/acp/acp-configuration.ts index b09ac60..029e6c6 100644 --- a/src/runtimes/acp/acp-configuration.ts +++ b/src/runtimes/acp/acp-configuration.ts @@ -1,5 +1,10 @@ import { PROTOCOL_VERSION } from "@agentclientprotocol/sdk"; -import type { AgentCapabilities, ClientCapabilities, McpServer } from "@agentclientprotocol/sdk"; +import type { + AgentCapabilities, + AuthMethod, + ClientCapabilities, + McpServer, +} from "@agentclientprotocol/sdk"; import { basename } from "node:path"; import type { DriverExecutionSessionContext } from "../../protocol/boot"; @@ -207,7 +212,7 @@ export function readResumeId(payload: DriverStartInput): string | null { } export function resolveAuthMethod( - authMethods: readonly { readonly id: string }[], + authMethods: readonly AuthMethod[], env: Record, ): string | null { const requestedMethodId = env["MOSOO_ACP_AUTH_METHOD_ID"]?.trim(); @@ -216,11 +221,17 @@ export function resolveAuthMethod( return null; } - if (authMethods.some((method) => method.id === requestedMethodId)) { - return requestedMethodId; + const method = authMethods.find((candidate) => candidate.id === requestedMethodId); + + if (method === undefined) { + throw new Error(`Configured ACP auth method is not advertised: ${requestedMethodId}.`); + } + + if ("type" in method && method.type === "terminal") { + throw new Error(`Configured ACP auth method requires unsupported terminal auth: ${method.id}.`); } - throw new Error(`Configured ACP auth method is not advertised: ${requestedMethodId}.`); + return method.id; } export function toRequestMeta(input: { diff --git a/src/runtimes/acp/acp-driver-backend.ts b/src/runtimes/acp/acp-driver-backend.ts index c00dc95..3823b1e 100644 --- a/src/runtimes/acp/acp-driver-backend.ts +++ b/src/runtimes/acp/acp-driver-backend.ts @@ -14,7 +14,6 @@ import { Readable, Writable } from "node:stream"; import { summarizePath, summarizePathCollection } from "../../observability/driver-debug"; import type { DriverEventInput } from "../../protocol/events"; -import type { DriverHostIntegrationSnapshot } from "../../protocol/host-integration"; import type { RunId } from "../../protocol/id"; import type { DriverRuntime } from "../../protocol/runtime"; import type { DriverStartInput } from "../../protocol/start"; @@ -26,10 +25,10 @@ import { DriverEventPublisher } from "../driver-event-publisher"; import { buildRuntimeBootstrapText, computeRuntimeBootstrapDigest, + exposeNativeSkillAliases, writeNativeRuntimeSystemPrompt, writeSkillBootstrapArtifacts, } from "../skill-bootstrap"; -import { exposeNativeSkillAliases } from "../skill-materialization"; import { startAcpAgentProcess, stopAcpAgentProcess } from "./acp-agent-process"; import type { AcpAgentProcess } from "./acp-agent-process"; import { AcpClientRequestHandler } from "./acp-client-request-handler"; @@ -49,7 +48,7 @@ import { supportsSessionLoad, supportsSessionResume, } from "./acp-configuration"; -import { toAuthEvent, toInitializeEvents, toSessionReadyEvents } from "./acp-event-translator"; +import { toAuthEvent, toInitializeEvents, toSessionReadyEvents } from "./acp-session-events"; import { setupAcpSession } from "./acp-session-setup"; import { withAcpStartupStage } from "./acp-startup"; import { AcpTurnController } from "./acp-turn-controller"; @@ -58,6 +57,7 @@ const ACP_STOP_BUDGET_MS = 4_000; const ACP_RECYCLE_BUDGET_MS = 4_500; const ACP_SESSION_SHUTDOWN_TIMEOUT_MS = 750; const ACP_UPDATE_DRAIN_TIMEOUT_MS = 750; +const MAX_ACTIVE_ACP_CLIENT_REQUESTS = 8; function toErrorMessage(error: unknown, fallback: string): string { return error instanceof Error ? error.message : fallback; @@ -70,18 +70,21 @@ export class AcpDriverBackend implements AgentDriverBackend { #agentCapabilities: AgentCapabilities | null = null; #agentLaunch: { readonly args: readonly string[]; readonly command: string } | null = null; #agentProcess: AcpAgentProcess | null = null; + #agentProcessStop: { readonly process: AcpAgentProcess; readonly task: Promise } | null = + null; readonly #childProcessEnv: Record; readonly #clientRequests: AcpClientRequestHandler; #connection: ClientConnection | null = null; readonly #eventPublisher = new DriverEventPublisher(this.runtime, () => this.#nativeSessionId); - #hostSnapshot: DriverHostIntegrationSnapshot | null = null; #nativeSessionId: string | null = null; #nativeInstructionPath: string | null = null; readonly #payload: DriverStartInput; readonly #runtimeBootstrapDigest: string | null; readonly #runtimeBootstrapText: string; #started = false; + #stopFailure: { readonly error: unknown } | null = null; #stopRequested = false; + #stopSucceeded = false; #stopTask: Promise | null = null; readonly #turnController: AcpTurnController; @@ -93,7 +96,10 @@ export class AcpDriverBackend implements AgentDriverBackend { this.#runtimeBootstrapText = buildRuntimeBootstrapText(payload.execution); this.#turnController = new AcpTurnController( (context, reason, events) => this.#push(context, reason, events), - (context) => this.#recycleCancelledTurn(context), + (context, providerPromptAdmitted, resumeSignal) => + this.#settleCancelledTurn(context, providerPromptAdmitted, resumeSignal), + (context, reason, closures, terminal, cancellationSignal) => + this.#eventPublisher.pushTerminal(context, reason, closures, terminal, cancellationSignal), ); this.#clientRequests = new AcpClientRequestHandler({ allowedRoots: payload.execution.session.additionalDirectories, @@ -113,41 +119,33 @@ export class AcpDriverBackend implements AgentDriverBackend { throw new Error("ACP driver backend cannot restart after stopping."); } - const hostSnapshot = await raceWithAbort(context.ports.hostIntegration.snapshot(), signal); - - if (hostSnapshot === null) { - throw new Error("ACP fallback requires a host integration snapshot."); - } - - this.#hostSnapshot = hostSnapshot; - const materializedSkills = await raceWithAbort( - context.ports.skill.materialize(this.#payload.execution), - signal, - ); - const nativeSkillAliases = await raceWithAbort( - exposeNativeSkillAliases(this.#payload.execution, context.logger, materializedSkills), - signal, - ); - const bootstrapArtifacts = await raceWithAbort( - writeSkillBootstrapArtifacts(this.#payload.execution), - signal, - ); - const launch = (this.#agentLaunch ??= { - args: readFallbackArgs(), - command: readFallbackCommand(), - }); - this.#nativeInstructionPath = isOpenCodeCommand(launch.command) - ? await raceWithAbort(writeNativeRuntimeSystemPrompt(this.#payload.execution), signal) - : null; - - if (this.#stopRequested || signal.aborted) { - signal.throwIfAborted(); - throw new Error("ACP driver backend stopped during startup."); - } - try { - signal.throwIfAborted(); - if (this.#stopRequested) { + await raceWithAbort(this.#clientRequests.initializePathScope(), signal); + const materializedSkills = await context.ports.skill.materialize( + this.#payload.execution, + signal, + ); + const nativeSkillAliases = await exposeNativeSkillAliases( + this.#payload.execution, + context.logger, + materializedSkills, + signal, + ); + const bootstrapArtifacts = await writeSkillBootstrapArtifacts( + this.#payload.execution, + materializedSkills, + signal, + ); + const launch = (this.#agentLaunch ??= { + args: readFallbackArgs(), + command: readFallbackCommand(), + }); + this.#nativeInstructionPath = isOpenCodeCommand(launch.command) + ? await writeNativeRuntimeSystemPrompt(this.#payload.execution, materializedSkills, signal) + : null; + + if (this.#stopRequested || signal.aborted) { + signal.throwIfAborted(); throw new Error("ACP driver backend stopped during startup."); } @@ -188,7 +186,6 @@ export class AcpDriverBackend implements AgentDriverBackend { this.#connection?.close( signal.reason instanceof Error ? signal.reason : new Error("ACP startup aborted."), ); - signal.throwIfAborted(); } await this.stop(context, "startup.failed", signal).catch((cleanupError: unknown) => { @@ -196,6 +193,7 @@ export class AcpDriverBackend implements AgentDriverBackend { message: toErrorMessage(cleanupError, "startup cleanup failed"), }); }); + signal.throwIfAborted(); throw error; } } @@ -214,17 +212,51 @@ export class AcpDriverBackend implements AgentDriverBackend { this.#nativeInstructionPath === null ? this.#childProcessEnv : appendOpenCodeInstruction(this.#childProcessEnv, this.#nativeInstructionPath); - const agentProcess = await startAcpAgentProcess( + const startedProcess = await startAcpAgentProcess( context, this.#payload, processEnv, signal, launch, ); + const agentProcess = startedProcess.process; this.#agentProcess = agentProcess; + let activeClientRequests = 0; let connection: ClientConnection | null = null; + const serveRequest = async ( + requestSignal: AbortSignal, + operation: (signal: AbortSignal) => Promise | T, + ): Promise => { + const signal = this.#requestSignal(requestSignal); + + if (this.#stopRequested || signal.aborted) { + throw RequestError.requestCancelled(); + } + if (activeClientRequests >= MAX_ACTIVE_ACP_CLIENT_REQUESTS) { + const error = RequestError.internalError( + { maxActiveRequests: MAX_ACTIVE_ACP_CLIENT_REQUESTS }, + "ACP client request capacity exceeded", + ); + connection?.close(error); + throw error; + } + activeClientRequests += 1; + + try { + return await operation(signal); + } catch (error) { + if (signal.aborted) { + throw RequestError.requestCancelled(); + } + + throw error; + } finally { + activeClientRequests -= 1; + } + }; try { + await startedProcess.ready; signal.throwIfAborted(); if (this.#stopRequested) { throw new Error("ACP driver backend stopped while connecting."); @@ -235,42 +267,42 @@ export class AcpDriverBackend implements AgentDriverBackend { this.#clientRequests.enqueueUpdate(context, params), ) .onRequest(acpMethods.client.session.requestPermission, ({ params, requestId, signal }) => - this.#serveRequest(signal, (requestSignal) => + serveRequest(signal, (requestSignal) => this.#clientRequests.requestPermission(context, requestId, params, requestSignal), ), ) .onRequest(acpMethods.client.fs.readTextFile, ({ params, signal }) => - this.#serveRequest(signal, (requestSignal) => + serveRequest(signal, (requestSignal) => this.#clientRequests.readTextFile(params, requestSignal), ), ) .onRequest(acpMethods.client.fs.writeTextFile, ({ params, signal }) => - this.#serveRequest(signal, (requestSignal) => + serveRequest(signal, (requestSignal) => this.#clientRequests.writeTextFile(context, params, requestSignal), ), ) .onRequest(acpMethods.client.terminal.create, ({ params, signal }) => - this.#serveRequest(signal, (requestSignal) => + serveRequest(signal, (requestSignal) => this.#clientRequests.createTerminal(context, params, requestSignal), ), ) .onRequest(acpMethods.client.terminal.kill, ({ params, signal }) => - this.#serveRequest(signal, (requestSignal) => + serveRequest(signal, (requestSignal) => this.#clientRequests.killTerminal(context, params, requestSignal), ), ) .onRequest(acpMethods.client.terminal.output, ({ params, signal }) => - this.#serveRequest(signal, (requestSignal) => + serveRequest(signal, (requestSignal) => this.#clientRequests.terminalOutput(params, requestSignal), ), ) .onRequest(acpMethods.client.terminal.release, ({ params, signal }) => - this.#serveRequest(signal, (requestSignal) => + serveRequest(signal, (requestSignal) => this.#clientRequests.releaseTerminal(context, params, requestSignal), ), ) .onRequest(acpMethods.client.terminal.waitForExit, ({ params, signal }) => - this.#serveRequest(signal, (requestSignal) => + serveRequest(signal, (requestSignal) => this.#clientRequests.waitForTerminalExit(params, requestSignal), ), ); @@ -295,16 +327,21 @@ export class AcpDriverBackend implements AgentDriverBackend { const error = reason instanceof Error ? reason : new Error("ACP transport closed unexpectedly."); context.logger.error("driver.acp.transport.failed", error, {}); - const cleanup = stopAcpAgentProcess(context, agentProcess, "connection.failed"); - if (this.#turnController.failActive(error, cleanup)) { + this.#connection = null; + const cleanup = this.#stopOwnedAgent(context, agentProcess, "connection.failed"); + const turnSettled = this.#turnController.routeFatal(error, cleanup); + if (turnSettled === null) { return; } - void cleanup.then( - () => context.lifecycle.fail(error), - (cleanupError: unknown) => - context.lifecycle.fail( - new AggregateError([error, cleanupError], "ACP provider failure cleanup failed."), - ), + void Promise.allSettled([cleanup, turnSettled]).then(([cleanupResult]) => + context.lifecycle.fail( + cleanupResult?.status === "rejected" + ? new AggregateError( + [error, cleanupResult.reason], + "ACP provider failure cleanup failed.", + ) + : error, + ), ); }); @@ -356,13 +393,6 @@ export class AcpDriverBackend implements AgentDriverBackend { signal, ); - if (setup.droppedAdditionalDirectories.length > 0) { - context.logger.warn("driver.acp.session.additional_directories_dropped", { - count: setup.droppedAdditionalDirectories.length, - reason: "agent_capability_missing", - }); - } - if ( requiredResumeSessionId !== null && (setup.mode !== "resumed" || setup.sessionId !== requiredResumeSessionId) @@ -398,12 +428,9 @@ export class AcpDriverBackend implements AgentDriverBackend { this.#connection = null; } connection?.close(error instanceof Error ? error : new Error("ACP connection failed.")); - if (this.#agentProcess === agentProcess) { - this.#agentProcess = null; - } try { - await stopAcpAgentProcess( + await this.#stopOwnedAgent( context, agentProcess, "connection.failed", @@ -429,7 +456,6 @@ export class AcpDriverBackend implements AgentDriverBackend { runId, this.#requireConnection(), this.#requireSessionId(), - this.#requireHostSnapshot(), this.#clientRequests, signal, ); @@ -449,9 +475,22 @@ export class AcpDriverBackend implements AgentDriverBackend { if (this.#stopTask !== null) { return this.#stopTask; } + if (this.#stopSucceeded) { + return Promise.resolve(); + } - const task = Promise.resolve() + const operation = Promise.resolve() .then(() => this.#performStop(context, reason, signal)) + .finally(() => this.#clientRequests.closePathScope()); + const task = operation + .then(() => { + this.#stopFailure = null; + this.#stopSucceeded = true; + }) + .catch((error: unknown) => { + this.#stopFailure = { error }; + throw error; + }) .finally(() => { if (this.#stopTask === task) { this.#stopTask = null; @@ -461,9 +500,19 @@ export class AcpDriverBackend implements AgentDriverBackend { return task; } - async #recycleCancelledTurn(context: AgentDriverContext): Promise { + async #settleCancelledTurn( + context: AgentDriverContext, + providerPromptAdmitted: boolean, + resumeSignal: AbortSignal, + ): Promise { if (this.#stopRequested) { - await this.#stopTask; + await this.#joinStop(); + return; + } + if (resumeSignal.aborted) { + return; + } + if (!providerPromptAdmitted) { return; } @@ -476,32 +525,36 @@ export class AcpDriverBackend implements AgentDriverBackend { const deadline = Date.now() + ACP_RECYCLE_BUDGET_MS; this.#connection = null; - this.#agentProcess = null; transport.close(new Error("ACP cancelled turn recycling provider process.")); - await stopAcpAgentProcess(context, agentProcess, "turn.cancelled.recycle", deadline); + await this.#stopOwnedAgent(context, agentProcess, "turn.cancelled.recycle", deadline); if (this.#stopRequested) { - await this.#stopTask; + await this.#joinStop(); + return; + } + if (resumeSignal.aborted) { return; } - try { await this.#connect( context, - AbortSignal.timeout(this.#remainingStopMs(deadline)), + AbortSignal.any([AbortSignal.timeout(this.#remainingStopMs(deadline)), resumeSignal]), false, sessionId, ); } catch (error) { + if (resumeSignal.aborted) { + return; + } if (!this.#stopRequested) { throw error; } - await this.#stopTask; + await this.#joinStop(); return; } if (this.#stopRequested) { - await this.#stopTask; + await this.#joinStop(); return; } @@ -515,6 +568,12 @@ export class AcpDriverBackend implements AgentDriverBackend { ): Promise { const deadline = Date.now() + ACP_STOP_BUDGET_MS; this.#turnController.abort("ACP driver backend stopped."); + this.#clientRequests.beginStop(); + const fileWriteDrainTask = settlePromiseWithTimeout(this.#clientRequests.drainFileWrites(), { + label: "ACP committed file report drain", + signal, + timeoutMs: this.#remainingStopMs(deadline), + }); const terminalCleanupTask = settlePromiseWithTimeout( this.#clientRequests.stopTerminals(context), { @@ -578,6 +637,15 @@ export class AcpDriverBackend implements AgentDriverBackend { }); } + const fileWriteDrain = await fileWriteDrainTask; + + if (fileWriteDrain.status !== "completed") { + context.logger.warn("driver.acp.files.drain.failed", { + message: toErrorMessage(fileWriteDrain.error, "committed file report drain failed"), + reason, + }); + } + transport?.close(new Error("ACP driver backend stopped.")); if (this.#connection === transport) { this.#connection = null; @@ -587,11 +655,7 @@ export class AcpDriverBackend implements AgentDriverBackend { try { if (agentProcess !== null) { - await stopAcpAgentProcess(context, agentProcess, reason, deadline, signal); - } - - if (this.#agentProcess === agentProcess) { - this.#agentProcess = null; + await this.#stopOwnedAgent(context, agentProcess, reason, deadline, signal); } } catch (error) { processFailure = { error }; @@ -614,6 +678,10 @@ export class AcpDriverBackend implements AgentDriverBackend { throw updateDrain.error; } + if (fileWriteDrain.status !== "completed") { + throw fileWriteDrain.error; + } + if (terminalCleanup.status !== "completed") { throw terminalCleanup.error; } @@ -707,12 +775,49 @@ export class AcpDriverBackend implements AgentDriverBackend { return this.#nativeSessionId; } - #requireHostSnapshot(): DriverHostIntegrationSnapshot { - if (this.#hostSnapshot === null) { - throw new Error("ACP driver backend host integration snapshot is not initialized."); + async #joinStop(): Promise { + if (this.#stopTask !== null) { + await this.#stopTask; + return; + } + if (this.#stopSucceeded) { + return; + } + if (this.#stopFailure !== null) { + throw this.#stopFailure.error; + } + throw new Error("ACP driver stop was requested without an owned cleanup barrier."); + } + + async #stopOwnedAgent( + context: AgentDriverContext, + agentProcess: AcpAgentProcess, + reason: string, + deadline?: number, + signal?: AbortSignal, + ): Promise { + const existing = this.#agentProcessStop; + if (existing?.process === agentProcess) { + await existing.task; + return; } - return this.#hostSnapshot; + const cleanup = + deadline === undefined + ? stopAcpAgentProcess(context, agentProcess, reason) + : stopAcpAgentProcess(context, agentProcess, reason, deadline, signal); + this.#agentProcessStop = { process: agentProcess, task: cleanup }; + + try { + await cleanup; + if (this.#agentProcess === agentProcess) { + this.#agentProcess = null; + } + } finally { + if (this.#agentProcessStop?.task === cleanup) { + this.#agentProcessStop = null; + } + } } #remainingStopMs(deadline: number): number { @@ -724,25 +829,7 @@ export class AcpDriverBackend implements AgentDriverBackend { return turnSignal === undefined ? signal : AbortSignal.any([signal, turnSignal]); } - async #serveRequest( - signal: AbortSignal, - operation: (signal: AbortSignal) => Promise | T, - ): Promise { - const requestSignal = this.#requestSignal(signal); - - try { - return await operation(requestSignal); - } catch (error) { - if (requestSignal.aborted) { - throw RequestError.requestCancelled(); - } - - throw error; - } - } - async #setupSession(signal: AbortSignal): Promise>> { - const hostSnapshot = this.#requireHostSnapshot(); signal.throwIfAborted(); const deferredUpdates = this.#nativeSessionId === null || @@ -758,7 +845,6 @@ export class AcpDriverBackend implements AgentDriverBackend { connection: this.#requireConnection(), currentSessionId: this.#nativeSessionId, payload: this.#payload, - sessionContext: hostSnapshot.sessionContext, replaySession: async (operation) => this.#clientRequests.withSessionReplay(operation), }), signal, diff --git a/src/runtimes/acp/acp-event-translator.ts b/src/runtimes/acp/acp-event-translator.ts deleted file mode 100644 index 6c9ce52..0000000 --- a/src/runtimes/acp/acp-event-translator.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { AcpAssistantTranscriptState } from "./acp-assistant-transcript-state"; - -export type { AcpTurnEventStateInput } from "./acp-assistant-transcript-state"; -export { AcpAssistantTranscriptState } from "./acp-assistant-transcript-state"; -export type { AcpPermissionOption, AcpPermissionTranslation } from "./acp-permission-events"; -export { toPermissionRequest, toPermissionResolvedEvent } from "./acp-permission-events"; -export { - shouldIgnoreReplay, - toAuthEvent, - toInitializeEvents, - toPromptStartEvents, - toSessionReadyEvents, -} from "./acp-session-events"; - -/** Compatibility façade for the existing runtime boundary. */ -export class AcpTurnEventState extends AcpAssistantTranscriptState {} diff --git a/src/runtimes/acp/acp-file-system.ts b/src/runtimes/acp/acp-file-system.ts index 4160b76..e840508 100644 --- a/src/runtimes/acp/acp-file-system.ts +++ b/src/runtimes/acp/acp-file-system.ts @@ -1,7 +1,11 @@ -import { mkdir, readFile, stat, writeFile } from "node:fs/promises"; -import { dirname } from "node:path"; +import { randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import type { FileHandle } from "node:fs/promises"; +import { lstat, open, rename, rm } from "node:fs/promises"; +import { join } from "node:path"; import type { AgentDriverContext } from "../../core/agent-driver-backend"; +import { DRIVER_EVENT_DELIVERY_TIMEOUT_MS } from "../../core/driver-runtime-io"; import { isRecord, raceWithAbort, readNonEmptyString, readNumber } from "./acp-types"; import { AcpPathScope } from "./acp-path-scope"; @@ -12,6 +16,7 @@ interface AcpFileSystemOptions { } const MAX_ACP_FILE_BYTES = 8 * 1_024 * 1_024; +const FILE_CHUNK_BYTES = 64 * 1_024; export class AcpFileSystem { readonly #pathScope: AcpPathScope; @@ -29,19 +34,25 @@ export class AcpFileSystem { throw new Error("ACP fs/read_text_file requires a path."); } - const path = await this.#pathScope.resolveExisting(requestedPath, "ACP file path"); - const file = await stat(path); - signal?.throwIfAborted(); + const path = await this.#pathScope.openFile(requestedPath, "ACP file path"); + let raw: string; - if (!file.isFile()) { - throw new Error("ACP fs/read_text_file requires a regular file."); - } + try { + const metadata = await path.file.stat(); + signal?.throwIfAborted(); - if (file.size > MAX_ACP_FILE_BYTES) { - throw new Error(`ACP file exceeds ${MAX_ACP_FILE_BYTES} bytes.`); - } + if (!metadata.isFile()) { + throw new Error("ACP fs/read_text_file requires a regular file."); + } - const raw = await readFile(path, { encoding: "utf8", signal }); + if (metadata.size > MAX_ACP_FILE_BYTES) { + throw new Error(`ACP file exceeds ${MAX_ACP_FILE_BYTES} bytes.`); + } + + raw = await readBoundedText(path.file, signal); + } finally { + await path.file.close(); + } const line = readNumber(record, "line"); const limit = readNumber(record, "limit"); @@ -62,6 +73,7 @@ export class AcpFileSystem { context: AgentDriverContext, params: unknown, signal?: AbortSignal, + onCommitted?: () => void, ): Promise> { signal?.throwIfAborted(); const record = isRecord(params) ? params : {}; @@ -76,19 +88,117 @@ export class AcpFileSystem { throw new Error(`ACP file exceeds ${MAX_ACP_FILE_BYTES} bytes.`); } - const path = await this.#pathScope.resolveWritable(requestedPath, "ACP file path"); - await mkdir(dirname(path), { recursive: true }); - signal?.throwIfAborted(); - await writeFile(path, content, { encoding: "utf8", signal }); + const path = await this.#pathScope.openWritable(requestedPath, "ACP file path"); + const temporaryPath = join(path.directory.procPath, `.${randomUUID()}.tmp`); + const destinationPath = join(path.directory.procPath, path.name); + let temporaryCreated = false; + let committed = false; + let changedPath: string; + + try { + const mode = await readExistingMode(destinationPath); + const temporary = await open( + temporaryPath, + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + 0o600, + ); + temporaryCreated = true; + + try { + const bytes = Buffer.from(content, "utf8"); + let offset = 0; + + while (offset < bytes.length) { + signal?.throwIfAborted(); + const { bytesWritten } = await temporary.write( + bytes, + offset, + Math.min(FILE_CHUNK_BYTES, bytes.length - offset), + offset, + ); + + if (bytesWritten === 0) { + throw new Error("ACP fs/write_text_file could not make write progress."); + } + offset += bytesWritten; + } + + await temporary.chmod(mode); + signal?.throwIfAborted(); + await temporary.sync(); + signal?.throwIfAborted(); + await rename(temporaryPath, destinationPath); + committed = true; + onCommitted?.(); + await path.directory.file.sync(); + } finally { + await temporary.close(); + } + + changedPath = join(await this.#pathScope.identify(path.directory), path.name); + } finally { + try { + if (temporaryCreated && !committed) { + await rm(temporaryPath, { force: true }); + } + } finally { + await path.directory.file.close(); + } + } + + const reportSignal = AbortSignal.timeout(DRIVER_EVENT_DELIVERY_TIMEOUT_MS); await raceWithAbort( - context.ports.file.reportChanged({ - change: "upsert", - path, - reason: "acp.fs", - }), - signal, + context.ports.file.reportChanged( + { + change: "upsert", + path: changedPath, + reason: "acp.fs", + }, + reportSignal, + ), + reportSignal, ); return {}; } } + +async function readExistingMode(path: string): Promise { + try { + const metadata = await lstat(path); + return metadata.isFile() ? metadata.mode & 0o7777 : 0o600; + } catch (error) { + if (typeof error === "object" && error !== null && "code" in error && error.code === "ENOENT") { + return 0o600; + } + throw error; + } +} + +async function readBoundedText(file: FileHandle, signal?: AbortSignal): Promise { + const decoder = new TextDecoder(); + const chunk = Buffer.allocUnsafe(FILE_CHUNK_BYTES); + const content: string[] = []; + let offset = 0; + + for (;;) { + signal?.throwIfAborted(); + const remaining = MAX_ACP_FILE_BYTES + 1 - offset; + + if (remaining <= 0) { + throw new Error(`ACP file exceeds ${MAX_ACP_FILE_BYTES} bytes.`); + } + + const { bytesRead } = await file.read(chunk, 0, Math.min(chunk.length, remaining), offset); + if (bytesRead === 0) { + content.push(decoder.decode()); + return content.join(""); + } + + offset += bytesRead; + if (offset > MAX_ACP_FILE_BYTES) { + throw new Error(`ACP file exceeds ${MAX_ACP_FILE_BYTES} bytes.`); + } + content.push(decoder.decode(chunk.subarray(0, bytesRead), { stream: true })); + } +} diff --git a/src/runtimes/acp/acp-input-limit.ts b/src/runtimes/acp/acp-input-limit.ts index fd64c65..51ec358 100644 --- a/src/runtimes/acp/acp-input-limit.ts +++ b/src/runtimes/acp/acp-input-limit.ts @@ -8,23 +8,47 @@ export function limitAcpInput( throw new RangeError("ACP message byte limit must be a positive safe integer."); } + const decoder = new TextDecoder("utf-8", { fatal: true }); let pendingBytes = 0; + + const append = (bytes: Uint8Array, lineEnded: boolean): void => { + if (bytes.byteLength === 0 && !lineEnded) { + return; + } + + pendingBytes += bytes.byteLength; + if (pendingBytes > maxMessageBytes) { + throw new Error(`ACP message exceeds ${maxMessageBytes} bytes.`); + } + + decoder.decode(bytes, { stream: !lineEnded }); + if (lineEnded) { + pendingBytes = 0; + } + }; + return input.pipeThrough( new TransformStream({ transform(chunk, controller) { - for (const byte of chunk) { - if (byte === 0x0a) { - pendingBytes = 0; - } else { - pendingBytes += 1; - if (pendingBytes > maxMessageBytes) { - throw new Error(`ACP message exceeds ${maxMessageBytes} bytes.`); - } - } + let lineStart = 0; + for ( + let newline = chunk.indexOf(0x0a); + newline >= 0; + newline = chunk.indexOf(0x0a, lineStart) + ) { + append(chunk.subarray(lineStart, newline), true); + lineStart = newline + 1; } + append(chunk.subarray(lineStart), false); + controller.enqueue(chunk); }, + flush() { + if (pendingBytes > 0) { + decoder.decode(); + } + }, }), ); } diff --git a/src/runtimes/acp/acp-lifecycle-events.ts b/src/runtimes/acp/acp-lifecycle-events.ts index 2252a4c..b3f96dc 100644 --- a/src/runtimes/acp/acp-lifecycle-events.ts +++ b/src/runtimes/acp/acp-lifecycle-events.ts @@ -2,22 +2,21 @@ import type { InitializeResponse } from "@agentclientprotocol/sdk"; import type { DriverEventInput } from "../../protocol/events"; import type { RunId } from "../../protocol/id"; -import { isRecord, readNumber, readRecord, readString } from "./acp-types"; -import type { JsonObject } from "./acp-types"; import { - toCapabilityEvents, - toConfigEvents, - toModeEvents, - toModelEvents, -} from "./acp-session-update-events"; - -// OpenCode's ACP usage reports fresh input tokens with cache read/write as -// separate buckets (Anthropic-style), not an input total that includes them. -const ACP_USAGE_CONTRACT = "anthropic_bucketed"; + ACP_USAGE_CONTRACT, + assertBoundedLosslessEvents, + isRecord, + readNumber, + readRecord, + readString, +} from "./acp-types"; +import type { JsonObject } from "./acp-types"; +import { toConfigEvents, toModeEvents } from "./acp-session-update-events"; export function toInitializeEvents(result: InitializeResponse): DriverEventInput[] { const events: DriverEventInput[] = [ { + delivery: "best_effort", kind: "runtime.capabilities.updated", payload: { capabilities: result.agentCapabilities ?? {}, @@ -29,6 +28,7 @@ export function toInitializeEvents(result: InitializeResponse): DriverEventInput if (result.authMethods !== undefined && result.authMethods.length > 0) { events.push({ + delivery: "best_effort", kind: "auth.methods.updated", payload: { methods: result.authMethods, @@ -45,7 +45,7 @@ export function toPromptStartEvents(input: { runId: RunId; text: string; }): DriverEventInput[] { - return [ + return assertBoundedLosslessEvents([ { actor: "user", kind: "message.added", @@ -80,7 +80,7 @@ export function toPromptStartEvents(input: { }, runId: input.runId, }, - ]; + ]); } export function toSessionReadyEvents(input: { @@ -88,7 +88,7 @@ export function toSessionReadyEvents(input: { nativeSessionId: string; setup: JsonObject; }): DriverEventInput[] { - return [ + return assertBoundedLosslessEvents([ { kind: input.mode === "created" ? "session.created" : "session.resumed", payload: @@ -109,28 +109,28 @@ export function toSessionReadyEvents(input: { }, visibility: "owner_debug", }, - ...toModeEvents(input.setup), - ...toModelEvents(input.setup), + ...toModeEvents(readRecord(input.setup, "modes")), ...toConfigEvents(input.setup), - ...toCapabilityEvents(input.setup), - ]; + ]); } export function toAuthEvent(input: { methodId: string; status: "authenticated" | "failed"; }): DriverEventInput { - return { - kind: "auth.session.updated", - payload: { - methodId: input.methodId, - status: input.status, + return assertBoundedLosslessEvents([ + { + kind: "auth.session.updated", + payload: { + methodId: input.methodId, + status: input.status, + }, + visibility: "owner_debug", }, - visibility: "owner_debug", - }; + ])[0]!; } -export function shouldIgnoreReplay(params: unknown): boolean { +export function isTurnScopedSessionUpdate(params: unknown): boolean { const record = isRecord(params) ? params : {}; const update = readRecord(record, "update"); @@ -171,12 +171,14 @@ export function normalizePromptUsage(raw: unknown): JsonObject | null { return null; } + // Prompt usage is emitted in the same lossless batch as every open-item + // closure. Keep only the bounded canonical counters; ACP extension metadata + // is arbitrary JSON and cannot safely participate in that atomic batch. return { ...(cachedReadTokens === null ? {} : { cachedReadTokens }), ...(cachedWriteTokens === null ? {} : { cachedWriteTokens }), ...(inputTokens === null ? {} : { inputTokens }), ...(outputTokens === null ? {} : { outputTokens }), - raw, source: "prompt_response", ...(thoughtTokens === null ? {} : { thoughtTokens }), ...(totalTokens === null ? {} : { totalTokens }), diff --git a/src/runtimes/acp/acp-path-scope.ts b/src/runtimes/acp/acp-path-scope.ts index 25021fd..6a3a4b1 100644 --- a/src/runtimes/acp/acp-path-scope.ts +++ b/src/runtimes/acp/acp-path-scope.ts @@ -1,78 +1,295 @@ -import { lstat, realpath } from "node:fs/promises"; -import { dirname, isAbsolute, relative, resolve } from "node:path"; +import { constants } from "node:fs"; +import type { FileHandle } from "node:fs/promises"; +import { mkdir, open, realpath } from "node:fs/promises"; +import { isAbsolute, join, relative, resolve } from "node:path"; export interface AcpPathScopeOptions { readonly allowedRoots: readonly string[]; readonly cwd: string; } +export interface AcpPathHandle { + readonly file: FileHandle; + readonly procPath: string; +} + +export interface AcpWritablePath { + readonly directory: AcpPathHandle; + readonly name: string; +} + +interface AcpRootHandle extends AcpPathHandle { + readonly lexical: string; +} + function contains(root: string, candidate: string): boolean { const path = relative(root, candidate); return path === "" || (!path.startsWith("..") && !isAbsolute(path)); } +function hasCode(error: unknown, code: string): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === code; +} + +function procPath(file: FileHandle): string { + return `/proc/${process.pid}/fd/${file.fd}`; +} + +async function closeRoots(roots: readonly AcpRootHandle[]): Promise { + const results = await Promise.allSettled(roots.map((root) => root.file.close())); + const failures = results.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + + if (failures.length > 0) { + throw new AggregateError(failures, "ACP path root capabilities failed to close."); + } +} + export class AcpPathScope { + readonly #configuredRoots: readonly string[]; readonly #cwd: string; - readonly #roots: readonly string[]; - #realRoots: Promise | null = null; + #closed = false; + #closeTask: Promise | null = null; + #initializeTask: Promise | null = null; + #roots: readonly AcpRootHandle[] | null = null; constructor(options: AcpPathScopeOptions) { + if (process.platform !== "linux") { + throw new Error("ACP filesystem capabilities require Linux /proc support."); + } + this.#cwd = resolve(options.cwd); - this.#roots = [options.cwd, ...options.allowedRoots].map((root) => resolve(options.cwd, root)); + this.#configuredRoots = [ + ...new Set([options.cwd, ...options.allowedRoots].map((root) => resolve(options.cwd, root))), + ].sort((left, right) => right.length - left.length); } cwd(): string { return this.#cwd; } - async resolveExisting(path: string, label: string): Promise { - const lexical = this.#resolveLexical(path, label); - const canonical = await realpath(lexical); - await this.#assertCanonical(canonical, path, label); - return canonical; + initialize(): Promise { + if (this.#closed) { + return Promise.reject(new Error("ACP path scope is closed.")); + } + if (this.#roots !== null) { + return Promise.resolve(); + } + if (this.#initializeTask !== null) { + return this.#initializeTask; + } + + const task = this.#acquireRoots().finally(() => { + if (this.#initializeTask === task) { + this.#initializeTask = null; + } + }); + this.#initializeTask = task; + return task; } - async resolveWritable(path: string, label: string): Promise { - const lexical = this.#resolveLexical(path, label); - let ancestor = lexical; + close(): Promise { + this.#closed = true; + if (this.#closeTask !== null) { + return this.#closeTask; + } - for (;;) { - try { - await lstat(ancestor); - break; - } catch (error) { - const parent = dirname(ancestor); - if (parent === ancestor) { - throw error; + const task = this.#close().finally(() => { + if (this.#closeTask === task) { + this.#closeTask = null; + } + }); + this.#closeTask = task; + return task; + } + + identify(handle: AcpPathHandle): Promise { + return realpath(handle.procPath); + } + + async openFile(path: string, label: string): Promise { + const resolved = await this.#resolve(path, label); + return this.#openAt( + resolved.root, + join(resolved.root.procPath, resolved.relativePath), + constants.O_RDONLY | constants.O_NONBLOCK, + path, + label, + ); + } + + async openDirectory(path: string, label: string): Promise { + const resolved = await this.#resolve(path, label); + return this.#openAt( + resolved.root, + join(resolved.root.procPath, resolved.relativePath), + constants.O_RDONLY | constants.O_DIRECTORY, + path, + label, + ); + } + + async openWritable(path: string, label: string): Promise { + const resolved = await this.#resolve(path, label); + const segments = resolved.relativePath.split("/").filter((segment) => segment.length > 0); + const name = segments.pop(); + + if (name === undefined) { + throw new Error(`${label} must name a file: ${path}.`); + } + + let directory = await this.#openAt( + resolved.root, + resolved.root.procPath, + constants.O_RDONLY | constants.O_DIRECTORY, + path, + label, + ); + + try { + for (const segment of segments) { + const child = join(directory.procPath, segment); + let next: AcpPathHandle; + + try { + next = await this.#openAt( + resolved.root, + child, + constants.O_RDONLY | constants.O_DIRECTORY, + path, + label, + ); + } catch (error) { + if (!hasCode(error, "ENOENT")) { + throw error; + } + + try { + await mkdir(child); + } catch (mkdirError) { + if (!hasCode(mkdirError, "EEXIST")) { + throw mkdirError; + } + } + next = await this.#openAt( + resolved.root, + child, + constants.O_RDONLY | constants.O_DIRECTORY, + path, + label, + ); + } + + await directory.file.close(); + directory = next; + } + + return { directory, name }; + } catch (error) { + await directory.file.close().catch(() => {}); + throw error; + } + } + + async #acquireRoots(): Promise { + const roots: AcpRootHandle[] = []; + + try { + for (const lexical of this.#configuredRoots) { + const file = await open(lexical, constants.O_RDONLY | constants.O_DIRECTORY); + const root = { file, lexical, procPath: procPath(file) }; + roots.push(root); + await realpath(root.procPath); + + if (this.#closed) { + throw new Error("ACP path scope closed during initialization."); } - ancestor = parent; } + + if (this.#closed) { + throw new Error("ACP path scope closed during initialization."); + } + this.#roots = roots; + } catch (error) { + try { + await closeRoots(roots); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + "ACP path root capability initialization failed.", + ); + } + throw error; + } + } + + async #close(): Promise { + await this.#initializeTask?.catch(() => {}); + const roots = this.#roots; + + if (roots === null) { + return; } - const canonicalAncestor = await realpath(ancestor); - await this.#assertCanonical(canonicalAncestor, path, label); - return resolve(canonicalAncestor, relative(ancestor, lexical)); + await closeRoots(roots); + if (this.#roots === roots) { + this.#roots = null; + } } - #resolveLexical(path: string, label: string): string { + async #resolve( + path: string, + label: string, + ): Promise<{ readonly relativePath: string; readonly root: AcpRootHandle }> { if (!isAbsolute(path)) { throw new Error(`${label} must be absolute: ${path}.`); } const candidate = resolve(this.#cwd, path); - if (this.#roots.some((root) => contains(root, candidate))) { - return candidate; + const lexicalRoot = this.#configuredRoots.find((root) => contains(root, candidate)); + + if (lexicalRoot === undefined) { + throw new Error(`${label} is outside the allowed roots: ${path}.`); + } + + await this.initialize(); + const root = this.#roots?.find((entry) => entry.lexical === lexicalRoot); + + if (root === undefined) { + throw new Error("ACP path scope is not initialized."); } - throw new Error(`${label} is outside the allowed roots: ${path}.`); + return { + relativePath: relative(root.lexical, candidate), + root, + }; } - async #assertCanonical(candidate: string, requested: string, label: string): Promise { - const roots = await (this.#realRoots ??= Promise.all( - this.#roots.map((root) => realpath(root)), - )); - if (!roots.some((root) => contains(root, candidate))) { - throw new Error(`${label} resolves outside the allowed roots: ${requested}.`); + async #openAt( + root: AcpRootHandle, + path: string, + flags: number, + requested: string, + label: string, + ): Promise { + const file = await open(path, flags); + const handle = { file, procPath: procPath(file) }; + + try { + const [canonicalRoot, canonical] = await Promise.all([ + realpath(root.procPath), + realpath(handle.procPath), + ]); + + if (!contains(canonicalRoot, canonical)) { + throw new Error(`${label} resolves outside the allowed roots: ${requested}.`); + } + + return handle; + } catch (error) { + await file.close().catch(() => {}); + throw error; } } } diff --git a/src/runtimes/acp/acp-permission-events.ts b/src/runtimes/acp/acp-permission-events.ts index f2ded69..dd992a9 100644 --- a/src/runtimes/acp/acp-permission-events.ts +++ b/src/runtimes/acp/acp-permission-events.ts @@ -1,5 +1,6 @@ import type { DriverEventInput } from "../../protocol/events"; import type { RunId } from "../../protocol/id"; +import type { DriverPermissionRequest } from "../../host-ports"; import { toRuntimeToolStatus, toToolCallPayload } from "./acp-tool-events"; import { isRecord, @@ -11,12 +12,10 @@ import { import type { JsonObject } from "./acp-types"; export interface AcpPermissionTranslation { - readonly defaultOptionId: string | null; readonly events: DriverEventInput[]; readonly options: readonly AcpPermissionOption[]; - readonly requestId: string; + readonly request: DriverPermissionRequest; readonly targetItemId: string; - readonly title: string; readonly toolCall: JsonObject | null; } @@ -34,7 +33,8 @@ export function toPermissionRequest(input: { const params = isRecord(input.params) ? input.params : {}; const toolCall = readRecord(params, "toolCall"); const options = normalizePermissionOptions(params["options"]); - const toolCallId = readNonEmptyString(toolCall, "toolCallId") ?? input.requestId; + const nativeToolCallId = readNonEmptyString(toolCall, "toolCallId"); + const toolCallId = nativeToolCallId ?? input.requestId; const title = readNonEmptyString(toolCall, "title") ?? readNonEmptyString(toolCall, "kind") ?? @@ -53,54 +53,21 @@ export function toPermissionRequest(input: { }); } - events.push({ - kind: "permission.requested", - payload: { - defaultOptionId: options.find((option) => option.kind === "allow_once")?.optionId ?? null, - details: stringifyForDisplay(toolCall?.["rawInput"]), - options, - requestId: input.requestId, - targetItemId: toolCallId, - title, - toolCall: toolCall === null ? null : toToolCallPayload(toolCallId, "running", toolCall), - }, - ...(input.runId === null ? {} : { runId: input.runId }), - }); - return { - defaultOptionId: options.find((option) => option.kind === "allow_once")?.optionId ?? null, events, options, - requestId: input.requestId, + request: { + rawInput: stringifyForDisplay(toolCall?.["rawInput"]), + requestId: input.requestId, + title, + toolCallId, + toolKind: readNonEmptyString(toolCall, "kind"), + }, targetItemId: toolCallId, - title, toolCall, }; } -export function toPermissionResolvedEvent(input: { - option: AcpPermissionOption | null; - requestId: string; - runId: RunId | null; -}): DriverEventInput { - return { - kind: "permission.resolved", - payload: - input.option === null - ? { - outcome: "cancelled", - requestId: input.requestId, - } - : { - optionId: input.option.optionId, - optionKind: input.option.kind, - outcome: "selected", - requestId: input.requestId, - }, - ...(input.runId === null ? {} : { runId: input.runId }), - }; -} - function normalizePermissionOptions(raw: unknown): AcpPermissionOption[] { if (!Array.isArray(raw)) { return []; diff --git a/src/runtimes/acp/acp-session-events.ts b/src/runtimes/acp/acp-session-events.ts index e3a7fe0..7035699 100644 --- a/src/runtimes/acp/acp-session-events.ts +++ b/src/runtimes/acp/acp-session-events.ts @@ -1,6 +1,6 @@ export { normalizePromptUsage, - shouldIgnoreReplay, + isTurnScopedSessionUpdate, toAuthEvent, toInitializeEvents, toPromptStartEvents, diff --git a/src/runtimes/acp/acp-session-setup.ts b/src/runtimes/acp/acp-session-setup.ts index 8b2a152..d804e78 100644 --- a/src/runtimes/acp/acp-session-setup.ts +++ b/src/runtimes/acp/acp-session-setup.ts @@ -7,7 +7,6 @@ import type { ResumeSessionRequest, } from "@agentclientprotocol/sdk"; -import type { DriverExecutionSessionContext } from "../../protocol/boot"; import type { DriverStartInput } from "../../protocol/start"; import { buildMcpServers, @@ -24,7 +23,6 @@ import type { JsonObject } from "./acp-types"; export type AcpSessionSetupMode = "created" | "loaded" | "resumed"; export interface AcpSessionSetup { - readonly droppedAdditionalDirectories: readonly string[]; readonly mode: AcpSessionSetupMode; readonly raw: JsonObject; readonly sessionId: string; @@ -35,7 +33,6 @@ interface AcpSessionSetupInput { readonly connection: ClientContext; readonly currentSessionId: string | null; readonly payload: DriverStartInput; - readonly sessionContext: DriverExecutionSessionContext; replaySession(operation: () => Promise): Promise; } @@ -80,20 +77,15 @@ export async function setupAcpSession(input: AcpSessionSetupInput): Promise 0 && !supportsAdditionalDirs(input.agentCapabilities)) { + throw new Error("ACP agent does not advertise additionalDirectories support."); + } const baseParams = { _meta: toRequestMeta({ - sessionContext: input.sessionContext, + sessionContext: input.payload.execution.session.context, }), ...(additionalDirectories.length === 0 ? {} : { additionalDirectories }), cwd: input.payload.execution.session.cwd, @@ -120,7 +112,6 @@ export async function setupAcpSession(input: AcpSessionSetupInput): Promise>(); #failure: Error | null = null; readonly #onFailure: AcpSessionUpdateInboxOptions["onFailure"]; #pendingBytes = 0; @@ -89,7 +88,7 @@ export class AcpSessionUpdateInbox { this.#pendingCount += 1; const gate = this.#updateGate; const scope = { replaying: this.#replaying, suppressed: this.#suppressed }; - let delivery = Promise.resolve(); + let admitted = false; const admission = this.#tail .then(async () => { if (this.#failure !== null) { @@ -99,28 +98,33 @@ export class AcpSessionUpdateInbox { return; } - delivery = this.#apply(context, notification, scope); - this.#deliveries.add(delivery); - void delivery - .catch((error: unknown) => this.#fail(error)) - .finally(() => this.#deliveries.delete(delivery)); - }) - .finally(() => { + admitted = true; this.#pendingBytes -= bytes; this.#pendingCount -= 1; + try { + await this.#apply(context, notification, scope); + } catch (error) { + this.#fail(error); + throw error; + } + }) + .finally(() => { + if (!admitted) { + this.#pendingBytes -= bytes; + this.#pendingCount -= 1; + } }); this.#tail = admission.catch((error: unknown) => this.#fail(error)); - return admission.then(() => delivery); + return admission; } async drain(): Promise { for (;;) { const tail = this.#tail; await tail; - await Promise.allSettled(this.#deliveries); await Promise.resolve(); - if (tail !== this.#tail || this.#deliveries.size > 0) { + if (tail !== this.#tail) { continue; } diff --git a/src/runtimes/acp/acp-terminal-manager.ts b/src/runtimes/acp/acp-terminal-manager.ts index b2338b0..a2fc6de 100644 --- a/src/runtimes/acp/acp-terminal-manager.ts +++ b/src/runtimes/acp/acp-terminal-manager.ts @@ -4,8 +4,9 @@ import { once } from "node:events"; import type { DriverEventInput } from "../../protocol/events"; import { createDriverId } from "../../protocol/id"; -import { settlePromiseWithTimeout } from "../../utils/async"; import type { AgentDriverContext } from "../../core/agent-driver-backend"; +import { DriverEventRejectedError } from "../../core/driver-runtime-io"; +import { settlePromiseWithTimeout } from "../../utils/async"; import { bindSpawnedProcess, createProcessTreeEnvironment, @@ -30,19 +31,26 @@ import { AcpPathScope } from "./acp-path-scope"; interface AcpTerminalState { readonly closed: Promise; closedStatus: AcpTerminalExitStatus | null; - committed: boolean; + createEvent: DriverEventInput | null; + createEventTask: Promise | null; cleanupTakenOver: boolean; completionTask: Promise | null; readonly exited: Promise; + exitEvent: DriverEventInput | null; exitEventTask: Promise | null; exitStatus: AcpTerminalExitStatus | null; readonly id: string; + killEvent: DriverEventInput | null; + killTask: Promise | null; readonly marker: string; + hostMayOwn: boolean; orphaned: boolean; output: string; readonly outputByteLimit: number; readonly process: ChildProcessWithoutNullStreams; readonly rejectExit: (reason?: unknown) => void; + readonly reservation: AcpTerminalReservation; + releaseEvent: DriverEventInput | null; releaseTask: Promise | null; readonly resolveExit: (status: AcpTerminalExitStatus) => void; supervisionFailure: Error | null; @@ -51,6 +59,12 @@ interface AcpTerminalState { watchdog: LinuxProcessTreeWatchdog | null; } +interface AcpTerminalReservation { + active: boolean; + claimed: boolean; + readonly turn: number; +} + interface AcpTerminalExitStatus { readonly exitCode: number | null; readonly signal: string | null; @@ -70,6 +84,7 @@ const DEFAULT_MAX_TERMINALS = 32; const DEFAULT_TERMINAL_OUTPUT_BYTE_LIMIT = 1024 * 1024; const TERMINAL_EXIT_TIMEOUT_MS = 2_000; const TERMINAL_FORCE_KILL_TIMEOUT_MS = 1_000; +const TERMINAL_KILL_WAIT_TIMEOUT_MS = TERMINAL_EXIT_TIMEOUT_MS + TERMINAL_FORCE_KILL_TIMEOUT_MS * 2; class AcpTerminalCleanupError extends Error { override readonly name = "AcpTerminalCleanupError"; @@ -84,9 +99,11 @@ export class AcpTerminalManager { readonly #maxTerminals: number; readonly #pathScope: AcpPathScope; readonly #push: AcpTerminalManagerOptions["push"]; - readonly #createTasks = new Set>(); + readonly #createTasks = new Map, number>(); readonly #spawnWatchdog: typeof spawnLinuxProcessTreeWatchdog; + #currentTurn = 0; #stopping = false; + #terminalReservations = 0; readonly #terminals = new Map(); constructor(options: AcpTerminalManagerOptions) { @@ -110,20 +127,41 @@ export class AcpTerminalManager { if (this.#stopping) { throw new Error("ACP terminal manager is stopping."); } + if (this.#terminalReservations >= this.#maxTerminals) { + throw new Error(`ACP terminal limit of ${this.#maxTerminals} is exhausted.`); + } - const creation = this.#create(context, params, signal); - this.#createTasks.add(creation); + const reservation: AcpTerminalReservation = { + active: true, + claimed: false, + turn: this.#currentTurn, + }; + this.#terminalReservations += 1; + const creation = this.#create(context, params, reservation, signal); + this.#createTasks.set(creation, reservation.turn); try { return await creation; } finally { this.#createTasks.delete(creation); + if (!reservation.claimed) { + this.#releaseReservation(reservation); + } } } + beginTurn(): number { + if (this.#stopping) { + throw new Error("ACP terminal manager is stopping."); + } + + return ++this.#currentTurn; + } + async #create( context: AgentDriverContext, params: unknown, + reservation: AcpTerminalReservation, signal?: AbortSignal, ): Promise<{ terminalId: string }> { signal?.throwIfAborted(); @@ -139,33 +177,38 @@ export class AcpTerminalManager { throw new Error("ACP terminal/create requires a command."); } - if (this.#terminals.size >= this.#maxTerminals) { - throw new Error(`ACP terminal limit of ${this.#maxTerminals} is exhausted.`); - } - const args = readArray(record, "args").filter( (entry): entry is string => typeof entry === "string", ); - const cwd = await this.#pathScope.resolveExisting( - readNonEmptyString(record, "cwd") ?? this.#pathScope.cwd(), - "ACP terminal cwd", - ); + const requestedCwd = readNonEmptyString(record, "cwd") ?? this.#pathScope.cwd(); const requestedOutputByteLimit = readNumber(record, "outputByteLimit"); const outputByteLimit = normalizeByteLimit(requestedOutputByteLimit); const env = this.#readTerminalEnv(record); const terminalId = createDriverId(); - const processTree = createProcessTreeEnvironment({ - ...this.#env, - ...env, - }); - const child = spawn(command, args, { - cwd, - detached: true, - env: processTree.env, - stdio: ["pipe", "pipe", "pipe"], - }); + const cwd = await this.#pathScope.openDirectory(requestedCwd, "ACP terminal cwd"); + let processTree: ReturnType; + let child: ChildProcessWithoutNullStreams; + let cwdPath: string; + + try { + signal?.throwIfAborted(); + processTree = createProcessTreeEnvironment({ + ...this.#env, + ...env, + }); + child = spawn(command, args, { + cwd: cwd.procPath, + detached: true, + env: processTree.env, + stdio: ["pipe", "pipe", "pipe"], + }); + } catch (error) { + await cwd.file.close().catch(() => {}); + throw error; + } const target = bindSpawnedProcess(child, process.platform, processTree); const spawned = once(child, "spawn"); + void spawned.catch(() => {}); const { promise: closed, resolve: resolveClosed } = Promise.withResolvers(); const { promise: exited, @@ -176,19 +219,26 @@ export class AcpTerminalManager { const terminal: AcpTerminalState = { closed, closedStatus: null, - committed: false, + createEvent: null, + createEventTask: null, cleanupTakenOver: false, completionTask: null, exited, + exitEvent: null, exitEventTask: null, exitStatus: null, id: terminalId, + killEvent: null, + killTask: null, marker: processTree.marker, + hostMayOwn: false, orphaned: false, output: "", outputByteLimit, process: child, rejectExit, + reservation, + releaseEvent: null, releaseTask: null, resolveExit, supervisionFailure: null, @@ -197,6 +247,7 @@ export class AcpTerminalManager { watchdog: null, }; + reservation.claimed = true; this.#terminals.set(terminalId, terminal); child.once("close", (exitCode, signal) => { const status = { exitCode: exitCode ?? null, signal: signal ?? null }; @@ -259,6 +310,8 @@ export class AcpTerminalManager { ); } await raceWithAbort(spawned, signal); + cwdPath = await this.#pathScope.identify(cwd); + await cwd.file.close(); if (process.platform === "linux" && terminal.watchdog === null) { throw new Error(`ACP terminal ${terminalId} supervision could not start.`); } @@ -276,23 +329,23 @@ export class AcpTerminalManager { throw new Error(`ACP terminal ${terminalId} lost ownership during creation.`); } - await this.#push(context, "driver.acp.terminal.created", [ - { - kind: "terminal.created", - payload: { - command, - cwd, - outputByteLimit, - terminalId, - }, + terminal.createEvent = { + kind: "terminal.created", + payload: { + command, + cwd: cwdPath, + outputByteLimit, + terminalId, }, - ]); + sourceEventId: `acp.terminal.created:${terminalId}`, + }; + await this.#publishCreated(context, terminal); if (this.#terminals.get(terminalId) !== terminal) { throw new Error(`ACP terminal ${terminalId} lost ownership during creation.`); } - terminal.committed = true; + terminal.hostMayOwn = true; await this.#publishExit(context, terminal); signal?.throwIfAborted(); @@ -300,7 +353,18 @@ export class AcpTerminalManager { throw new Error("ACP terminal manager is stopping."); } } catch (error) { - if (terminal.committed) { + if ( + !terminal.hostMayOwn && + terminal.createEvent !== null && + !( + error instanceof DriverEventRejectedError && + error.sourceEventId === terminal.createEvent.sourceEventId + ) + ) { + terminal.hostMayOwn = true; + } + + if (terminal.hostMayOwn) { try { await this.#releaseTerminal(context, terminal); } catch (cleanupError) { @@ -327,8 +391,10 @@ export class AcpTerminalManager { } releaseLinuxProcessMarker(terminal.marker); - this.#terminals.delete(terminalId); + this.#removeTerminal(terminal); throw error; + } finally { + await cwd.file.close().catch(() => {}); } return { terminalId }; @@ -341,24 +407,47 @@ export class AcpTerminalManager { ): Promise> { signal?.throwIfAborted(); const terminal = this.#requireTerminal(params); + await raceWithAbort(this.#killTerminal(context, terminal), signal); + + return {}; + } + + #killTerminal(context: AgentDriverContext, terminal: AcpTerminalState): Promise { + if (terminal.killTask !== null) { + return terminal.killTask; + } + terminal.killEvent ??= { + kind: "terminal.killed", + payload: { + terminalId: terminal.id, + }, + sourceEventId: `acp.terminal.killed:${terminal.id}`, + }; + const operation = this.#runKill(context, terminal); + let task: Promise; + task = operation.catch((error: unknown) => { + if (terminal.killTask === task) { + terminal.killTask = null; + } + throw error; + }); + terminal.killTask = task; + return task; + } + + async #runKill(context: AgentDriverContext, terminal: AcpTerminalState): Promise { if (terminal.exitStatus === null) { signalAcpTerminalProcess(terminal, "SIGKILL"); - await terminal.exited; + const exited = await this.#waitForExit(terminal, TERMINAL_KILL_WAIT_TIMEOUT_MS); + if (!exited) { + throw new Error( + `ACP terminal ${terminal.id} cleanup did not finish within ${TERMINAL_KILL_WAIT_TIMEOUT_MS}ms after force kill.`, + ); + } } - await raceWithAbort( - this.#push(context, "driver.acp.terminal.killed", [ - { - kind: "terminal.killed", - payload: { - terminalId: terminal.id, - }, - }, - ]), - signal, - ); - - return {}; + await this.#publishExit(context, terminal); + await this.#push(context, "driver.acp.terminal.killed", [terminal.killEvent!]); } output( @@ -403,9 +492,41 @@ export class AcpTerminalManager { async stopAll(context: AgentDriverContext): Promise { this.#stopping = true; - const creations = await Promise.allSettled(this.#createTasks); + try { + await this.#stop(context); + } finally { + await this.#pathScope.close(); + } + } + + async stopTurn(context: AgentDriverContext, turn: number): Promise { + await this.#stop(context, turn); + } + + async #stop(context: AgentDriverContext, turn?: number): Promise { + const ownsTurn = (ownedTurn: number): boolean => turn === undefined || ownedTurn === turn; + const admittedKills = new Map( + [...this.#terminals.values()].flatMap((terminal) => + ownsTurn(terminal.reservation.turn) ? [[terminal, terminal.killTask] as const] : [], + ), + ); + const creations = await Promise.allSettled( + [...this.#createTasks].flatMap(([creation, ownedTurn]) => + ownsTurn(ownedTurn) ? [creation] : [], + ), + ); const releases = await Promise.allSettled( - [...this.#terminals.values()].map((terminal) => this.#releaseTerminal(context, terminal)), + [...this.#terminals.values()].flatMap((terminal) => + ownsTurn(terminal.reservation.turn) + ? [ + this.#releaseTerminal( + context, + terminal, + admittedKills.get(terminal) ?? terminal.killTask, + ), + ] + : [], + ), ); const failures = [ ...creations.flatMap((result) => @@ -421,16 +542,26 @@ export class AcpTerminalManager { } } - #releaseTerminal(context: AgentDriverContext, terminal: AcpTerminalState): Promise { - return (terminal.releaseTask ??= this.#runRelease(context, terminal).catch((error: unknown) => { - if (this.#terminals.get(terminal.id) === terminal) { - terminal.releaseTask = null; - } - throw error; - })); + #releaseTerminal( + context: AgentDriverContext, + terminal: AcpTerminalState, + admittedKill: Promise | null = terminal.killTask, + ): Promise { + return (terminal.releaseTask ??= this.#runRelease(context, terminal, admittedKill).catch( + (error: unknown) => { + if (this.#terminals.get(terminal.id) === terminal) { + terminal.releaseTask = null; + } + throw error; + }, + )); } - async #runRelease(context: AgentDriverContext, terminal: AcpTerminalState): Promise { + async #runRelease( + context: AgentDriverContext, + terminal: AcpTerminalState, + admittedKill: Promise | null, + ): Promise { const wasRunning = terminal.closedStatus === null; if (wasRunning) { @@ -482,24 +613,51 @@ export class AcpTerminalManager { } terminal.exitStatus = terminal.closedStatus; } + if (terminal.hostMayOwn) { + await this.#publishCreated(context, terminal); + } await this.#publishExit(context, terminal); terminal.resolveExit(terminal.exitStatus); - if (terminal.committed) { - await this.#push(context, "driver.acp.terminal.released", [ - { - kind: "terminal.released", - payload: { - terminalId: terminal.id, - }, + if (admittedKill !== null) { + await admittedKill; + } else if (terminal.killEvent !== null) { + await this.#killTerminal(context, terminal); + } + + if (terminal.hostMayOwn) { + terminal.releaseEvent ??= { + kind: "terminal.released", + payload: { + terminalId: terminal.id, }, - ]); + sourceEventId: `acp.terminal.released:${terminal.id}`, + }; + await this.#push(context, "driver.acp.terminal.released", [terminal.releaseEvent]); } if (this.#terminals.get(terminal.id) === terminal) { releaseLinuxProcessMarker(terminal.marker); - this.#terminals.delete(terminal.id); + this.#removeTerminal(terminal); + } + } + + #removeTerminal(terminal: AcpTerminalState): void { + if (this.#terminals.get(terminal.id) !== terminal) { + return; } + + this.#terminals.delete(terminal.id); + this.#releaseReservation(terminal.reservation); + } + + #releaseReservation(reservation: AcpTerminalReservation): void { + if (!reservation.active) { + return; + } + + reservation.active = false; + this.#terminalReservations -= 1; } async #completeExit( @@ -544,8 +702,11 @@ export class AcpTerminalManager { } terminal.exitStatus = status; - await this.#publishExit(context, terminal); + const publication = this.#publishExit(context, terminal); terminal.resolveExit(status); + void publication.catch((error: unknown) => { + this.#warnPushFailure(context, "driver.acp.terminal.exited", error); + }); } #failSupervision(context: AgentDriverContext, terminal: AcpTerminalState, error: Error): void { @@ -569,7 +730,7 @@ export class AcpTerminalManager { terminal.output = appended.output; terminal.truncated ||= appended.truncated; - if (!terminal.committed) { + if (!terminal.hostMayOwn) { return; } @@ -638,28 +799,65 @@ export class AcpTerminalManager { events: DriverEventInput[], ): Promise { return this.#push(context, reason, events).catch((error: unknown) => { - context.logger.warn("driver.acp.terminal.event_push.failed", { - message: error instanceof Error ? error.message : "terminal event push failed", - reason, - }); + this.#warnPushFailure(context, reason, error); + }); + } + + #warnPushFailure(context: AgentDriverContext, reason: string, error: unknown): void { + context.logger.warn("driver.acp.terminal.event_push.failed", { + message: error instanceof Error ? error.message : "terminal event push failed", + reason, }); } #publishExit(context: AgentDriverContext, terminal: AcpTerminalState): Promise { - if (!terminal.committed || terminal.exitStatus === null) { + if (!terminal.hostMayOwn || terminal.exitStatus === null) { return Promise.resolve(); } - return (terminal.exitEventTask ??= this.#pushBestEffort(context, "driver.acp.terminal.exited", [ - { - kind: "terminal.exited", - payload: { - exitCode: terminal.exitStatus.exitCode, - signal: terminal.exitStatus.signal, - terminalId: terminal.id, - }, + terminal.exitEvent ??= { + kind: "terminal.exited", + payload: { + exitCode: terminal.exitStatus.exitCode, + signal: terminal.exitStatus.signal, + terminalId: terminal.id, }, - ])); + sourceEventId: `acp.terminal.exited:${terminal.id}`, + }; + if (terminal.exitEventTask !== null) { + return terminal.exitEventTask; + } + + const operation = this.#push(context, "driver.acp.terminal.exited", [terminal.exitEvent]); + let task: Promise; + task = operation.catch((error: unknown) => { + if (terminal.exitEventTask === task) { + terminal.exitEventTask = null; + } + throw error; + }); + terminal.exitEventTask = task; + return task; + } + + #publishCreated(context: AgentDriverContext, terminal: AcpTerminalState): Promise { + if (terminal.createEvent === null) { + return Promise.resolve(); + } + if (terminal.createEventTask !== null) { + return terminal.createEventTask; + } + + const operation = this.#push(context, "driver.acp.terminal.created", [terminal.createEvent]); + let task: Promise; + task = operation.catch((error: unknown) => { + if (terminal.createEventTask === task) { + terminal.createEventTask = null; + } + throw error; + }); + terminal.createEventTask = task; + return task; } #requireTerminal(params: unknown): AcpTerminalState { @@ -671,7 +869,11 @@ export class AcpTerminalManager { const terminal = this.#terminals.get(terminalId); - if (!terminal || (!terminal.committed && !terminal.orphaned)) { + if ( + !terminal || + terminal.releaseTask !== null || + (!terminal.hostMayOwn && !terminal.orphaned) + ) { throw new Error(`ACP terminal does not exist: ${terminalId}.`); } diff --git a/src/runtimes/acp/acp-tool-events.ts b/src/runtimes/acp/acp-tool-events.ts index 0a42752..4b37aa8 100644 --- a/src/runtimes/acp/acp-tool-events.ts +++ b/src/runtimes/acp/acp-tool-events.ts @@ -3,9 +3,9 @@ import { isDeepStrictEqual } from "node:util"; import type { DriverEventInput } from "../../protocol/events"; import type { RunId } from "../../protocol/id"; import { + MAX_ACP_LOSSLESS_EVENT_BYTES, isRecord, readNonEmptyString, - readNullableString, readNumber, readRecord, readString, @@ -13,7 +13,20 @@ import { } from "./acp-types"; import type { JsonObject } from "./acp-types"; -export type RuntimeToolStatus = "completed" | "failed" | "running"; +export type RuntimeToolStatus = "cancelled" | "completed" | "failed" | "running"; + +// Completed calls are retained only to suppress bounded late replays. They do +// not participate in terminal settlement admission: only open calls can emit +// terminal closures. The cache has its own independent memory bound. +const MAX_ACP_COMPLETED_TOOL_HISTORY_BYTES = MAX_ACP_LOSSLESS_EVENT_BYTES; +const MAX_ACP_COMPLETED_TOOL_HISTORY_ITEMS = 1_024; + +interface AcpToolState { + readonly completed: boolean; + readonly hasNonzeroExit: boolean; + readonly snapshot: JsonObject; + readonly started: boolean; +} function readToolDisplayString(value: unknown): string | undefined { const display = stringifyForDisplay(value); @@ -41,24 +54,52 @@ function hasNonzeroExecuteExit(kind: unknown, update: JsonObject | null): boolea } export class AcpToolEventState { - readonly #completed = new Set(); - readonly #nonzeroExecuteExits = new Set(); - readonly #snapshots = new Map(); - readonly #started = new Set(); + #hadActivity = false; + #tools = new Map(); hasActivity(): boolean { - return this.#started.size > 0; + return this.#hadActivity; } hasStarted(toolCallId: string): boolean { - return this.#started.has(toolCallId); + return this.#tools.get(toolCallId)?.started ?? false; + } + + openItemCount(): number { + let count = 0; + + for (const tool of this.#tools.values()) { + if (tool.started && !tool.completed) { + count += 1; + } + } + + return count; + } + + retainedOpenState(): readonly JsonObject[] { + return [...this.#tools.values()].flatMap((tool) => + tool.started && !tool.completed ? [tool.snapshot] : [], + ); + } + + compactHistory(): void { + this.#trimCompletedHistory(); } clear(): void { - this.#completed.clear(); - this.#nonzeroExecuteExits.clear(); - this.#snapshots.clear(); - this.#started.clear(); + this.#hadActivity = false; + this.#tools.clear(); + } + + checkpoint(): () => void { + const hadActivity = this.#hadActivity; + const tools = new Map([...this.#tools].map(([toolCallId, tool]) => [toolCallId, { ...tool }])); + + return () => { + this.#hadActivity = hadActivity; + this.#tools = tools; + }; } patch(input: { @@ -67,40 +108,48 @@ export class AcpToolEventState { toolCallId: string; update: JsonObject | null; }): { changed: boolean; payload: JsonObject; status: RuntimeToolStatus } { - const previous = this.#snapshots.get(input.toolCallId); - const previousStatus = previous?.["status"]; - const kind = readNonEmptyString(input.update, "kind") ?? previous?.["kind"] ?? "tool"; + const previous = this.#tools.get(input.toolCallId); + const previousSnapshot = previous?.snapshot; + const previousStatus = previousSnapshot?.["status"]; + const kind = readNonEmptyString(input.update, "kind") ?? previousSnapshot?.["kind"] ?? "tool"; const nextStatus = input.status ?? "running"; - if (hasNonzeroExecuteExit(kind, input.update)) { - this.#nonzeroExecuteExits.add(input.toolCallId); - } + const hasNonzeroExit = + (previous?.hasNonzeroExit ?? false) || hasNonzeroExecuteExit(kind, input.update); const status = - previousStatus === "completed" || previousStatus === "failed" + previousStatus === "cancelled" || + previousStatus === "completed" || + previousStatus === "failed" ? previousStatus - : nextStatus === "completed" && this.#nonzeroExecuteExits.has(input.toolCallId) + : nextStatus === "completed" && hasNonzeroExit ? "failed" : nextStatus; // The projection layer links tool calls to their assistant message via // parentMessageId; keep the first observed parent for the call's lifetime. const parentMessageId = - (typeof previous?.["parentMessageId"] === "string" - ? previous["parentMessageId"] + (typeof previousSnapshot?.["parentMessageId"] === "string" + ? previousSnapshot["parentMessageId"] : undefined) ?? input.parentMessageId; + const title = readNonEmptyString(input.update, "title") ?? previousSnapshot?.["title"]; const payload = { - ...previous, + ...previousSnapshot, ...toToolCallPayload(input.toolCallId, status, input.update), kind, ...(parentMessageId === undefined ? {} : { parentMessageId }), status, - title: readNullableString(input.update, "title") ?? previous?.["title"] ?? null, + ...(typeof title === "string" ? { title } : {}), toolCallId: input.toolCallId, }; - const changed = previous === undefined || !isDeepStrictEqual(previous, payload); + const changed = previousSnapshot === undefined || !isDeepStrictEqual(previousSnapshot, payload); - if (changed) { - this.#snapshots.set(input.toolCallId, structuredClone(payload)); + if (changed || hasNonzeroExit !== previous?.hasNonzeroExit) { + this.#tools.set(input.toolCallId, { + completed: previous?.completed ?? false, + hasNonzeroExit, + snapshot: changed ? structuredClone(payload) : previous!.snapshot, + started: previous?.started ?? false, + }); } return { changed, payload, status }; @@ -112,18 +161,24 @@ export class AcpToolEventState { toolCallId: string; update: JsonObject | null; }): DriverEventInput | null { - if (this.#completed.has(input.toolCallId)) { + const tool = this.#tools.get(input.toolCallId); + + if (tool === undefined) { + throw new Error("ACP tool completion requires a projected tool call."); + } + if (tool.completed) { + this.#trimCompletedHistory(); return null; } - this.#completed.add(input.toolCallId); + this.#tools.set(input.toolCallId, { ...tool, completed: true }); + this.#trimCompletedHistory(); return { kind: "item.completed", payload: { error: input.status === "failed" ? readString(input.update, "error") : undefined, itemId: input.toolCallId, itemType: "tool_call", - result: input.update?.["rawOutput"], status: input.status, }, runId: input.runId, @@ -137,16 +192,16 @@ export class AcpToolEventState { }): DriverEventInput[] { const events: DriverEventInput[] = []; - for (const itemId of this.#started) { - if (this.#completed.has(itemId)) { + for (const [itemId, tool] of this.#tools) { + if (!tool.started || tool.completed) { continue; } - this.#completed.add(itemId); + this.#tools.set(itemId, { ...tool, completed: true }); events.push({ kind: "tool.call.updated", payload: { - ...this.#snapshots.get(itemId), + ...(input.error === undefined ? {} : { error: input.error }), status: input.status, toolCallId: itemId, }, @@ -164,6 +219,8 @@ export class AcpToolEventState { }); } + this.#trimCompletedHistory(); + return events; } @@ -173,11 +230,17 @@ export class AcpToolEventState { title: string; toolCallId: string; }): DriverEventInput[] { - if (this.#started.has(input.toolCallId)) { + const tool = this.#tools.get(input.toolCallId); + + if (tool?.started) { return []; } + if (tool === undefined) { + throw new Error("ACP tool start requires a projected tool call."); + } - this.#started.add(input.toolCallId); + this.#hadActivity = true; + this.#tools.set(input.toolCallId, { ...tool, started: true }); return [ { kind: "item.started", @@ -191,6 +254,29 @@ export class AcpToolEventState { }, ]; } + + #trimCompletedHistory(): void { + const completed = [...this.#tools].filter(([, tool]) => tool.completed); + let retainedItems = completed.length; + let bytes = completed.reduce( + (total, [toolCallId, tool]) => + total + Buffer.byteLength(JSON.stringify([toolCallId, tool.snapshot]), "utf8"), + 0, + ); + + for (const [toolCallId, tool] of completed) { + if ( + retainedItems <= MAX_ACP_COMPLETED_TOOL_HISTORY_ITEMS && + bytes <= MAX_ACP_COMPLETED_TOOL_HISTORY_BYTES + ) { + break; + } + + this.#tools.delete(toolCallId); + retainedItems -= 1; + bytes -= Buffer.byteLength(JSON.stringify([toolCallId, tool.snapshot]), "utf8"); + } + } } export function toRuntimeToolStatus(status: string | null): RuntimeToolStatus { @@ -198,7 +284,7 @@ export function toRuntimeToolStatus(status: string | null): RuntimeToolStatus { return "completed"; } - if (status === "failed" || status === "cancelled") { + if (status === "failed") { return "failed"; } @@ -212,9 +298,10 @@ export function toToolCallPayload( ): JsonObject { const content = readToolContentString(update?.["content"]); const kind = readNonEmptyString(update, "kind"); + const name = readNonEmptyString(update, "name"); const rawInput = readToolDisplayString(update?.["rawInput"]); const rawOutput = readToolDisplayString(update?.["rawOutput"]); - const title = readNullableString(update, "title"); + const title = readNonEmptyString(update, "title"); const locations = update?.["locations"]; return { @@ -222,7 +309,8 @@ export function toToolCallPayload( ...(kind === null ? {} : { kind }), ...(locations === undefined || locations === null ? {} : { locations }), ...(rawInput === undefined ? {} : { rawInput }), - ...(rawOutput === undefined ? {} : { rawOutput }), + ...(rawOutput === undefined || rawOutput === content ? {} : { rawOutput }), + ...(name === null ? {} : { name }), status, ...(title === undefined || title === null ? {} : { title }), toolCallId, diff --git a/src/runtimes/acp/acp-turn-controller.ts b/src/runtimes/acp/acp-turn-controller.ts index cbb62a6..19f53c3 100644 --- a/src/runtimes/acp/acp-turn-controller.ts +++ b/src/runtimes/acp/acp-turn-controller.ts @@ -1,5 +1,5 @@ import { methods as acpMethods } from "@agentclientprotocol/sdk"; -import type { ClientContext } from "@agentclientprotocol/sdk"; +import type { ClientContext, StopReason } from "@agentclientprotocol/sdk"; import type { AgentDriverContext } from "../../core/agent-driver-backend"; import { ACTIVE_TURN_CANCEL_GRACE_MS } from "../../core/driver-command-dispatcher"; @@ -9,30 +9,64 @@ import { } from "../../core/driver-runtime-state"; import { summarizeRuntimeCommandInput } from "../../observability/driver-debug"; import type { DriverEventInput } from "../../protocol/events"; -import type { DriverHostIntegrationSnapshot } from "../../protocol/host-integration"; import { createDriverId } from "../../protocol/id"; import type { MessageId, RunId } from "../../protocol/id"; import type { RuntimeCommandInput } from "../../runtime-command"; import { raceWithAbort } from "../../utils/async"; +import { DriverCompletedTerminalSupersededError } from "../driver-event-publisher"; import type { AcpClientRequestHandler } from "./acp-client-request-handler"; import { toRequestMeta } from "./acp-configuration"; -import { AcpTurnEventState, toPromptStartEvents } from "./acp-event-translator"; +import { AcpAssistantTranscriptState } from "./acp-assistant-transcript-state"; +import { toPromptStartEvents } from "./acp-session-events"; interface ActiveAcpTurn { readonly cancellation: AbortController; cancellationBarrier: Promise | null; cancellationReason: string | null; cancellationRequest: Promise | null; - cancelRequested: boolean; + cancellationRequestedByHost: boolean; readonly drainCancellation: AbortController; drainDeadline: ReturnType | null; fatal: { readonly cleanup: Promise; readonly error: Error } | null; + readonly promptCancellation: AbortController; providerPromptAdmitted: boolean; - readonly providerPromptSettled: ReturnType>; + promptResponseAccepted: boolean; + readonly resumeCancellation: AbortController; + resumeCancellationDetach: (() => void) | null; readonly runId: RunId; + readonly runSignal: AbortSignal | null; + readonly settled: ReturnType>; + readonly terminalTurn: number; terminalStarted: boolean; } +function createActiveTurn( + runId: RunId, + terminalTurn: number, + runSignal?: AbortSignal, +): ActiveAcpTurn { + return { + cancellation: new AbortController(), + cancellationBarrier: null, + cancellationReason: null, + cancellationRequest: null, + cancellationRequestedByHost: false, + drainCancellation: new AbortController(), + drainDeadline: null, + fatal: null, + promptCancellation: new AbortController(), + providerPromptAdmitted: false, + promptResponseAccepted: false, + resumeCancellation: new AbortController(), + resumeCancellationDetach: null, + runId, + runSignal: runSignal ?? null, + settled: Promise.withResolvers(), + terminalStarted: false, + terminalTurn, + }; +} + class AcpPromptTerminalError extends Error { override readonly name = "AcpPromptTerminalError"; @@ -46,6 +80,7 @@ async function drainTurnWork( signal?: AbortSignal, ): Promise { const results = await Promise.allSettled([ + clientRequests.drainTurnFileWrites(signal), clientRequests.drainPermissions(signal), signal === undefined ? clientRequests.drainUpdates() @@ -65,19 +100,27 @@ function startDrainDeadline(active: ActiveAcpTurn): void { ); } -function requestCancellation(active: ActiveAcpTurn, reason: string): void { - if (active.cancelRequested || active.fatal !== null || active.terminalStarted) { +function requestCancellation( + active: ActiveAcpTurn, + reason: string, + allowTerminalStarted = false, + interruptPrompt = true, +): void { + if ( + active.cancellationReason !== null || + active.fatal !== null || + (active.terminalStarted && !allowTerminalStarted) + ) { return; } - active.cancelRequested = true; active.cancellationReason = reason; startDrainDeadline(active); - active.cancellation.abort(new DriverTurnCancelledError(reason)); -} - -function readFatal(active: ActiveAcpTurn): ActiveAcpTurn["fatal"] { - return active.fatal; + const cancellation = new DriverTurnCancelledError(reason); + active.cancellation.abort(cancellation); + if (interruptPrompt) { + active.promptCancellation.abort(cancellation); + } } export type AcpTurnEventPush = ( @@ -86,24 +129,55 @@ export type AcpTurnEventPush = ( events: DriverEventInput[], ) => Promise; -export type AcpCancelledTurnBarrier = (context: AgentDriverContext) => Promise; +export type AcpTurnTerminalPush = ( + context: AgentDriverContext, + reason: string, + closures: readonly DriverEventInput[], + terminal: DriverEventInput, + cancellationSignal?: AbortSignal, +) => Promise; + +export type AcpCancelledTurnBarrier = ( + context: AgentDriverContext, + providerPromptAdmitted: boolean, + resumeSignal: AbortSignal, +) => Promise; + +function parsePromptStopReason(value: unknown): StopReason { + switch (value) { + case "cancelled": + case "end_turn": + case "max_tokens": + case "max_turn_requests": + case "refusal": { + return value; + } + default: { + throw new Error("ACP prompt response contains an invalid stop reason."); + } + } +} export class AcpTurnController { #active: ActiveAcpTurn | null = null; readonly #cancelledTurnBarrier: AcpCancelledTurnBarrier; - readonly events = new AcpTurnEventState(); + readonly events = new AcpAssistantTranscriptState(); readonly #push: AcpTurnEventPush; + readonly #pushTerminal: AcpTurnTerminalPush; constructor( push: AcpTurnEventPush, cancelledTurnBarrier: AcpCancelledTurnBarrier = async () => {}, + pushTerminal: AcpTurnTerminalPush = async (context, reason, closures, terminal) => + push(context, reason, [...closures, terminal]), ) { this.#push = push; this.#cancelledTurnBarrier = cancelledTurnBarrier; + this.#pushTerminal = pushTerminal; } isCancelling(): boolean { - return this.#active?.cancelRequested ?? false; + return this.#active !== null && this.#active.cancellationReason !== null; } activeSignal(): AbortSignal | undefined { @@ -112,20 +186,25 @@ export class AcpTurnController { abort(reason: string): void { if (this.#active !== null) { - requestCancellation(this.#active, reason); + this.#active.cancellationRequestedByHost = true; + this.#active.resumeCancellation.abort(); + requestCancellation(this.#active, reason, false, false); } } - failActive(error: Error, cleanup: Promise): boolean { + routeFatal(error: Error, cleanup: Promise): Promise | null { const active = this.#active; - if (active === null || active.terminalStarted) { - return false; + if (active === null) { + return Promise.resolve(); + } + if (active.promptResponseAccepted || active.terminalStarted) { + return active.settled.promise; } active.fatal ??= { cleanup, error }; active.cancellation.abort(error); void cleanup.catch(() => {}); - return true; + return null; } async handleInput( @@ -134,7 +213,6 @@ export class AcpTurnController { runId: RunId, connection: ClientContext, sessionId: string, - hostSnapshot: DriverHostIntegrationSnapshot, clientRequests: AcpClientRequestHandler, signal?: AbortSignal, ): Promise { @@ -142,37 +220,43 @@ export class AcpTurnController { throw new Error("ACP driver backend already has an active turn."); } + clientRequests.openFileWriteIngress(); const messageId = createDriverId() as MessageId; - const active = { - cancellation: new AbortController(), - cancellationBarrier: null, - cancellationReason: null, - cancellationRequest: null, - cancelRequested: false, - drainCancellation: new AbortController(), - drainDeadline: null, - fatal: null, - providerPromptAdmitted: false, - providerPromptSettled: Promise.withResolvers(), - runId, - terminalStarted: false, - }; + const active = createActiveTurn(runId, clientRequests.beginTurnTerminals(), signal); this.#active = active; - this.events.begin({ messageId, runId, sessionId }); - const onAbort = () => + this.events.begin({ messageId, runId }); + const onAbort = () => { + active.cancellationRequestedByHost = true; + const cancellation = + signal?.reason instanceof DriverTurnCancelledError ? signal.reason : null; + if (cancellation?.resumeAllowed) { + const preventResume = () => active.resumeCancellation.abort(); + cancellation.resumeSignal.addEventListener("abort", preventResume, { once: true }); + active.resumeCancellationDetach = () => + cancellation.resumeSignal.removeEventListener("abort", preventResume); + if (cancellation.resumeSignal.aborted) { + preventResume(); + } + } else { + active.resumeCancellation.abort(); + } requestCancellation( active, signal?.reason instanceof Error ? signal.reason.message : "ACP driver backend turn was cancelled.", + true, ); + }; signal?.addEventListener("abort", onAbort, { once: true }); if (signal?.aborted) { onAbort(); } clientRequests.openPermissionIngress(); - clientRequests.openTurnUpdateIngress(); + clientRequests.openTurnTranscriptIngress(); let drainTask: Promise | null = null; + let preserveEventState = false; + let terminalDelivered = false; const drain = async () => { try { await (drainTask ??= drainTurnWork(clientRequests, active.drainCancellation.signal)); @@ -188,6 +272,34 @@ export class AcpTurnController { await drainTask; } }; + const publishTerminal = async ( + reason: string | ((events: readonly DriverEventInput[]) => string), + prepare: () => DriverEventInput[], + ): Promise => { + const restore = this.events.checkpoint(); + + try { + const events = prepare(); + await this.#pushTerminalEvents( + context, + typeof reason === "string" ? reason : reason(events), + events, + ); + terminalDelivered = true; + return events; + } catch (error) { + if (error === active.runSignal?.reason) { + restore(); + } else if ( + !(error instanceof DriverCompletedTerminalSupersededError) || + error.cause !== active.runSignal?.reason + ) { + restore(); + preserveEventState = true; + } + throw error; + } + }; context.logger.info("driver.acp.prompt.sending", { sessionId, @@ -205,61 +317,89 @@ export class AcpTurnController { toPromptStartEvents({ messageId, runId, text: input.text }), ); - if (active.cancelRequested) { + if (active.cancellationReason !== null) { + clientRequests.closeFileWriteIngress(); clientRequests.closePermissionIngress(); - clientRequests.closeTurnUpdateIngress(); + clientRequests.closeTurnTranscriptIngress(); await drain(); await this.#publishCancellationRequest( context, active, active.cancellationReason ?? "ACP driver backend turn was cancelled.", ); + await this.#crossCancelledTurnBarrier(context, active, clientRequests); active.terminalStarted = true; - await this.#push( - context, - "driver.acp.prompt.cancelled", + await publishTerminal("driver.acp.prompt.cancelled", () => this.events.completePrompt("cancelled", null), ); throw new DriverTurnCancelledError("ACP driver backend turn was cancelled."); } active.providerPromptAdmitted = true; - const promptResult = await connection.request(acpMethods.agent.session.prompt, { - _meta: { - ...toRequestMeta({ sessionContext: hostSnapshot.sessionContext }), - "mosoo.ai/messageId": messageId, - }, - prompt: [{ text: input.text, type: "text" }], - sessionId, - }); - active.providerPromptSettled.resolve(true); + const promptResult = await raceWithAbort( + connection.request(acpMethods.agent.session.prompt, { + _meta: { + ...toRequestMeta({ sessionContext: context.payload.execution.session.context }), + "mosoo.ai/messageId": messageId, + }, + prompt: [{ text: input.text, type: "text" }], + sessionId, + }), + active.promptCancellation.signal, + ); + clientRequests.closeFileWriteIngress(); + if (active.fatal !== null) { + throw active.fatal.error; + } + const providerStopReason = parsePromptStopReason(promptResult.stopReason); + active.promptResponseAccepted = true; clientRequests.closePermissionIngress(); - clientRequests.closeTurnUpdateIngress(); - if (promptResult.stopReason === "cancelled") { + clientRequests.closeTurnTranscriptIngress(); + if (providerStopReason === "cancelled") { requestCancellation(active, "ACP provider cancelled the turn."); } await drain(); - const stopReason = active.cancelRequested ? "cancelled" : promptResult.stopReason; - const promptCancelled = active.cancelRequested || promptResult.stopReason === "cancelled"; + const stopReason = active.cancellationReason !== null ? "cancelled" : providerStopReason; + const promptCancelled = + active.cancellationReason !== null || providerStopReason === "cancelled"; + const cancelledByProvider = + providerStopReason === "cancelled" && !active.cancellationRequestedByHost; if (promptCancelled) { - await this.#crossCancelledTurnBarrier(context, active); + if (active.cancellationRequestedByHost) { + await this.#publishCancellationRequest( + context, + active, + active.cancellationReason ?? "ACP driver backend turn was cancelled.", + ); + } + await this.#crossCancelledTurnBarrier(context, active, clientRequests); + if (active.cancellationRequestedByHost) { + await this.#publishCancellationRequest( + context, + active, + active.cancellationReason ?? "ACP driver backend turn was cancelled.", + ); + } } - const completionEvents = this.events.completePrompt(stopReason, promptResult.usage); - const promptFailed = completionEvents.some((event) => event.kind === "run.failed"); - active.terminalStarted = true; - await this.#push( - context, - promptCancelled - ? "driver.acp.prompt.cancelled" - : promptFailed - ? "driver.acp.prompt.failed" - : "driver.acp.prompt.completed", - completionEvents, + const completionEvents = await publishTerminal( + (events) => + promptCancelled + ? "driver.acp.prompt.cancelled" + : events.some((event) => event.kind === "run.failed") + ? "driver.acp.prompt.failed" + : "driver.acp.prompt.completed", + () => + this.events.completePrompt( + stopReason, + promptResult.usage, + cancelledByProvider ? "provider" : "user", + ), ); + const promptFailed = completionEvents.some((event) => event.kind === "run.failed"); context.logger.info( promptFailed ? "driver.acp.prompt.failed" : "driver.acp.prompt.completed", - { sessionId, stopReason: promptResult.stopReason }, + { sessionId, stopReason: providerStopReason }, ); if (promptCancelled) { @@ -270,47 +410,79 @@ export class AcpTurnController { throw new AcpPromptTerminalError(stopReason); } } catch (error) { - active.providerPromptSettled.resolve(active.providerPromptAdmitted && active.cancelRequested); + clientRequests.closeFileWriteIngress(); clientRequests.closePermissionIngress(); - clientRequests.closeTurnUpdateIngress(); - const fatal = readFatal(active); - const fatalCleanup = - fatal === null - ? null - : Promise.allSettled([fatal.cleanup, clientRequests.stopTerminals(context)]); - await drain(); + clientRequests.closeTurnTranscriptIngress(); + if (preserveEventState) { + throw error; + } + let catchDrainError: unknown = null; + try { + await drain(); + } catch (drainError) { + catchDrainError = drainError; + context.logger.warn("driver.acp.prompt.drain.failed", { + message: drainError instanceof Error ? drainError.message : "ACP turn drain failed.", + }); + } + + const fatal = active.fatal; + + if (fatal !== null) { + active.terminalStarted = true; + const cleanupResults = await Promise.allSettled([ + fatal.cleanup, + clientRequests.stopTerminals(context), + ]); + const cleanupFailures = cleanupResults.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); - if (fatal !== null && fatalCleanup !== null) { - const cleanupResults = await fatalCleanup; - const cleanupFailure = cleanupResults.find((result) => result.status === "rejected"); - if (cleanupFailure?.status === "rejected") { + try { + await publishTerminal("driver.acp.provider.failed", () => + this.events.failPrompt({ + code: "acp.provider_failed", + message: fatal.error.message, + }), + ); + } catch (terminalError) { + if (cleanupFailures.length > 0) { + throw new AggregateError( + [fatal.error, ...cleanupFailures, terminalError], + "ACP provider failure cleanup and terminal publication failed.", + ); + } + throw terminalError; + } + if (cleanupFailures.length > 0) { throw new AggregateError( - [fatal.error, cleanupFailure.reason], + [fatal.error, ...cleanupFailures], "ACP provider failure cleanup failed.", ); } + throw fatal.error; + } + if (catchDrainError !== null) { + const message = + catchDrainError instanceof Error ? catchDrainError.message : "ACP turn drain failed."; active.terminalStarted = true; - await this.#push( - context, - "driver.acp.provider.failed", - this.events.failPrompt({ - code: "acp.provider_failed", - message: fatal.error.message, - }), + await publishTerminal("driver.acp.prompt.failed", () => + this.events.failPrompt({ code: "acp.turn_drain_failed", message }), ); - throw fatal.error; + throw catchDrainError; } - if (error instanceof DriverTurnCancelledError || error instanceof AcpPromptTerminalError) { + if ( + error instanceof AcpPromptTerminalError || + (error instanceof DriverTurnCancelledError && terminalDelivered) + ) { throw error; } if (error instanceof DriverTurnCancellationCleanupError) { active.terminalStarted = true; - await this.#push( - context, - "driver.acp.prompt.failed", + await publishTerminal("driver.acp.prompt.failed", () => this.events.failPrompt({ code: "acp.cancel_cleanup_failed", message: error.message, @@ -319,14 +491,26 @@ export class AcpTurnController { throw error; } - if (active.cancelRequested) { + if (active.cancellationReason !== null) { try { - await this.#crossCancelledTurnBarrier(context, active); + if (active.cancellationRequestedByHost) { + await this.#publishCancellationRequest( + context, + active, + active.cancellationReason ?? "ACP driver backend turn was cancelled.", + ); + } + await this.#crossCancelledTurnBarrier(context, active, clientRequests); + if (active.cancellationRequestedByHost) { + await this.#publishCancellationRequest( + context, + active, + active.cancellationReason ?? "ACP driver backend turn was cancelled.", + ); + } } catch (cleanupError) { active.terminalStarted = true; - await this.#push( - context, - "driver.acp.prompt.failed", + await publishTerminal("driver.acp.prompt.failed", () => this.events.failPrompt({ code: "acp.cancel_cleanup_failed", message: @@ -337,31 +521,33 @@ export class AcpTurnController { ); throw cleanupError; } - const events = - this.events.activeRunId() === null ? [] : this.events.completePrompt("cancelled", null); active.terminalStarted = true; - await this.#push(context, "driver.acp.prompt.cancelled", events); + await publishTerminal("driver.acp.prompt.cancelled", () => + this.events.activeRunId() === null ? [] : this.events.completePrompt("cancelled", null), + ); throw new DriverTurnCancelledError("ACP driver backend turn was cancelled."); } const message = error instanceof Error ? error.message : "ACP driver backend turn failed."; active.terminalStarted = true; - await this.#push( - context, - "driver.acp.prompt.failed", + await publishTerminal("driver.acp.prompt.failed", () => this.events.failPrompt({ code: "acp.turn_failed", message }), ); throw error; } finally { + clientRequests.closeFileWriteIngress(); clientRequests.closePermissionIngress(); - clientRequests.closeTurnUpdateIngress(); + clientRequests.closeTurnTranscriptIngress(); if (active.drainDeadline !== null) { clearTimeout(active.drainDeadline); } signal?.removeEventListener("abort", onAbort); - active.providerPromptSettled.resolve(false); + active.resumeCancellationDetach?.(); this.#active = null; - this.events.clear(); + if (!preserveEventState) { + this.events.clear(); + } + active.settled.resolve(); } } @@ -379,19 +565,12 @@ export class AcpTurnController { return; } + active.cancellationRequestedByHost = true; requestCancellation(active, "ACP driver backend turn was cancelled."); - const cancel = active.providerPromptAdmitted - ? connection.notify(acpMethods.agent.session.cancel, { sessionId }) - : Promise.resolve(); - const eventPush = this.#publishCancellationRequest(context, active, reason); - const [cancelResult, eventResult] = await Promise.allSettled([cancel, eventPush]); - - if (eventResult.status === "rejected") { - throw eventResult.reason; - } - if (cancelResult.status === "rejected" && !(await active.providerPromptSettled.promise)) { - throw cancelResult.reason; + if (active.providerPromptAdmitted) { + void connection.notify(acpMethods.agent.session.cancel, { sessionId }).catch(() => {}); } + void this.#publishCancellationRequest(context, active, reason).catch(() => {}); } #publishCancellationRequest( @@ -408,16 +587,49 @@ export class AcpTurnController { ])); } - async #crossCancelledTurnBarrier( + async #pushTerminalEvents( context: AgentDriverContext, - active: ActiveAcpTurn, + reason: string, + events: DriverEventInput[], ): Promise { - if (!active.providerPromptAdmitted) { + if (events.length === 0) { + await this.#push(context, reason, events); return; } + const terminal = events.at(-1)!; + + if ( + terminal.kind !== "run.cancelled" && + terminal.kind !== "run.completed" && + terminal.kind !== "run.failed" + ) { + throw new Error("ACP terminal event batch must end with a run terminal."); + } + + await this.#pushTerminal( + context, + reason, + events.slice(0, -1), + terminal, + terminal.kind === "run.completed" ? (this.#active?.runSignal ?? undefined) : undefined, + ); + } + + async #crossCancelledTurnBarrier( + context: AgentDriverContext, + active: ActiveAcpTurn, + clientRequests: AcpClientRequestHandler, + ): Promise { try { - await (active.cancellationBarrier ??= this.#cancelledTurnBarrier(context)); + await (active.cancellationBarrier ??= (async () => { + await clientRequests.stopTurnTerminals(context, active.terminalTurn); + await this.#cancelledTurnBarrier( + context, + active.providerPromptAdmitted, + active.resumeCancellation.signal, + ); + })()); } catch (error) { throw new DriverTurnCancellationCleanupError( `ACP cancelled turn process recycle failed: ${ diff --git a/src/runtimes/acp/acp-types.ts b/src/runtimes/acp/acp-types.ts index 2fcd543..a496c18 100644 --- a/src/runtimes/acp/acp-types.ts +++ b/src/runtimes/acp/acp-types.ts @@ -1,11 +1,33 @@ +import type { DriverEventInput } from "../../protocol/events"; import type { JsonObject } from "../provider-json"; +export const MAX_ACP_LOSSLESS_EVENT_BYTES = 512 * 1_024; + +// OpenCode reports cache read/write as separate Anthropic-style buckets. +export const ACP_USAGE_CONTRACT = "anthropic_bucketed"; + +export function assertBoundedLosslessEvents(events: DriverEventInput[]): DriverEventInput[] { + for (const event of events) { + if ( + event.delivery !== "best_effort" && + Buffer.byteLength(JSON.stringify(event), "utf8") > MAX_ACP_LOSSLESS_EVENT_BYTES + ) { + throw new RangeError( + `ACP ${event.kind} event exceeds ${MAX_ACP_LOSSLESS_EVENT_BYTES} UTF-8 bytes.`, + ); + } + } + + return events; +} + export { raceWithAbort } from "../../utils/async"; export { isRecord, readArray, readNonEmptyString, + readNumber, readRecord, readString, stringifyForDisplay, @@ -24,8 +46,3 @@ export function readNullableString( return typeof entry === "string" ? entry : undefined; } - -export function readNumber(value: JsonObject | null, key: string): number | null { - const entry = value?.[key]; - return typeof entry === "number" && Number.isFinite(entry) ? entry : null; -} diff --git a/src/runtimes/acp/contract-item-projector.ts b/src/runtimes/acp/contract-item-projector.ts deleted file mode 100644 index f20bbe0..0000000 --- a/src/runtimes/acp/contract-item-projector.ts +++ /dev/null @@ -1,337 +0,0 @@ -import { isDeepStrictEqual } from "node:util"; - -import type { SessionUpdate, ToolCall, ToolCallUpdate } from "@agentclientprotocol/sdk"; - -import { itemSchema, toolItemSchema } from "../../contract"; -import type { FileChange, ItemStatus, ToolItem } from "../../contract"; -import { - asJsonValue, - createProviderMeta, - ContractProjection, - nonEmpty, -} from "../contract-projection"; -import type { AcpContractTerminalProjector } from "./contract-terminal-projector"; -import { - itemStatus, - toChanges, - toContentBlocks, - toolCategory, - toolError, - toOutput, -} from "./contract-mapping"; - -const { cause: providerCause, provenance } = createProviderMeta("agent-client-protocol"); - -export interface AcpContractItemProjectorOptions { - readonly now: () => string; - readonly projection: ContractProjection; - readonly resolveId: (runId: string, kind: string, nativeId: string) => string; - readonly terminals: AcpContractTerminalProjector; -} - -export class AcpContractItemProjector { - readonly #now: () => string; - readonly #projection: ContractProjection; - readonly #resolveId: AcpContractItemProjectorOptions["resolveId"]; - readonly #terminals: AcpContractTerminalProjector; - - constructor(options: AcpContractItemProjectorOptions) { - this.#now = options.now; - this.#projection = options.projection; - this.#resolveId = options.resolveId; - this.#terminals = options.terminals; - } - - async putMessageChunk( - runId: string, - update: Extract< - SessionUpdate, - { sessionUpdate: "agent_message_chunk" | "agent_thought_chunk" } - >, - kind: "message" | "reasoning", - ): Promise { - const nativeId = update.messageId ?? `${runId}:anonymous:${kind}`; - const id = this.#resolveId(runId, kind, nativeId); - const event = `session/${update.sessionUpdate}`; - let item = this.#projection.item(runId, id); - - if (item === undefined) { - const now = this.#now(); - item = await this.#projection.putItem( - runId, - event, - providerCause(event, nativeId), - itemSchema.parse({ - audience: "participants", - content: [], - createdAt: now, - id, - kind, - ...(kind === "message" ? { phase: "final", role: "agent" } : {}), - provenance: provenance( - event, - update.messageId === null || update.messageId === undefined - ? undefined - : { messageId: update.messageId }, - ), - runId, - status: "active", - updatedAt: now, - }), - ); - } - - if (item.status !== "active" || item.kind !== kind) { - return; - } - - const channel = kind === "message" ? "message.text" : "reasoning.text"; - - if (update.content.type === "text") { - await this.#projection.appendText({ - cause: providerCause(event, nativeId), - channel, - delta: update.content.text, - event, - itemId: id, - runId, - }); - return; - } - - const checkpoint = await this.#projection.checkpointText({ - cause: providerCause(`${event}.checkpoint`, nativeId), - channel, - event: `${event}.checkpoint`, - itemId: id, - runId, - }); - const current = checkpoint ?? item; - - if (current.kind !== "message" && current.kind !== "reasoning") { - throw new Error("ACP v1 message chunk changed item kind while being projected."); - } - - await this.#projection.putItem( - runId, - event, - providerCause(event, nativeId), - itemSchema.parse({ - ...current, - content: [...current.content, ...toContentBlocks(update.content)], - updatedAt: this.#now(), - }), - ); - } - - async putTool( - runId: string, - update: ToolCall | ToolCallUpdate, - event: string, - ): Promise { - const id = this.#resolveId(runId, "tool", update.toolCallId); - const existing = this.#projection.item(runId, id); - - if (existing !== undefined && existing.kind !== "tool") { - throw new Error("ACP v1 tool update collided with a non-tool item."); - } - - const existingTool = existing?.kind === "tool" ? existing : undefined; - const now = this.#now(); - const title = nonEmpty(update.title, existingTool?.title ?? existingTool?.name ?? "Tool"); - const nextStatus = - update.status === undefined || update.status === null - ? (existingTool?.status ?? "active") - : itemStatus(update.status); - const status = - existingTool === undefined || existingTool.status === "active" - ? nextStatus - : existingTool.status; - const content = update.content ?? undefined; - const terminalIds = - content?.flatMap((entry) => (entry.type === "terminal" ? [entry.terminalId] : [])) ?? []; - const projectedTerminalIds: string[] = []; - - for (const terminalId of terminalIds) { - projectedTerminalIds.push(await this.#terminals.ensureTerminal(runId, terminalId)); - } - - const terminalItemId = - content === undefined ? existingTool?.terminalItemId : projectedTerminalIds[0]; - - const input = - update.rawInput === undefined || update.rawInput === null - ? existingTool?.input - : asJsonValue(update.rawInput); - const structuredOutput = - update.rawOutput === undefined || update.rawOutput === null - ? existingTool?.structuredOutput - : asJsonValue(update.rawOutput); - const output = content === undefined ? existingTool?.output : toOutput(content); - const locations = - update.locations === undefined || update.locations === null - ? existingTool?.locations - : update.locations.flatMap((location) => - location.path.trim().length === 0 - ? [] - : [ - { - ...(location.line === undefined || - location.line === null || - !Number.isSafeInteger(location.line) || - location.line < 1 - ? {} - : { line: location.line }), - path: location.path, - }, - ], - ); - const item = toolItemSchema.parse({ - audience: "participants", - category: - update.kind === undefined || update.kind === null - ? (existingTool?.category ?? "other") - : toolCategory(update.kind), - createdAt: existingTool?.createdAt ?? now, - ...(status === "active" ? {} : { endedAt: existingTool?.endedAt ?? now }), - ...(status === "failed" ? { error: existingTool?.error ?? toolError(title) } : {}), - id, - ...(input === undefined ? {} : { input }), - kind: "tool", - ...(locations === undefined ? {} : { locations }), - name: existingTool?.name ?? title, - origin: "provider", - ...(output === undefined ? {} : { output }), - provenance: provenance(event, { toolCallId: update.toolCallId }), - runId, - status, - ...(structuredOutput === undefined ? {} : { structuredOutput }), - ...(terminalItemId === undefined ? {} : { terminalItemId }), - title, - updatedAt: now, - }); - const changed = - existingTool === undefined || - !isDeepStrictEqual( - { ...existingTool, provenance: item.provenance, updatedAt: item.updatedAt }, - item, - ); - - if (changed) { - await this.#projection.putItem(runId, event, providerCause(event, update.toolCallId), item); - } - - await this.#putChanges( - runId, - update.toolCallId, - content === undefined ? undefined : toChanges(content), - status, - event, - now, - ); - - if (changed && item.status === "active") { - await this.#projection.replacePreview({ - channel: "tool.progress", - itemId: item.id, - runId, - text: item.title ?? item.name, - }); - } - - return changed ? item : existingTool; - } - - async #putChanges( - runId: string, - toolCallId: string, - changes: FileChange[] | undefined, - status: ItemStatus, - event: string, - now: string, - ): Promise { - const id = this.#resolveId(runId, "change", toolCallId); - const existing = this.#projection.item(runId, id); - - if (existing !== undefined && existing.kind !== "change") { - return; - } - - const nextStatus = - existing === undefined || existing.status === "active" ? status : existing.status; - - if ( - (changes === undefined && (existing === undefined || existing.status === nextStatus)) || - (changes?.length === 0 && existing === undefined) - ) { - return; - } - - const item = itemSchema.parse({ - audience: "participants", - changes: changes ?? existing?.changes, - createdAt: existing?.createdAt ?? now, - ...(nextStatus === "active" ? {} : { endedAt: existing?.endedAt ?? now }), - ...(nextStatus === "failed" ? { error: existing?.error ?? toolError("File change") } : {}), - id, - kind: "change", - provenance: provenance(event, { toolCallId }), - runId, - status: nextStatus, - updatedAt: now, - }); - - if ( - existing !== undefined && - isDeepStrictEqual( - { ...existing, provenance: item.provenance, updatedAt: item.updatedAt }, - item, - ) - ) { - return; - } - - await this.#projection.putItem( - runId, - `${event}.changes`, - providerCause(`${event}.changes`, toolCallId), - item, - ); - } - - async putPlan( - runId: string, - nativeId: string, - entries: Extract["entries"], - event: string, - ): Promise { - const id = this.#resolveId(runId, "plan", nativeId); - const existing = this.#projection.item(runId, id); - - if (existing !== undefined && (existing.kind !== "plan" || existing.status !== "active")) { - return; - } - - const now = this.#now(); - await this.#projection.putItem( - runId, - event, - providerCause(event, nativeId), - itemSchema.parse({ - audience: "participants", - createdAt: existing?.createdAt ?? now, - entries: entries.map((entry) => ({ - priority: entry.priority, - status: entry.status, - text: entry.content, - })), - id, - kind: "plan", - provenance: provenance(event, nativeId === "current" ? undefined : { planId: nativeId }), - runId, - status: "active", - updatedAt: now, - }), - ); - } -} diff --git a/src/runtimes/acp/contract-mapping.ts b/src/runtimes/acp/contract-mapping.ts deleted file mode 100644 index 14fbc6e..0000000 --- a/src/runtimes/acp/contract-mapping.ts +++ /dev/null @@ -1,219 +0,0 @@ -import type { - ContentBlock as AcpContentBlock, - SessionConfigOption, - ToolCall, - ToolCallContent, - ToolKind, - Usage, -} from "@agentclientprotocol/sdk"; - -import { configOptionSchema } from "../../contract"; -import type { - ConfigOption, - ContentBlock, - FileChange, - ItemStatus, - ProtocolError, - TokenUsage, - ToolItem, -} from "../../contract"; -import { asJsonValue, nonEmpty } from "../contract-projection"; - -const SELECT_GROUPS_EXTENSION = "agentclientprotocol.v1/select-groups"; - -function resourceName(uri: string): string { - try { - const path = new URL(uri).pathname; - return decodeURIComponent(path.split("/").filter(Boolean).at(-1) ?? "resource").slice(0, 1_024); - } catch { - return "resource"; - } -} - -export function toContentBlocks(block: AcpContentBlock): ContentBlock[] { - switch (block.type) { - case "text": - return [{ text: block.text, type: "text" }]; - case "image": - case "audio": - return [{ data: block.data, mediaType: block.mimeType, type: "inline_blob" }]; - case "resource_link": { - if (URL.canParse(block.uri)) { - return [ - { - ...(block.mimeType === undefined || block.mimeType === null - ? {} - : { mediaType: block.mimeType }), - name: nonEmpty(block.name, resourceName(block.uri)), - type: "resource_link", - uri: block.uri, - }, - ]; - } - - const value = asJsonValue(block); - return value === undefined ? [] : [{ type: "json", value }]; - } - case "resource": { - const resource = block.resource; - - if ("text" in resource) { - const content: ContentBlock[] = [{ text: resource.text, type: "text" }]; - - if (URL.canParse(resource.uri)) { - content.unshift({ - ...(resource.mimeType === undefined || resource.mimeType === null - ? {} - : { mediaType: resource.mimeType }), - name: resourceName(resource.uri), - type: "resource_link", - uri: resource.uri, - }); - } - - return content; - } - - return [ - { - data: resource.blob, - mediaType: resource.mimeType ?? "application/octet-stream", - name: resourceName(resource.uri), - type: "inline_blob", - }, - ]; - } - } -} - -export function toolCategory(kind: ToolKind | null | undefined): ToolItem["category"] { - switch (kind) { - case "read": - return "read"; - case "edit": - case "delete": - case "move": - return "edit"; - case "search": - return "search"; - case "execute": - return "execute"; - case "fetch": - return "fetch"; - default: - return "other"; - } -} - -export function itemStatus(status: ToolCall["status"] | null | undefined): ItemStatus { - return status === "completed" ? "completed" : status === "failed" ? "failed" : "active"; -} - -export function toolError(title: string): ProtocolError { - return { - code: "agent_client_protocol.tool_failed", - message: `${title} failed.`, - retryable: false, - }; -} - -export function toOutput(content: readonly ToolCallContent[]): ContentBlock[] { - return content.flatMap((entry) => - entry.type === "content" ? toContentBlocks(entry.content) : [], - ); -} - -export function toChanges(content: readonly ToolCallContent[]): FileChange[] { - return content.flatMap((entry) => { - if (entry.type !== "diff" || entry.path.trim().length === 0) { - return []; - } - - return [ - { - diff: { - type: "json", - value: { - newText: entry.newText, - oldText: entry.oldText ?? null, - }, - }, - operation: entry.oldText === undefined || entry.oldText === null ? "create" : "update", - path: entry.path, - }, - ]; - }); -} - -export function toUsage(usage: Usage, previous: TokenUsage | undefined): TokenUsage { - const input = usage.inputTokens; - const output = usage.outputTokens; - const cachedInput = usage.cachedReadTokens; - const reasoning = usage.thoughtTokens; - const total = usage.totalTokens; - - return { - ...previous, - ...(cachedInput !== undefined && - cachedInput !== null && - Number.isSafeInteger(cachedInput) && - cachedInput >= (previous?.cachedInput ?? 0) - ? { cachedInput } - : {}), - ...(Number.isSafeInteger(input) && input >= (previous?.input ?? 0) ? { input } : {}), - ...(Number.isSafeInteger(output) && output >= (previous?.output ?? 0) ? { output } : {}), - ...(reasoning !== undefined && - reasoning !== null && - Number.isSafeInteger(reasoning) && - reasoning >= (previous?.reasoning ?? 0) - ? { reasoning } - : {}), - ...(Number.isSafeInteger(total) && total >= (previous?.total ?? 0) ? { total } : {}), - }; -} - -function configDescription(value: string | null | undefined) { - return value === undefined || value === null ? {} : { description: value }; -} - -export function toConfigOptions(options: readonly SessionConfigOption[]): ConfigOption[] { - return options.map((option) => { - const base = { - ...(option.category === undefined || option.category === null || option.category.length === 0 - ? {} - : { category: option.category }), - ...configDescription(option.description), - id: option.id, - label: nonEmpty(option.name, option.id), - }; - - if (option.type === "boolean") { - return configOptionSchema.parse({ ...base, type: "boolean", value: option.currentValue }); - } - - const groups = option.options.flatMap((entry) => ("group" in entry ? [entry] : [])); - const choices = option.options.flatMap((entry) => ("group" in entry ? entry.options : [entry])); - - return configOptionSchema.parse({ - ...base, - choices: choices.map((choice) => ({ - ...configDescription(choice.description), - id: choice.value, - label: nonEmpty(choice.name, choice.value), - })), - ...(groups.length === 0 - ? {} - : { - extensions: { - [SELECT_GROUPS_EXTENSION]: groups.map((group) => ({ - id: group.group, - label: group.name, - optionIds: group.options.map((choice) => choice.value), - })), - }, - }), - type: "select", - value: option.currentValue, - }); - }); -} diff --git a/src/runtimes/acp/contract-permission-controller.ts b/src/runtimes/acp/contract-permission-controller.ts deleted file mode 100644 index a318ee3..0000000 --- a/src/runtimes/acp/contract-permission-controller.ts +++ /dev/null @@ -1,412 +0,0 @@ -import { isDeepStrictEqual } from "node:util"; - -import type { RequestPermissionRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk"; - -import { interactionSchema } from "../../contract"; -import type { Interaction, InteractionResolution } from "../../contract"; -import { - AuthorityOutcomeUnknownError, - createProviderMeta, - ContractProjection, - nonEmpty, -} from "../contract-projection"; -import type { AcpContractSessionUpdateInbox } from "./contract-session-update-inbox"; -import type { AcpContractItemProjector } from "./contract-item-projector"; - -const { cause: providerCause, provenance } = createProviderMeta("agent-client-protocol"); - -interface PermissionIntent { - readonly bytes: number; - released: boolean; - readonly receivedAt: string; - readonly request: RequestPermissionRequest; - readonly runId: string; -} - -interface PendingPermission { - readonly intent: PermissionIntent; - readonly interaction: Interaction; - opened: boolean; - readonly optionIds: ReadonlyMap; - response?: RequestPermissionResponse; - readonly toolCallId: string; -} - -interface OpeningPermission { - readonly intent: PermissionIntent; - readonly promise: Promise; -} - -interface UnknownPermission { - readonly error: AuthorityOutcomeUnknownError; - readonly intent: PermissionIntent; - retry?: Promise; -} - -export interface AcpContractPermissionControllerOptions { - readonly assertNativeSession: (sessionId: string) => void; - readonly createId: () => string; - readonly inbox: AcpContractSessionUpdateInbox; - readonly interactionTimeoutMs: number; - readonly items: AcpContractItemProjector; - readonly maxPendingPermissionBytes: number; - readonly now: () => string; - readonly projection: ContractProjection; - readonly resolveId: (runId: string, kind: string, nativeId: string) => string; - readonly withReceiptTime: (receivedAt: string, operation: () => Promise) => Promise; -} - -export class AcpContractPermissionController { - readonly #assertNativeSession: (sessionId: string) => void; - readonly #createId: () => string; - #disposed = false; - readonly #inbox: AcpContractSessionUpdateInbox; - readonly #interactionTimeoutMs: number; - readonly #items: AcpContractItemProjector; - readonly #maxPendingPermissionBytes: number; - readonly #now: () => string; - readonly #openingPermissions = new Map(); - readonly #pendingPermissions = new Map(); - #pendingPermissionBytes = 0; - readonly #projection: ContractProjection; - readonly #resolveId: AcpContractPermissionControllerOptions["resolveId"]; - readonly #textEncoder = new TextEncoder(); - #unknownPermission: UnknownPermission | undefined; - readonly #withReceiptTime: AcpContractPermissionControllerOptions["withReceiptTime"]; - - constructor(options: AcpContractPermissionControllerOptions) { - this.#assertNativeSession = options.assertNativeSession; - this.#createId = options.createId; - this.#inbox = options.inbox; - this.#interactionTimeoutMs = options.interactionTimeoutMs; - this.#items = options.items; - this.#maxPendingPermissionBytes = options.maxPendingPermissionBytes; - this.#now = options.now; - this.#projection = options.projection; - this.#resolveId = options.resolveId; - this.#withReceiptTime = options.withReceiptTime; - } - - async openPermission(runId: string, request: RequestPermissionRequest): Promise { - this.#assertActive(); - const snapshot = structuredClone(request); - const unknownAtAdmission = this.#unknownPermission; - const exactRetry = - unknownAtAdmission !== undefined && - unknownAtAdmission.intent.runId === runId && - isDeepStrictEqual(unknownAtAdmission.intent.request, snapshot) - ? unknownAtAdmission - : undefined; - - if (unknownAtAdmission !== undefined && exactRetry === undefined) { - throw unknownAtAdmission.error; - } - - if (exactRetry?.retry !== undefined) { - return exactRetry.retry; - } - - const toolCallId = snapshot.toolCall.toolCallId; - const openingAtAdmission = this.#openingPermissions.get(toolCallId); - - if (openingAtAdmission !== undefined) { - if ( - openingAtAdmission.intent.runId !== runId || - !isDeepStrictEqual(openingAtAdmission.intent.request, snapshot) - ) { - throw new Error(`ACP v1 permission request ${toolCallId} changed identity or content.`); - } - - return openingAtAdmission.promise; - } - - const existing = [...this.#pendingPermissions].find( - ([, candidate]) => candidate.toolCallId === toolCallId, - ); - - if (existing !== undefined) { - const [interactionId, pending] = existing; - - if (pending.intent.runId !== runId || !isDeepStrictEqual(pending.intent.request, snapshot)) { - throw new Error(`ACP v1 permission request ${toolCallId} changed identity or content.`); - } - - if (pending.opened) { - return interactionId; - } - } - - let intent = exactRetry?.intent; - - if (intent === undefined) { - const bytes = this.#textEncoder.encode(JSON.stringify(snapshot)).byteLength; - - if (bytes > this.#maxPendingPermissionBytes - this.#pendingPermissionBytes) { - throw new RangeError("ACP v1 pending permission budget is exhausted."); - } - - this.#pendingPermissionBytes += bytes; - intent = { - bytes, - receivedAt: this.#projection.now().toISOString(), - released: false, - request: snapshot, - runId, - }; - } - - const stableIntent = intent; - const opening = this.#inbox.enqueue(async () => { - try { - const unknown = this.#unknownPermission; - - if (unknown !== undefined && unknown !== unknownAtAdmission) { - throw unknown.error; - } - - const interactionId = await this.#withReceiptTime(stableIntent.receivedAt, () => - this.#openPermission(stableIntent), - ); - - if (this.#unknownPermission === unknownAtAdmission) { - this.#unknownPermission = undefined; - } - - return interactionId; - } catch (error) { - if (error instanceof AuthorityOutcomeUnknownError) { - this.#unknownPermission ??= { error, intent: stableIntent }; - } else if (this.#unknownPermission?.intent === stableIntent) { - this.#unknownPermission = undefined; - } - - throw error; - } finally { - if (!this.#permissionRetained(stableIntent)) { - this.#releasePermissionIntent(stableIntent); - } - } - }); - - const tracked = opening.finally(() => { - if (this.#openingPermissions.get(toolCallId)?.promise === tracked) { - this.#openingPermissions.delete(toolCallId); - } - - if (exactRetry !== undefined && exactRetry.retry === tracked) { - delete exactRetry.retry; - } - }); - this.#openingPermissions.set(toolCallId, { intent: stableIntent, promise: tracked }); - if (exactRetry !== undefined) { - exactRetry.retry = tracked; - } - - return tracked; - } - - async #openPermission(intent: PermissionIntent): Promise { - const { request, runId } = intent; - let pending: PendingPermission | undefined; - - try { - this.#assertActive(); - this.#assertNativeSession(request.sessionId); - - const existing = [...this.#pendingPermissions].find( - ([, candidate]) => candidate.toolCallId === request.toolCall.toolCallId, - ); - - if (existing !== undefined) { - const [interactionId, existingPermission] = existing; - - if ( - existingPermission.intent.runId !== runId || - !isDeepStrictEqual(existingPermission.intent.request, request) - ) { - throw new Error( - `ACP v1 permission request ${request.toolCall.toolCallId} changed identity or content.`, - ); - } - - if (!existingPermission.opened) { - try { - await this.#projection.putInteraction( - runId, - "permission/requested", - providerCause("permission/requested", existingPermission.toolCallId), - existingPermission.interaction, - ); - existingPermission.opened = true; - } catch (error) { - if (!(error instanceof AuthorityOutcomeUnknownError)) { - this.#dropPermission(interactionId); - } - - throw error; - } - } - - if (existingPermission.response !== undefined) { - this.#projection.releaseInteraction(interactionId); - this.#dropPermission(interactionId); - } - - return interactionId; - } - - if (request.options.length === 0) { - throw new Error("ACP v1 permission request must advertise at least one option."); - } - - const item = await this.#items.putTool(runId, request.toolCall, "permission/requested.tool"); - const createdAt = this.#now(); - const optionIds = new Map(); - const options = request.options.map((option) => { - const id = this.#resolveId(runId, "permission-option", option.optionId); - optionIds.set(id, option.optionId); - return { - effect: option.kind.startsWith("allow") ? "allow" : "deny", - id, - label: nonEmpty(option.name, option.optionId), - scope: option.kind.endsWith("always") ? "session" : "once", - }; - }); - const interaction = interactionSchema.parse({ - audience: "participants", - blocking: true, - createdAt, - expiresAt: new Date(Date.parse(createdAt) + this.#interactionTimeoutMs).toISOString(), - id: this.#createId(), - itemId: item.id, - kind: "permission", - provenance: provenance("permission/requested", { toolCallId: request.toolCall.toolCallId }), - request: { - options, - subject: { itemId: item.id, type: "item" }, - title: nonEmpty(request.toolCall.title, `Allow ${item.name}?`), - }, - runId, - status: "open", - }); - pending = { - intent, - interaction, - opened: false, - optionIds, - toolCallId: request.toolCall.toolCallId, - }; - this.#pendingPermissions.set(interaction.id, pending); - await this.#projection.putInteraction( - runId, - "permission/requested", - providerCause("permission/requested", request.toolCall.toolCallId), - interaction, - ); - pending.opened = true; - return interaction.id; - } catch (error) { - if (pending !== undefined && !(error instanceof AuthorityOutcomeUnknownError)) { - this.#dropPermission(pending.interaction.id); - } - throw error; - } - } - - async resolveInteraction( - interactionId: string, - resolution: InteractionResolution, - ): Promise { - this.#assertActive(); - return this.#inbox.enqueue(() => this.#resolveInteraction(interactionId, resolution)); - } - - async #resolveInteraction( - interactionId: string, - resolution: InteractionResolution, - ): Promise { - const pending = this.#pendingPermissions.get(interactionId); - - if (pending === undefined) { - return null; - } - - if (resolution.kind !== "permission") { - throw new Error("ACP v1 permission interaction requires a permission resolution."); - } - - if (pending.response !== undefined) { - return pending.response; - } - - const selected = resolution.value.type === "selected" ? resolution.value.optionId : null; - const nativeOptionId = selected === null ? undefined : pending.optionIds.get(selected); - - if (selected !== null && nativeOptionId === undefined) { - throw new Error("ACP v1 permission resolution selected an unavailable option."); - } - - const response: RequestPermissionResponse = - nativeOptionId === undefined - ? { outcome: { outcome: "cancelled" } } - : { outcome: { optionId: nativeOptionId, outcome: "selected" } }; - - this.#projection.releaseInteraction(interactionId); - if (this.#unknownPermission?.intent === pending.intent) { - pending.response = response; - } else { - this.#dropPermission(interactionId); - } - - return response; - } - - #dropPermission(interactionId: string): void { - const pending = this.#pendingPermissions.get(interactionId); - - if (pending !== undefined) { - this.#pendingPermissions.delete(interactionId); - this.#releasePermissionIntent(pending.intent); - } - } - - #permissionRetained(intent: PermissionIntent): boolean { - return ( - this.#unknownPermission?.intent === intent || - [...this.#pendingPermissions.values()].some((pending) => pending.intent === intent) - ); - } - - #releasePermissionIntent(intent: PermissionIntent): void { - if (!intent.released) { - intent.released = true; - this.#pendingPermissionBytes -= intent.bytes; - } - } - - releaseRun(runId: string): void { - for (const [id, pending] of this.#pendingPermissions) { - if (pending.intent.runId === runId) { - this.#dropPermission(id); - } - } - } - - dispose(): void { - this.#disposed = true; - this.#openingPermissions.clear(); - for (const id of this.#pendingPermissions.keys()) { - this.#dropPermission(id); - } - if (this.#unknownPermission !== undefined) { - this.#releasePermissionIntent(this.#unknownPermission.intent); - } - this.#unknownPermission = undefined; - } - - #assertActive(): void { - if (this.#disposed) { - throw new Error("ACP v1 permission controller is disposed."); - } - } -} diff --git a/src/runtimes/acp/contract-session-update-inbox.ts b/src/runtimes/acp/contract-session-update-inbox.ts deleted file mode 100644 index 6aecd50..0000000 --- a/src/runtimes/acp/contract-session-update-inbox.ts +++ /dev/null @@ -1,156 +0,0 @@ -import { isDeepStrictEqual } from "node:util"; - -import type { SessionNotification, SessionUpdate } from "@agentclientprotocol/sdk"; - -import { AuthorityOutcomeUnknownError } from "../contract-projection"; - -const MAX_PENDING_UPDATE_BYTES = 32 * 1_024 * 1_024; -const MAX_PENDING_UPDATES = 1_024; - -interface UnknownSessionUpdate { - readonly error: AuthorityOutcomeUnknownError; - readonly notification: SessionNotification; - readonly receivedAt: string; - retry?: Promise; - readonly runId: string; -} - -export interface AcpContractSessionUpdateInboxOptions { - readonly apply: ( - runId: string, - notification: SessionNotification, - receivedAt: string, - ) => Promise; - readonly now: () => Date; -} - -export class AcpContractSessionUpdateInbox { - readonly #apply: AcpContractSessionUpdateInboxOptions["apply"]; - #closed = false; - #failure: Error | null = null; - #mutationTail: Promise = Promise.resolve(); - readonly #now: () => Date; - #pendingBytes = 0; - #pendingCount = 0; - readonly #textEncoder = new TextEncoder(); - #unknown: UnknownSessionUpdate | undefined; - - constructor(options: AcpContractSessionUpdateInboxOptions) { - this.#apply = options.apply; - this.#now = options.now; - } - - async handle(runId: string, notification: SessionNotification): Promise { - this.#assertOpen(); - this.throwIfFailed(); - - const snapshot = structuredClone(notification); - const unknownAtAdmission = this.#unknown; - const exactRetry = - unknownAtAdmission !== undefined && - unknownAtAdmission.runId === runId && - isDeepStrictEqual(unknownAtAdmission.notification, snapshot) - ? unknownAtAdmission - : undefined; - - if (exactRetry?.retry !== undefined) { - return exactRetry.retry; - } - - const bytes = this.#textEncoder.encode(JSON.stringify(snapshot)).byteLength; - if ( - this.#pendingCount >= MAX_PENDING_UPDATES || - bytes > MAX_PENDING_UPDATE_BYTES - this.#pendingBytes - ) { - throw (this.#failure = new Error("ACP v1 session update queue limit exceeded.")); - } - - const receivedAt = this.#now().toISOString(); - this.#pendingBytes += bytes; - this.#pendingCount += 1; - const update = this.enqueue(async () => { - this.throwIfFailed(); - const unknown = this.#unknown; - const retrying = - unknown !== undefined && - unknownAtAdmission === unknown && - unknown.runId === runId && - isDeepStrictEqual(unknown.notification, snapshot); - - try { - if (unknown !== undefined && unknownAtAdmission !== unknown) { - throw unknown.error; - } - - const result = await this.#apply( - runId, - snapshot, - retrying ? unknown.receivedAt : receivedAt, - ); - if (retrying) { - this.#unknown = undefined; - } - return result; - } catch (cause) { - if (cause instanceof AuthorityOutcomeUnknownError) { - this.#unknown ??= { - error: cause, - notification: snapshot, - receivedAt, - runId, - }; - throw cause; - } - - if (retrying) { - this.#unknown = undefined; - } - this.#failure ??= - cause instanceof Error ? cause : new Error("ACP v1 session update failed.", { cause }); - throw this.#failure; - } - }).finally(() => { - this.#pendingBytes -= bytes; - this.#pendingCount -= 1; - }); - - if (exactRetry === undefined) { - return update; - } - - const retry = update.finally(() => { - if (exactRetry.retry === retry) { - delete exactRetry.retry; - } - }); - exactRetry.retry = retry; - return retry; - } - - enqueue(operation: () => Promise): Promise { - this.#assertOpen(); - const mutation = this.#mutationTail.then(operation); - this.#mutationTail = mutation.then( - () => {}, - () => {}, - ); - return mutation; - } - - throwIfFailed(): void { - if (this.#failure !== null) { - throw this.#failure; - } - } - - close(): void { - this.#closed = true; - this.#unknown = undefined; - } - - #assertOpen(): void { - if (this.#closed) { - throw new Error("ACP v1 session update inbox is closed."); - } - } -} diff --git a/src/runtimes/acp/contract-terminal-projector.ts b/src/runtimes/acp/contract-terminal-projector.ts deleted file mode 100644 index c0eca6c..0000000 --- a/src/runtimes/acp/contract-terminal-projector.ts +++ /dev/null @@ -1,378 +0,0 @@ -import { isDeepStrictEqual } from "node:util"; - -import type { - CreateTerminalRequest, - TerminalOutputResponse, - WaitForTerminalExitResponse, -} from "@agentclientprotocol/sdk"; - -import { itemSchema } from "../../contract"; -import type { Item } from "../../contract"; -import { - AuthorityOutcomeUnknownError, - createProviderMeta, - ContractProjection, -} from "../contract-projection"; -import type { AcpContractSessionUpdateInbox } from "./contract-session-update-inbox"; - -const TERMINAL_OUTPUT_EXTENSION = "agentclientprotocol.v1/terminal-output"; -const { cause: providerCause, provenance } = createProviderMeta("agent-client-protocol"); - -interface PendingTerminalExit { - readonly endedAt: string; - readonly exit: WaitForTerminalExitResponse; -} - -interface TerminalIntent { - readonly receivedAt: string; - readonly request: CreateTerminalRequest | undefined; - readonly runId: string; - readonly terminalId: string; -} - -interface UnknownTerminal { - readonly error: AuthorityOutcomeUnknownError; - readonly intent: TerminalIntent; - retry?: Promise; -} - -export interface AcpContractTerminalProjectorOptions { - readonly assertNativeSession: (sessionId: string) => void; - readonly inbox: AcpContractSessionUpdateInbox; - readonly now: () => string; - readonly projection: ContractProjection; - readonly resolveId: (runId: string, kind: string, nativeId: string) => string; - readonly withReceiptTime: (receivedAt: string, operation: () => Promise) => Promise; -} - -export class AcpContractTerminalProjector { - readonly #assertNativeSession: (sessionId: string) => void; - #disposed = false; - readonly #inbox: AcpContractSessionUpdateInbox; - readonly #now: () => string; - readonly #pendingTerminalExits = new Map(); - readonly #projection: ContractProjection; - readonly #resolveId: AcpContractTerminalProjectorOptions["resolveId"]; - readonly #truncatedTerminals = new Set(); - #unknownTerminal: UnknownTerminal | undefined; - readonly #withReceiptTime: AcpContractTerminalProjectorOptions["withReceiptTime"]; - - constructor(options: AcpContractTerminalProjectorOptions) { - this.#assertNativeSession = options.assertNativeSession; - this.#inbox = options.inbox; - this.#now = options.now; - this.#projection = options.projection; - this.#resolveId = options.resolveId; - this.#withReceiptTime = options.withReceiptTime; - } - - async registerTerminal( - runId: string, - terminalId: string, - request?: CreateTerminalRequest, - ): Promise { - this.#assertActive(); - - const snapshot = request === undefined ? undefined : structuredClone(request); - - if (snapshot !== undefined) { - this.#assertNativeSession(snapshot.sessionId); - } - - const unknownAtAdmission = this.#unknownTerminal; - const exactRetry = - unknownAtAdmission !== undefined && - unknownAtAdmission.intent.runId === runId && - unknownAtAdmission.intent.terminalId === terminalId && - isDeepStrictEqual(unknownAtAdmission.intent.request, snapshot) - ? unknownAtAdmission - : undefined; - - if (unknownAtAdmission !== undefined && exactRetry === undefined) { - throw unknownAtAdmission.error; - } - - if (exactRetry?.retry !== undefined) { - return exactRetry.retry; - } - - const intent = - exactRetry?.intent ?? - ({ - receivedAt: this.#projection.now().toISOString(), - request: snapshot, - runId, - terminalId, - } satisfies TerminalIntent); - const registration = this.#inbox.enqueue(async () => { - try { - const unknown = this.#unknownTerminal; - - if (unknown !== undefined && unknown !== unknownAtAdmission) { - throw unknown.error; - } - - const id = await this.#withReceiptTime(intent.receivedAt, () => - this.ensureTerminal(intent.runId, intent.terminalId, intent.request), - ); - - if (this.#unknownTerminal === unknownAtAdmission) { - this.#unknownTerminal = undefined; - } - - return id; - } catch (error) { - if (error instanceof AuthorityOutcomeUnknownError) { - this.#unknownTerminal ??= { error, intent }; - } else if (this.#unknownTerminal?.intent === intent) { - this.#unknownTerminal = undefined; - } - - throw error; - } - }); - - if (exactRetry !== undefined) { - const retry = registration.finally(() => { - if (exactRetry.retry === retry) { - delete exactRetry.retry; - } - }); - exactRetry.retry = retry; - return retry; - } - - return registration; - } - - async handleTerminalOutput( - runId: string, - terminalId: string, - response: TerminalOutputResponse, - ): Promise { - this.#assertActive(); - return this.#inbox.enqueue(() => this.#handleTerminalOutput(runId, terminalId, response)); - } - - async #handleTerminalOutput( - runId: string, - terminalId: string, - response: TerminalOutputResponse, - ): Promise { - if (this.#projection.run(runId)?.status !== "active") { - return; - } - - const id = await this.ensureTerminal(runId, terminalId); - const item = this.#projection.item(runId, id); - - if (item?.kind !== "terminal" || item.status !== "active") { - return; - } - - const truncationKey = `${runId}\0${terminalId}`; - - if (response.truncated) { - this.#truncatedTerminals.add(truncationKey); - } - - const pendingExit = this.#pendingTerminalExits.get(truncationKey); - const exit = response.exitStatus ?? pendingExit?.exit; - - if (exit === undefined || exit === null) { - await this.#projection.replacePreview({ - channel: "terminal.stdout", - itemId: id, - runId, - text: response.output, - }); - return; - } - - if (pendingExit !== undefined && !isDeepStrictEqual(pendingExit.exit, exit)) { - throw new Error(`ACP v1 terminal ${terminalId} changed its exit status.`); - } - - const terminalExit = pendingExit ?? { endedAt: this.#now(), exit }; - this.#pendingTerminalExits.set(truncationKey, terminalExit); - - await this.#finishTerminal( - runId, - item, - response.output, - terminalExit, - this.#truncatedTerminals.has(truncationKey), - ); - this.#pendingTerminalExits.delete(truncationKey); - this.#truncatedTerminals.delete(truncationKey); - } - - async handleTerminalExit( - runId: string, - terminalId: string, - response: WaitForTerminalExitResponse, - ): Promise { - this.#assertActive(); - return this.#inbox.enqueue(() => this.#handleTerminalExit(runId, terminalId, response)); - } - - async #handleTerminalExit( - runId: string, - terminalId: string, - response: WaitForTerminalExitResponse, - ): Promise { - if (this.#projection.run(runId)?.status !== "active") { - return; - } - - const id = await this.ensureTerminal(runId, terminalId); - const truncationKey = `${runId}\0${terminalId}`; - const item = this.#projection.item(runId, id); - - if (item?.kind === "terminal" && item.status === "active") { - const pending = this.#pendingTerminalExits.get(truncationKey); - - if (pending !== undefined && !isDeepStrictEqual(pending.exit, response)) { - throw new Error(`ACP v1 terminal ${terminalId} changed its exit status.`); - } - - this.#pendingTerminalExits.set(truncationKey, { - endedAt: pending?.endedAt ?? this.#now(), - exit: response, - }); - } - } - - async ensureTerminal( - runId: string, - terminalId: string, - request?: CreateTerminalRequest, - ): Promise { - const id = this.#resolveId(runId, "terminal", terminalId); - const existing = this.#projection.item(runId, id); - - if (existing !== undefined) { - if (existing.kind !== "terminal") { - throw new Error("ACP v1 terminal ID collided with a non-terminal item."); - } - - return id; - } - - const now = this.#now(); - await this.#projection.putItem( - runId, - "terminal/created", - providerCause("terminal/created", terminalId), - itemSchema.parse({ - audience: "participants", - ...(request === undefined - ? {} - : { - command: [request.command, ...(request.args ?? [])].join(" "), - ...(request.cwd === undefined || request.cwd === null ? {} : { cwd: request.cwd }), - }), - createdAt: now, - id, - kind: "terminal", - provenance: provenance("terminal/created", { terminalId }), - runId, - status: "active", - stderr: [], - stdout: [], - updatedAt: now, - }), - ); - return id; - } - - async #finishTerminal( - runId: string, - item: Extract, - output: string, - terminalExit: PendingTerminalExit, - truncated: boolean, - ): Promise { - const { endedAt: now, exit } = terminalExit; - const failed = - (exit.exitCode !== undefined && exit.exitCode !== null && exit.exitCode !== 0) || - (exit.signal !== undefined && exit.signal !== null); - await this.#projection.putItem( - runId, - "terminal/exited", - providerCause("terminal/exited", item.id), - itemSchema.parse({ - ...item, - endedAt: now, - ...(failed - ? { - error: { - code: "agent_client_protocol.terminal_failed", - message: "Terminal command failed.", - retryable: false, - }, - } - : {}), - ...(truncated - ? { extensions: { ...item.extensions, [TERMINAL_OUTPUT_EXTENSION]: { truncated } } } - : {}), - exitCode: exit.exitCode ?? null, - signal: exit.signal ?? null, - status: failed ? "failed" : "completed", - stdout: output.length === 0 ? [] : [{ text: output, type: "text" }], - updatedAt: now, - }), - ); - } - - async flushTerminalExits(runId: string): Promise { - for (const [key, pending] of this.#pendingTerminalExits) { - if (!key.startsWith(`${runId}\0`)) { - continue; - } - - const terminalId = key.slice(runId.length + 1); - const id = this.#resolveId(runId, "terminal", terminalId); - const item = this.#projection.item(runId, id); - - if (item?.kind === "terminal" && item.status === "active") { - await this.#finishTerminal( - runId, - item, - this.#projection.materializedText(runId, id, "terminal.stdout"), - pending, - this.#truncatedTerminals.has(key), - ); - } - - this.#pendingTerminalExits.delete(key); - this.#truncatedTerminals.delete(key); - } - } - - releaseRun(runId: string): void { - for (const key of this.#truncatedTerminals) { - if (key.startsWith(`${runId}\0`)) { - this.#truncatedTerminals.delete(key); - } - } - for (const key of this.#pendingTerminalExits.keys()) { - if (key.startsWith(`${runId}\0`)) { - this.#pendingTerminalExits.delete(key); - } - } - } - - dispose(): void { - this.#disposed = true; - this.#pendingTerminalExits.clear(); - this.#truncatedTerminals.clear(); - this.#unknownTerminal = undefined; - } - - #assertActive(): void { - if (this.#disposed) { - throw new Error("ACP v1 terminal projector is disposed."); - } - } -} diff --git a/src/runtimes/acp/v1-contract-adapter.ts b/src/runtimes/acp/v1-contract-adapter.ts deleted file mode 100644 index 6d280fa..0000000 --- a/src/runtimes/acp/v1-contract-adapter.ts +++ /dev/null @@ -1,312 +0,0 @@ -import { isDeepStrictEqual } from "node:util"; - -import type { - CreateTerminalRequest, - PromptResponse, - RequestPermissionRequest, - RequestPermissionResponse, - SessionNotification, - SessionUpdate, - TerminalOutputResponse, - WaitForTerminalExitResponse, -} from "@agentclientprotocol/sdk"; - -import type { InteractionResolution, Run } from "../../contract"; -import { createDriverId } from "../../protocol/id"; -import { - createProviderMeta, - ContractProjection, - type ContractProjectionOptions, -} from "../contract-projection"; -import { AcpContractSessionUpdateInbox } from "./contract-session-update-inbox"; -import { AcpContractItemProjector } from "./contract-item-projector"; -import { AcpContractPermissionController } from "./contract-permission-controller"; -import { AcpContractTerminalProjector } from "./contract-terminal-projector"; -import { toUsage } from "./contract-mapping"; - -export { toConfigOptions } from "./contract-mapping"; - -const DEFAULT_INTERACTION_TIMEOUT_MS = 5 * 60 * 1_000; -const DEFAULT_MAX_PENDING_PERMISSION_BYTES = 8 * 1_024 * 1_024; -const PROVIDER = "agent-client-protocol"; -const { cause: providerCause } = createProviderMeta(PROVIDER); - -export interface AcpV1ContractAdapterOptions extends ContractProjectionOptions { - readonly createId?: (() => string) | undefined; - readonly interactionTimeoutMs?: number | undefined; - readonly maxPendingPermissionBytes?: number | undefined; - readonly nativeSessionId: string; -} - -export class AcpV1ContractAdapter { - readonly #createId: () => string; - #disposed = false; - readonly #ids = new Map>(); - readonly #inbox: AcpContractSessionUpdateInbox; - readonly #items: AcpContractItemProjector; - readonly #nativeSessionId: string; - readonly #permissions: AcpContractPermissionController; - readonly #projection: ContractProjection; - #receivedAt: string | null = null; - readonly #terminals: AcpContractTerminalProjector; - - constructor(options: AcpV1ContractAdapterOptions) { - this.#createId = options.createId ?? createDriverId; - const interactionTimeoutMs = options.interactionTimeoutMs ?? DEFAULT_INTERACTION_TIMEOUT_MS; - const maxPendingPermissionBytes = - options.maxPendingPermissionBytes ?? DEFAULT_MAX_PENDING_PERMISSION_BYTES; - this.#nativeSessionId = options.nativeSessionId; - this.#projection = new ContractProjection(options); - this.#inbox = new AcpContractSessionUpdateInbox({ - apply: (runId, notification, receivedAt) => - this.#withReceipt(receivedAt, () => this.#applySessionUpdate(runId, notification)), - now: () => this.#projection.now(), - }); - this.#terminals = new AcpContractTerminalProjector({ - assertNativeSession: (sessionId) => this.#assertSession(sessionId), - inbox: this.#inbox, - now: () => this.#timestamp(), - projection: this.#projection, - resolveId: (runId, kind, nativeId) => this.#id(runId, kind, nativeId), - withReceiptTime: (receivedAt, operation) => this.#withReceipt(receivedAt, operation), - }); - this.#items = new AcpContractItemProjector({ - now: () => this.#timestamp(), - projection: this.#projection, - resolveId: (runId, kind, nativeId) => this.#id(runId, kind, nativeId), - terminals: this.#terminals, - }); - this.#permissions = new AcpContractPermissionController({ - assertNativeSession: (sessionId) => this.#assertSession(sessionId), - createId: this.#createId, - inbox: this.#inbox, - interactionTimeoutMs, - items: this.#items, - maxPendingPermissionBytes, - now: () => this.#timestamp(), - projection: this.#projection, - resolveId: (runId, kind, nativeId) => this.#id(runId, kind, nativeId), - withReceiptTime: (receivedAt, operation) => this.#withReceipt(receivedAt, operation), - }); - - if (this.#nativeSessionId.trim().length === 0) { - throw new Error("ACP v1 adapter requires a native session ID."); - } - - if ( - [interactionTimeoutMs, maxPendingPermissionBytes].some( - (value) => !Number.isSafeInteger(value) || value < 1, - ) - ) { - throw new RangeError("ACP v1 adapter limits must be finite and positive."); - } - } - - attachRun(run: Run): void { - this.#assertActive(); - this.#projection.attachRun(run); - } - - async handleSessionUpdate( - runId: string, - notification: SessionNotification, - ): Promise { - this.#assertActive(); - return this.#inbox.handle(runId, notification); - } - - async #applySessionUpdate( - runId: string, - notification: SessionNotification, - ): Promise { - this.#assertSession(notification.sessionId); - - const update = notification.update; - - switch (update.sessionUpdate) { - case "available_commands_update": - case "config_option_update": - case "current_mode_update": - case "session_info_update": - case "usage_update": - return update; - } - - if (this.#projection.run(runId)?.status !== "active") { - return null; - } - - switch (update.sessionUpdate) { - case "agent_message_chunk": - await this.#items.putMessageChunk(runId, update, "message"); - return null; - case "agent_thought_chunk": - await this.#items.putMessageChunk(runId, update, "reasoning"); - return null; - case "user_message_chunk": - return null; - case "tool_call": - case "tool_call_update": - await this.#items.putTool(runId, update, `session/${update.sessionUpdate}`); - return null; - case "plan": - await this.#items.putPlan(runId, "current", update.entries, "session/plan"); - return null; - default: - return null; - } - } - - async completePrompt(runId: string, response: PromptResponse): Promise { - this.#assertActive(); - return this.#inbox.enqueue(() => this.#completePrompt(runId, response)); - } - - async #completePrompt(runId: string, response: PromptResponse): Promise { - this.#inbox.throwIfFailed(); - - if (this.#projection.run(runId)?.status !== "active") { - return; - } - - await this.#terminals.flushTerminalExits(runId); - - const event = `prompt/${response.stopReason}`; - const cause = providerCause(event, runId); - - if (response.usage !== undefined && response.usage !== null) { - const current = this.#projection.run(runId)?.usage; - const usage = toUsage(response.usage, current); - - if (Object.keys(usage).length > 0 && !isDeepStrictEqual(usage, current)) { - await this.#projection.updateUsage(runId, event, cause, usage); - } - } - - if (response.stopReason === "cancelled") { - await this.#projection.finishRun({ cause, event, runId, status: "cancelled" }); - } else { - await this.#projection.finishRun({ - cause, - event, - finishReason: - response.stopReason === "refusal" - ? "refusal" - : response.stopReason === "max_tokens" || response.stopReason === "max_turn_requests" - ? "limit" - : "success", - runId, - status: "completed", - }); - } - - this.#releaseRun(runId); - } - - openPermission(runId: string, request: RequestPermissionRequest): Promise { - this.#assertActive(); - return this.#permissions.openPermission(runId, request); - } - - resolveInteraction( - interactionId: string, - resolution: InteractionResolution, - ): Promise { - this.#assertActive(); - return this.#permissions.resolveInteraction(interactionId, resolution); - } - - registerTerminal( - runId: string, - terminalId: string, - request?: CreateTerminalRequest, - ): Promise { - this.#assertActive(); - return this.#terminals.registerTerminal(runId, terminalId, request); - } - - handleTerminalOutput( - runId: string, - terminalId: string, - response: TerminalOutputResponse, - ): Promise { - this.#assertActive(); - return this.#terminals.handleTerminalOutput(runId, terminalId, response); - } - - handleTerminalExit( - runId: string, - terminalId: string, - response: WaitForTerminalExitResponse, - ): Promise { - this.#assertActive(); - return this.#terminals.handleTerminalExit(runId, terminalId, response); - } - - dispose(): void { - this.#disposed = true; - this.#ids.clear(); - this.#permissions.dispose(); - this.#inbox.close(); - this.#terminals.dispose(); - this.#projection.dispose(); - } - - #id(runId: string, kind: string, nativeId: string): string { - const candidate = nativeId.length > 0 ? `${kind}:${nativeId}` : ""; - - if (candidate.length > 0 && candidate.length <= 256) { - return candidate; - } - - let ids = this.#ids.get(runId); - - if (ids === undefined) { - ids = new Map(); - this.#ids.set(runId, ids); - } - - const key = `${kind}:${nativeId}`; - let id = ids.get(key); - - if (id === undefined) { - id = this.#createId(); - ids.set(key, id); - } - - return id; - } - - #assertSession(sessionId: string): void { - if (sessionId !== this.#nativeSessionId) { - throw new Error("ACP v1 message does not belong to the active native session."); - } - } - - #releaseRun(runId: string): void { - this.#ids.delete(runId); - this.#permissions.releaseRun(runId); - this.#terminals.releaseRun(runId); - } - - #timestamp(): string { - return this.#receivedAt ?? this.#projection.now().toISOString(); - } - - async #withReceipt(receivedAt: string, operation: () => Promise): Promise { - const previous = this.#receivedAt; - this.#receivedAt = receivedAt; - - try { - return await operation(); - } finally { - this.#receivedAt = previous; - } - } - - #assertActive(): void { - if (this.#disposed) { - throw new Error("ACP v1 adapter is disposed."); - } - } -} diff --git a/src/runtimes/atomic-file.ts b/src/runtimes/atomic-file.ts new file mode 100644 index 0000000..3818ea4 --- /dev/null +++ b/src/runtimes/atomic-file.ts @@ -0,0 +1,353 @@ +import { randomUUID } from "node:crypto"; +import { constants, type Dirent } from "node:fs"; +import { lstat, mkdir, open, opendir, rename, unlink } from "node:fs/promises"; +import type { FileHandle } from "node:fs/promises"; +import { basename, dirname, join, resolve } from "node:path"; + +export function hasErrorCode(error: unknown, code: string): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === code; +} + +const activeAtomicWriteTemporaryFiles = new Set(); +const ATOMIC_WRITE_TEMPORARY_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const MAX_ATOMIC_WRITE_TEMPORARY_SCAN_ENTRIES = 256; +const NON_ABORTING_SIGNAL = AbortSignal.any([]); + +export async function closeFileHandles( + handles: readonly (FileHandle | null | undefined)[], +): Promise { + const results = await Promise.allSettled( + handles + .filter((handle): handle is FileHandle => handle !== null && handle !== undefined) + .map((handle) => handle.close()), + ); + return results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])); +} + +async function closeFileHandlesAndThrow( + error: unknown, + handles: readonly (FileHandle | null | undefined)[], + message: string, +): Promise { + const closeFailures = await closeFileHandles(handles); + throw closeFailures.length > 0 ? new AggregateError([error, ...closeFailures], message) : error; +} + +async function atomicWriteTemporaryFileKey(directory: FileHandle, name: string): Promise { + const stats = await directory.stat(); + return `${String(stats.dev)}:${String(stats.ino)}:${name}`; +} + +export function directoryEntryPath(directory: FileHandle, name: string): string { + if (name.length === 0 || name === "." || name === ".." || basename(name) !== name) { + throw new Error(`Directory entry name is invalid: ${name}.`); + } + + return join(openedDirectoryPath(directory), name); +} + +export function openedDirectoryPath(directory: FileHandle): string { + return join("/proc/self/fd", String(directory.fd)); +} + +export async function openRealDirectory(path: string, label: string): Promise { + if (process.platform !== "linux") { + throw new Error(`${label} requires Linux /proc filesystem capabilities.`); + } + + try { + return await open(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW); + } catch (error) { + if (hasErrorCode(error, "ELOOP") || hasErrorCode(error, "ENOTDIR")) { + throw new Error(`${label} must be a real directory: ${path}.`, { cause: error }); + } + throw error; + } +} + +export async function openOptionalRealDirectory( + path: string, + label: string, +): Promise { + try { + return await openRealDirectory(path, label); + } catch (error) { + if (hasErrorCode(error, "ENOENT")) { + return null; + } + throw error; + } +} + +export async function readPathStats( + path: string, +): Promise> | null> { + try { + return await lstat(path); + } catch (error) { + if (hasErrorCode(error, "ENOENT")) { + return null; + } + throw error; + } +} + +export async function ensureRealDirectoryAt( + parent: FileHandle, + name: string, + label: string, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + const path = directoryEntryPath(parent, name); + let created = false; + + try { + await mkdir(path); + created = true; + } catch (error) { + if (!hasErrorCode(error, "EEXIST")) { + throw error; + } + } + + const directory = await openRealDirectory(path, label); + try { + if (created) { + await parent.sync(); + } + signal?.throwIfAborted(); + return directory; + } catch (error) { + const closeFailures = await closeFileHandles([directory]); + if (closeFailures.length > 0) { + throw new AggregateError([error, ...closeFailures], `Failed to create ${path}.`); + } + throw error; + } +} + +async function walkRealDirectory( + startPath: string, + segments: readonly string[], + label: string, + create: boolean, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted(); + let directory = await openRealDirectory(startPath, label); + + for (const segment of segments) { + let next: FileHandle; + try { + signal?.throwIfAborted(); + next = create + ? await ensureRealDirectoryAt(directory, segment, label, signal) + : await openRealDirectory(directoryEntryPath(directory, segment), label); + } catch (error) { + return closeFileHandlesAndThrow(error, [directory], `Failed to walk ${startPath}.`); + } + + const closeFailures = await closeFileHandles([directory]); + if (closeFailures.length > 0) { + return closeFileHandlesAndThrow( + new AggregateError(closeFailures, `Failed to close an ancestor of ${startPath}.`), + [next], + `Failed to walk ${startPath}.`, + ); + } + directory = next; + } + + return directory; +} + +export function openAbsoluteRealDirectory(path: string, label: string): Promise { + const absolutePath = resolve(path); + return walkRealDirectory("/", absolutePath.split("/").filter(Boolean), label, false); +} + +export function ensureAbsoluteRealDirectory( + path: string, + label: string, + signal?: AbortSignal, +): Promise { + const absolutePath = resolve(path); + return walkRealDirectory("/", absolutePath.split("/").filter(Boolean), label, true, signal); +} + +export function openRelativeRealDirectory( + root: FileHandle, + path: string, + label: string, + create: boolean, + signal?: AbortSignal, +): Promise { + return walkRealDirectory( + `${openedDirectoryPath(root)}/.`, + path === "." ? [] : path.split("/"), + label, + create, + signal, + ); +} + +export async function assertDirectoryIdentity( + directory: FileHandle, + path: string, + label: string, +): Promise { + const openedStats = await directory.stat(); + const pathStats = await readPathStats(path); + + if ( + pathStats === null || + pathStats.isSymbolicLink() || + !pathStats.isDirectory() || + pathStats.dev !== openedStats.dev || + pathStats.ino !== openedStats.ino + ) { + throw new Error(`${label} changed while managed files were being written: ${path}.`); + } +} + +export async function cleanupAtomicWriteTemporaryFiles( + directory: FileHandle, + targetNames: readonly string[], + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + await using entries = await opendir(openedDirectoryPath(directory)); + let removed = false; + let scanned = 0; + + for await (const entry of entries) { + signal.throwIfAborted(); + scanned += 1; + if (scanned > MAX_ATOMIC_WRITE_TEMPORARY_SCAN_ENTRIES) { + break; + } + + const target = targetNames.find( + (name) => + entry.name.startsWith(`.${name}.`) && + entry.name.endsWith(".tmp") && + ATOMIC_WRITE_TEMPORARY_ID_PATTERN.test(entry.name.slice(name.length + 2, -".tmp".length)), + ); + if ( + target === undefined || + activeAtomicWriteTemporaryFiles.has(await atomicWriteTemporaryFileKey(directory, entry.name)) + ) { + continue; + } + if (entry.isDirectory() && !entry.isSymbolicLink()) { + throw new Error(`Atomic write temporary path must not be a directory: ${entry.name}.`); + } + + await unlink(directoryEntryPath(directory, entry.name)); + removed = true; + } + + if (removed) { + await directory.sync(); + } +} + +export async function readDirectoryEntriesBounded( + directory: FileHandle, + label: string, + maxEntries: number, + signal?: AbortSignal, +): Promise { + await using stream = await opendir(openedDirectoryPath(directory)); + const entries: Dirent[] = []; + + for await (const entry of stream) { + signal?.throwIfAborted(); + if (entries.length >= maxEntries) { + throw new Error(`${label} contains too many entries.`); + } + entries.push(entry); + } + + return entries.toSorted((a, b) => a.name.localeCompare(b.name)); +} + +export async function writeFileAtomically( + directory: FileHandle, + name: string, + contents: string, + mode: number, + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + const path = directoryEntryPath(directory, name); + const temporaryName = `.${name}.${randomUUID()}.tmp`; + const temporaryPath = directoryEntryPath(directory, temporaryName); + const temporaryKey = await atomicWriteTemporaryFileKey(directory, temporaryName); + let temporaryFileCreated = false; + activeAtomicWriteTemporaryFiles.add(temporaryKey); + + try { + await using temporaryFile = await open(temporaryPath, "wx", mode); + temporaryFileCreated = true; + await temporaryFile.writeFile(contents, { encoding: "utf8", signal }); + await temporaryFile.sync(); + + signal.throwIfAborted(); + await rename(temporaryPath, path); + await directory.sync(); + } catch (error) { + if (temporaryFileCreated) { + try { + await unlink(temporaryPath); + } catch (cleanupError) { + if (!hasErrorCode(cleanupError, "ENOENT")) { + throw new AggregateError([error, cleanupError], `Failed to clean ${temporaryPath}.`); + } + } + } + throw error; + } finally { + activeAtomicWriteTemporaryFiles.delete(temporaryKey); + } +} + +export async function writeFileAtomicallyAtPath( + path: string, + contents: string, + options: { mode: number; skipIfUnchanged?: boolean }, +): Promise { + const absolutePath = resolve(path); + const directoryPath = dirname(absolutePath); + const name = basename(absolutePath); + await using directory = await ensureAbsoluteRealDirectory( + directoryPath, + "Atomic file parent", + NON_ABORTING_SIGNAL, + ); + await cleanupAtomicWriteTemporaryFiles(directory, [name], NON_ABORTING_SIGNAL); + + let written = true; + if (options.skipIfUnchanged === true) { + const existing = await (async (): Promise => { + try { + await using file = await open( + directoryEntryPath(directory, name), + constants.O_RDONLY | constants.O_NOFOLLOW, + ); + return (await file.stat()).isFile() ? await file.readFile("utf8") : null; + } catch { + return null; + } + })(); + written = existing !== contents; + } + + if (written) { + await writeFileAtomically(directory, name, contents, options.mode, NON_ABORTING_SIGNAL); + } + await assertDirectoryIdentity(directory, directoryPath, "Atomic file parent"); + return written; +} diff --git a/src/runtimes/child-process-env.ts b/src/runtimes/child-process-env.ts index 6d01339..37e7403 100644 --- a/src/runtimes/child-process-env.ts +++ b/src/runtimes/child-process-env.ts @@ -1,3 +1,5 @@ +import { delimiter } from "node:path"; + import { DRIVER_BOOT_PAYLOAD_ENV_NAME, DRIVER_BOOT_PAYLOAD_FILE_ENV_NAME } from "../protocol/boot"; import type { DriverExecutionEnvironment } from "../protocol/boot"; @@ -6,7 +8,9 @@ export { DRIVER_BOOT_PAYLOAD_ENV_NAME, DRIVER_BOOT_PAYLOAD_FILE_ENV_NAME }; export function buildRuntimeChildProcessEnv( paths: DriverExecutionEnvironment["paths"], env: NodeJS.ProcessEnv, + platform: NodeJS.Platform = process.platform, ): Record { + const pathDelimiter = platform === "win32" ? ";" : delimiter; const childEnv = Object.fromEntries( Object.entries(env).filter((entry): entry is [string, string] => entry[1] !== undefined), ); @@ -17,7 +21,23 @@ export function buildRuntimeChildProcessEnv( ["PYTHONPATH", paths?.python ?? []], ] as const) { if (artifactPaths.length > 0) { - childEnv[name] = [...artifactPaths, ...(childEnv[name] ? [childEnv[name]] : [])].join(":"); + const inheritedKey = + platform === "win32" + ? Object.hasOwn(childEnv, name) + ? name + : Object.keys(childEnv).find((key) => key.toUpperCase() === name) + : name; + const inherited = inheritedKey === undefined ? undefined : childEnv[inheritedKey]; + + if (platform === "win32") { + for (const key of Object.keys(childEnv)) { + if (key.toUpperCase() === name) { + delete childEnv[key]; + } + } + } + + childEnv[name] = [...artifactPaths, ...(inherited ? [inherited] : [])].join(pathDelimiter); } } diff --git a/src/runtimes/child-process.ts b/src/runtimes/child-process.ts index 8374dad..ed5982b 100644 --- a/src/runtimes/child-process.ts +++ b/src/runtimes/child-process.ts @@ -425,9 +425,6 @@ export function createProcessTreeEnvironment( ): ProcessTreeEnvironment { const supervisor = ensureLinuxProcessTreeSupervisor(); const marker = randomUUID(); - if (supervisor !== null) { - linuxMarkedProcessState(marker).cutoff = Number(supervisor.startTime); - } return { env: { ...env, @@ -488,15 +485,16 @@ export function bindSpawnedProcess( ): BoundSpawnedProcess { const pid = child.pid; const stat = platform === "linux" && pid !== undefined ? readLinuxProcessStat(pid) : null; - if (pid !== undefined && stat !== null && processTree !== undefined) { - const state = linuxMarkedProcessStates.get(processTree.marker); - const stdin = linuxProcessTreeSupervisor?.process.stdin; - if (state !== undefined) { - state.cutoff = Number(stat.startTime); + if (platform === "linux" && processTree !== undefined && linuxProcessTreeSupervisor !== null) { + const state = linuxMarkedProcessState(processTree.marker); + state.cutoff = Number(stat?.startTime ?? linuxProcessTreeSupervisor.startTime); + const stdin = linuxProcessTreeSupervisor.process.stdin; + if (pid !== undefined && stat !== null) { state.processes.set(pid, stat.startTime); } if ( - state === undefined || + pid === undefined || + stat === null || stdin === null || stdin === undefined || stdin.destroyed || diff --git a/src/runtimes/claude/agent-sdk-driver-backend.ts b/src/runtimes/claude/agent-sdk-driver-backend.ts index 365756b..91210ed 100644 --- a/src/runtimes/claude/agent-sdk-driver-backend.ts +++ b/src/runtimes/claude/agent-sdk-driver-backend.ts @@ -1,7 +1,7 @@ import { mkdir } from "node:fs/promises"; import { query, startup } from "@anthropic-ai/claude-agent-sdk"; -import type { Query } from "@anthropic-ai/claude-agent-sdk"; +import type { Query, SDKMessage } from "@anthropic-ai/claude-agent-sdk"; import { DriverTurnCancelledError } from "../../core/driver-runtime-state"; import { @@ -21,13 +21,20 @@ import type { DriverStartInput } from "../../protocol/start"; import type { RuntimeCommandInput } from "../../runtime-command"; import { raceWithAbort, settlePromiseWithTimeout } from "../../utils/async"; import type { AgentDriverBackend, AgentDriverContext } from "../../core/agent-driver-backend"; -import { DriverEventPublisher } from "../driver-event-publisher"; +import { + DriverCompletedTerminalSupersededError, + DriverEventPublisher, +} from "../driver-event-publisher"; +import { toRuntimePublicId } from "../runtime-public-id"; import { computeRuntimeBootstrapDigest, writeSkillBootstrapArtifacts } from "../skill-bootstrap"; import { readProcessEnvString, toErrorMessage } from "./agent-sdk-json"; +import { ClaudeDurableEventTooLargeError } from "./agent-sdk-event-writer"; import { ClaudeAgentSdkPrewarm } from "./agent-sdk-prewarm"; import { ClaudeAgentSdkMessageTranslator, ClaudeTerminalWriteError, + type ClaudePreparedResult, + type ClaudeTerminalOutcome, } from "./agent-sdk-message-translator"; import { CLAUDE_CODE_EXECUTABLE_ENV, @@ -35,18 +42,19 @@ import { resolveClaudeConfigDir, } from "./agent-sdk-query-options"; import { buildClaudeRecoveryPrompt } from "./agent-sdk-recovery-context"; -import { readClaudeNativeResumeSessionId } from "./agent-sdk-resume"; +import { readClaudeNativeResumeSessionId, requireClaudeNativeSessionId } from "./agent-sdk-resume"; import { drainClaudeTasks } from "./agent-sdk-tasks"; interface ActiveClaudeTurn { abortController: AbortController; - cancelTask: Promise | null; cancelReason: string | null; permissionTasks: Set>; processTasks: Set>; query: Query | null; queryCloseTask: Promise | null; runId: RunId; + runSignal: AbortSignal | null; + readonly settled: ReturnType>; state: "running" | "finalizing" | "cancelled"; } @@ -62,10 +70,17 @@ const DEFAULT_DEPENDENCIES: ClaudeAgentSdkDriverBackendDependencies = { startup, }; -const CLAUDE_INTERRUPT_TIMEOUT_MS = 1_500; +const CLAUDE_QUERY_RETURN_TIMEOUT_MS = 2_500; function isTurnCancelled(turn: ActiveClaudeTurn): boolean { - return turn.state === "cancelled"; + return turn.state === "cancelled" || turn.runSignal?.aborted === true; +} + +function turnCancellationReason(turn: ActiveClaudeTurn): string { + return ( + turn.cancelReason ?? + toErrorMessage(turn.runSignal?.reason, "Claude Agent SDK turn was cancelled.") + ); } export class ClaudeAgentSdkDriverBackend implements AgentDriverBackend { @@ -92,12 +107,27 @@ export class ClaudeAgentSdkDriverBackend implements AgentDriverBackend { createQueryOptions: this.#dependencies.createQueryOptions, getNativeSessionId: () => this.#nativeSessionId, payload, + publicToolCallId: (nativeToolCallId) => toRuntimePublicId(nativeToolCallId, "claude-tool"), startup: async (input) => this.#dependencies.startup(input), }); this.#messageTranslator = new ClaudeAgentSdkMessageTranslator({ + publicToolCallId: (nativeToolCallId) => toRuntimePublicId(nativeToolCallId, "claude-tool"), push: async (context, reason, events) => this.#push(context, reason, events), + pushTerminal: async (context, reason, closures, terminal) => { + const activeTurn = this.#activeTurn; + await this.#eventPublisher.pushTerminal( + context, + reason, + closures, + terminal, + terminal.kind === "run.completed" ? (activeTurn?.runSignal ?? undefined) : undefined, + ); + }, recordNativeSessionId: async (context, sessionId) => this.#recordNativeSessionId(context, sessionId), + replaceNativeSessionId: async (context, previousSessionId, nextSessionId) => + this.#replaceNativeSessionId(context, previousSessionId, nextSessionId), + sessionId: payload.execution.run.sessionId, }); } @@ -107,12 +137,13 @@ export class ClaudeAgentSdkDriverBackend implements AgentDriverBackend { throw new Error("Claude Agent SDK backend cannot restart after stopping."); } - const materializedSkills = await raceWithAbort( - context.ports.skill.materialize(this.#payload.execution), + const materializedSkills = await context.ports.skill.materialize( + this.#payload.execution, signal, ); - const bootstrapArtifacts = await raceWithAbort( - writeSkillBootstrapArtifacts(this.#payload.execution), + const bootstrapArtifacts = await writeSkillBootstrapArtifacts( + this.#payload.execution, + materializedSkills, signal, ); const { homePath } = this.#payload.execution.session; @@ -150,6 +181,7 @@ export class ClaudeAgentSdkDriverBackend implements AgentDriverBackend { context: AgentDriverContext, input: RuntimeCommandInput, runId: RunId, + signal?: AbortSignal, ): Promise { if (this.#activeTurn) { throw new Error("Claude Agent SDK already has an active turn."); @@ -171,21 +203,29 @@ export class ClaudeAgentSdkDriverBackend implements AgentDriverBackend { const { abortController, permissionTasks, processTasks, warmQuery } = this.#prewarm.take(); const activeTurn: ActiveClaudeTurn = { abortController, - cancelTask: null, cancelReason: null, permissionTasks, processTasks, query: null, queryCloseTask: null, runId, + runSignal: signal ?? null, + settled: Promise.withResolvers(), state: "running", }; this.#activeTurn = activeTurn; + const turnSignal = + activeTurn.runSignal === null + ? activeTurn.abortController.signal + : AbortSignal.any([activeTurn.abortController.signal, activeTurn.runSignal]); let queryStartedAtMs = Date.now(); let queryOptionsMs = 0; let runStarted = false; + let preparedResult: ClaudePreparedResult | null = null; + let terminalOutcome: ClaudeTerminalOutcome | null = null; + try { await this.#push(context, "driver.claude.turn.started", [ { @@ -208,14 +248,19 @@ export class ClaudeAgentSdkDriverBackend implements AgentDriverBackend { activeQuery = warmQuery.query(promptText); } else { const optionsStartedAtMs = Date.now(); - const queryOptions = await this.#dependencies.createQueryOptions({ - abortController, - context, - nativeSessionId: this.#nativeSessionId, - payload: this.#payload, - permissionTasks, - processTasks, - }); + const queryOptions = await raceWithAbort( + this.#dependencies.createQueryOptions({ + abortController, + context, + nativeSessionId: this.#nativeSessionId, + payload: this.#payload, + permissionTasks, + processTasks, + publicToolCallId: (nativeToolCallId) => + toRuntimePublicId(nativeToolCallId, "claude-tool"), + }), + turnSignal, + ); queryOptionsMs = Date.now() - optionsStartedAtMs; if (isTurnCancelled(activeTurn)) { @@ -230,7 +275,7 @@ export class ClaudeAgentSdkDriverBackend implements AgentDriverBackend { activeTurn.query = activeQuery; if (isTurnCancelled(activeTurn)) { - await this.#closeQuery(context, activeTurn, activeTurn.cancelReason ?? "turn.cancelled"); + await this.#closeQuery(context, activeTurn, turnCancellationReason(activeTurn)); throw new DriverTurnCancelledError("Claude Agent SDK turn was cancelled."); } } catch (error) { @@ -272,11 +317,28 @@ export class ClaudeAgentSdkDriverBackend implements AgentDriverBackend { nativeSessionIdPresent: Boolean(this.#nativeSessionId), }); - let completed = false; let firstProviderEventPublished = false; const providerStartedAtMs = Date.now(); + for (;;) { + let iteration: IteratorResult; + try { + iteration = await raceWithAbort(activeQuery.next(), turnSignal); + } catch (error) { + if ( + preparedResult !== null && + activeTurn.state === "finalizing" && + !isTurnCancelled(activeTurn) && + activeTurn.abortController.signal.aborted + ) { + break; + } + throw error; + } + if (iteration.done) { + break; + } + const message = iteration.value; - for await (const message of activeQuery) { if (isTurnCancelled(activeTurn)) { throw new DriverTurnCancelledError("Claude Agent SDK turn was cancelled."); } @@ -309,65 +371,80 @@ export class ClaudeAgentSdkDriverBackend implements AgentDriverBackend { throw new DriverTurnCancelledError("Claude Agent SDK turn was cancelled."); } + if (preparedResult !== null) { + if (message.type === "result") { + throw new Error("Claude Agent SDK emitted multiple result frames."); + } + if ( + message.type === "assistant" || + message.type === "stream_event" || + message.type === "user" + ) { + throw new Error("Claude Agent SDK emitted turn content after its result frame."); + } + await this.#messageTranslator.handleSdkMessage(context, message, runId, true); + continue; + } + if (message.type === "result") { activeTurn.state = "finalizing"; - await this.#closeQuery(context, activeTurn, "provider.result"); + preparedResult = await this.#messageTranslator.prepareResult(context, message, runId); + continue; } - completed = await this.#messageTranslator.handleSdkMessage(context, message, runId); - if (completed) { - break; - } + await this.#messageTranslator.handleSdkMessage(context, message, runId); } - if (isTurnCancelled(activeTurn)) { + if (preparedResult === null && isTurnCancelled(activeTurn)) { throw new DriverTurnCancelledError("Claude Agent SDK turn was cancelled."); } - if (!completed) { + if (preparedResult === null) { throw new Error("Claude Agent SDK query ended before a result frame."); } + + await this.#closeQuery(context, activeTurn, "provider.result"); + terminalOutcome = await this.#messageTranslator.publishPreparedResult( + context, + preparedResult, + ); } catch (error) { if (!runStarted) { throw error; } + if (error instanceof ClaudeTerminalWriteError) { + const replaceableCompletion = + error.terminalKind === "run.completed" && + (error.cause === activeTurn.runSignal?.reason || + (error.cause instanceof DriverCompletedTerminalSupersededError && + error.cause.cause === activeTurn.runSignal?.reason)); + if (!replaceableCompletion) { + throw error.cause; + } + } + if (isTurnCancelled(activeTurn)) { - await this.#closeQuery(context, activeTurn, activeTurn.cancelReason ?? "turn.cancelled"); - await this.#messageTranslator.finishTurn(context, "failed").catch(() => {}); - await this.#push(context, "driver.claude.turn.cancelled", [ - { - kind: "run.cancelled", - payload: { - reason: activeTurn.cancelReason ?? "turn.cancelled", - requestedBy: "user", - stopReason: "cancelled", - }, - runId, - }, - ]); - throw new DriverTurnCancelledError("Claude Agent SDK turn was cancelled."); + const cancellationReason = turnCancellationReason(activeTurn); + await this.#closeQuery(context, activeTurn, cancellationReason); + await this.#messageTranslator.cancelTurn(context, runId, cancellationReason); + throw new DriverTurnCancelledError(cancellationReason); } activeTurn.state = "finalizing"; await this.#closeQuery(context, activeTurn, "turn.failed"); - await this.#messageTranslator.finishTurn(context, "failed").catch(() => {}); if (error instanceof ClaudeTerminalWriteError) { throw error.cause; } const message = toErrorMessage(error, "Claude Agent SDK turn failed."); - await this.#push(context, "driver.claude.turn.failed", [ - { - kind: "run.failed", - payload: { - error: { code: "claude.turn_failed", message }, - recoverable: false, - }, - runId, - }, - ]); + await this.#messageTranslator.failTurn( + context, + runId, + error instanceof ClaudeDurableEventTooLargeError ? error.code : "claude.turn_failed", + message, + ); throw error; } finally { try { @@ -377,8 +454,18 @@ export class ClaudeAgentSdkDriverBackend implements AgentDriverBackend { if (this.#activeTurn === activeTurn) { this.#activeTurn = null; } + activeTurn.settled.resolve(); } } + + if (terminalOutcome.kind === "run.cancelled") { + throw new DriverTurnCancelledError( + terminalOutcome.payload.reason ?? "Claude Agent SDK turn was cancelled by the provider.", + ); + } + if (terminalOutcome.kind === "run.failed") { + throw new Error(terminalOutcome.payload.error.message); + } } async cancelActiveTurn(context: AgentDriverContext, reason: string): Promise { @@ -388,13 +475,9 @@ export class ClaudeAgentSdkDriverBackend implements AgentDriverBackend { return; } - if (activeTurn.cancelTask !== null) { - await activeTurn.cancelTask; - return; - } - if (activeTurn.state === "finalizing") { - await this.#closeQuery(context, activeTurn, reason); + activeTurn.abortController.abort(reason); + void this.#closeQuery(context, activeTurn, reason).catch(() => {}); return; } @@ -404,33 +487,8 @@ export class ClaudeAgentSdkDriverBackend implements AgentDriverBackend { activeTurn.state = "cancelled"; activeTurn.cancelReason = reason; - - activeTurn.cancelTask = (async () => { - try { - if (activeTurn.query !== null) { - const interrupted = await settlePromiseWithTimeout( - Promise.resolve().then(() => activeTurn.query?.interrupt()), - { - label: "Claude Agent SDK turn interrupt", - timeoutMs: CLAUDE_INTERRUPT_TIMEOUT_MS, - }, - ); - - if (interrupted.status !== "completed") { - context.logger.debug("driver.claude.turn.interrupt_failed", { - message: toErrorMessage(interrupted.error, "Claude turn interrupt failed"), - reason, - runId: activeTurn.runId, - }); - } - } - } finally { - activeTurn.abortController.abort(reason); - await this.#closeQuery(context, activeTurn, reason); - } - })(); - - await activeTurn.cancelTask; + activeTurn.abortController.abort(reason); + void this.#closeQuery(context, activeTurn, reason).catch(() => {}); } stop(context: AgentDriverContext, reason: string, signal: AbortSignal): Promise { @@ -455,17 +513,27 @@ export class ClaudeAgentSdkDriverBackend implements AgentDriverBackend { ): Promise { const activeTurn = this.#activeTurn; const prewarmStop = this.#prewarm.stop(context, reason, signal); + const activeCleanup = + activeTurn === null + ? Promise.resolve() + : raceWithAbort( + (async () => { + await this.cancelActiveTurn(context, reason); + const [closeResult] = await Promise.allSettled([ + this.#closeQuery(context, activeTurn, reason), + activeTurn.settled.promise, + ]); + if (closeResult.status === "rejected") { + throw closeResult.reason; + } + })(), + signal, + ); const [activeResult, prewarmResult, pendingResult] = await Promise.allSettled([ - this.cancelActiveTurn(context, reason), + activeCleanup, prewarmStop, - drainClaudeTasks(this.#pendingProcessTasks), + raceWithAbort(drainClaudeTasks(this.#pendingProcessTasks), signal), ]); - if (activeTurn !== null) { - this.#retainProcessTasks(activeTurn); - if (activeResult.status === "rejected" && this.#activeTurn === activeTurn) { - this.#activeTurn = null; - } - } if (activeResult.status === "rejected") { throw activeResult.reason; @@ -488,9 +556,7 @@ export class ClaudeAgentSdkDriverBackend implements AgentDriverBackend { } async #recordNativeSessionId(context: AgentDriverContext, sessionId: string): Promise { - if (sessionId.trim().length === 0) { - throw new Error("Claude Agent SDK message has an empty native session ID."); - } + requireClaudeNativeSessionId(sessionId); if (this.#nativeSessionId === sessionId) { return; @@ -500,8 +566,44 @@ export class ClaudeAgentSdkDriverBackend implements AgentDriverBackend { throw new Error("Claude Agent SDK message belongs to a different native session."); } + const previousSessionId = this.#nativeSessionId; this.#nativeSessionId = sessionId; - await this.#publishNativeResumeRef(context); + try { + await this.#publishNativeResumeRef(context, sessionId); + } catch (error) { + if (this.#nativeSessionId === sessionId) { + this.#nativeSessionId = previousSessionId; + } + throw error; + } + } + + async #replaceNativeSessionId( + context: AgentDriverContext, + previousSessionId: string, + nextSessionId: string, + ): Promise { + requireClaudeNativeSessionId(previousSessionId); + requireClaudeNativeSessionId(nextSessionId); + + if (this.#nativeSessionId === nextSessionId) { + return; + } + + if (this.#nativeSessionId !== null && this.#nativeSessionId !== previousSessionId) { + throw new Error("Claude conversation reset belongs to a different native session."); + } + + const retainedSessionId = this.#nativeSessionId; + this.#nativeSessionId = nextSessionId; + try { + await this.#publishNativeResumeRef(context, nextSessionId); + } catch (error) { + if (this.#nativeSessionId === nextSessionId) { + this.#nativeSessionId = retainedSessionId; + } + throw error; + } } async #closeQuery( @@ -511,34 +613,52 @@ export class ClaudeAgentSdkDriverBackend implements AgentDriverBackend { ): Promise { if (turn.queryCloseTask === null) { const query = turn.query; - turn.queryCloseTask = Promise.resolve() - .then(() => query?.return()) - .then( - () => {}, - (error) => { + turn.queryCloseTask = (async () => { + if (query !== null) { + try { + query.close(); + } catch (error) { + turn.abortController.abort(reason); context.logger.debug("driver.claude.turn.close_failed", { message: toErrorMessage(error, "query close failed"), reason, runId: turn.runId, }); - }, - ) - .then(() => drainClaudeTasks(turn.permissionTasks, turn.processTasks)); + } + + const returned = await settlePromiseWithTimeout( + Promise.resolve().then(() => query.return()), + { + label: "Claude Agent SDK query return", + timeoutMs: CLAUDE_QUERY_RETURN_TIMEOUT_MS, + }, + ); + if (returned.status !== "completed") { + turn.abortController.abort(reason); + context.logger.debug("driver.claude.turn.return_failed", { + message: toErrorMessage(returned.error, "query return failed"), + reason, + runId: turn.runId, + }); + } + } + + await drainClaudeTasks(turn.permissionTasks, turn.processTasks); + })(); } await turn.queryCloseTask; } - async #publishNativeResumeRef(context: AgentDriverContext): Promise { - if (!this.#nativeSessionId) { - throw new Error("Claude native session id is required before publishing resume ref."); - } - + async #publishNativeResumeRef( + context: AgentDriverContext, + nativeSessionId: string, + ): Promise { await this.#push(context, "driver.claude.native_resume_ref.updated", [ { kind: "runtime.resume.updated", payload: { - resumePointer: this.#nativeSessionId, + resumePointer: nativeSessionId, threadId: null, }, visibility: "owner_debug", diff --git a/src/runtimes/claude/agent-sdk-event-writer.ts b/src/runtimes/claude/agent-sdk-event-writer.ts index c545a21..fdf9f9f 100644 --- a/src/runtimes/claude/agent-sdk-event-writer.ts +++ b/src/runtimes/claude/agent-sdk-event-writer.ts @@ -1,18 +1,64 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import type { ProtocolError } from "../../contract"; import type { DriverEventInput } from "../../protocol/events"; import type { MessageId, RunId } from "../../protocol/id"; +import type { JsonValue } from "../../protocol/json"; import type { AgentDriverContext } from "../../core/agent-driver-backend"; +import { chunkJsonText } from "../provider-json"; import type { JsonObject } from "./agent-sdk-json"; import { toClaudeDiagnosticEvent, toClaudeUsageUpdatedEvents } from "./agent-sdk-message-events"; +const MAX_CLAUDE_MESSAGE_CHUNK_BYTES = 512 * 1_024; +const MAX_CLAUDE_DURABLE_EVENT_BYTES = 1_020 * 1_024; + +function claudeDurableEventBytes(event: DriverEventInput): number { + return Buffer.byteLength(JSON.stringify(event), "utf8"); +} + +export class ClaudeDurableEventTooLargeError extends RangeError { + readonly code: string; + + constructor(code: string, subject: string, bytes: number) { + super(`Claude ${subject} exceeds durable event capacity (${String(bytes)} UTF-8 bytes).`); + this.code = code; + this.name = "ClaudeDurableEventTooLargeError"; + } +} + +export function assertClaudeDurableEventFits( + event: DriverEventInput, + code: string, + subject: string, +): void { + const bytes = claudeDurableEventBytes(event); + + if (bytes > MAX_CLAUDE_DURABLE_EVENT_BYTES) { + throw new ClaudeDurableEventTooLargeError(code, subject, bytes); + } +} + interface ClaudeAgentSdkEventWriterOptions { push(context: AgentDriverContext, reason: string, events: DriverEventInput[]): Promise; } +export type ClaudeTerminalOutcome = + | (DriverEventInput & { + readonly kind: "run.cancelled"; + readonly payload: { readonly [key: string]: unknown; readonly reason: string }; + }) + | (DriverEventInput & { readonly kind: "run.completed" }) + | (DriverEventInput & { + readonly kind: "run.failed"; + readonly payload: { + readonly [key: string]: unknown; + readonly error: { readonly [key: string]: unknown; readonly message: string }; + }; + }); + export interface ClaudeToolStartEvent { context: AgentDriverContext; - parentMessageId: string; + parentMessageId?: string; toolCallId: string; toolCallName: string; } @@ -38,15 +84,34 @@ export interface ClaudeToolArgumentsEvent { } export interface ClaudeToolResultEvent { - content: string; + agentId?: string; + authoritative?: boolean; + content?: string; context: AgentDriverContext; - messageId: string; - status: "completed" | "failed"; + decisionReason?: string; + decisionReasonType?: string; + messageId?: string; + nonExecutionKind?: string; + rawInput?: string; + status: "cancelled" | "completed" | "failed"; + structuredOutput?: JsonValue; toolCallId: string; + toolCallName?: string; + userFeedback?: string; +} + +export interface ClaudeTurnClosure { + readonly commit: () => void; + readonly events: readonly DriverEventInput[]; } +type ClaudeMessageSettlement = + | { readonly status: "cancelled" | "completed" } + | { readonly error: ProtocolError; readonly status: "failed" }; + export class ClaudeAgentSdkEventWriter { readonly #messageEnded = new Set(); + readonly #messageRetracted = new Set(); readonly #messageSealed = new Set(); readonly #messageStarted = new Set(); readonly #options: ClaudeAgentSdkEventWriterOptions; @@ -55,6 +120,7 @@ export class ClaudeAgentSdkEventWriter { readonly #toolEnded = new Set(); readonly #toolParentMessage = new Map(); readonly #toolStarted = new Set(); + readonly #toolRetracted = new Set(); constructor(options: ClaudeAgentSdkEventWriterOptions) { this.#options = options; @@ -66,6 +132,7 @@ export class ClaudeAgentSdkEventWriter { resetTurnState(): void { this.#messageEnded.clear(); + this.#messageRetracted.clear(); this.#messageSealed.clear(); this.#messageStarted.clear(); this.#thoughtEnded.clear(); @@ -73,28 +140,80 @@ export class ClaudeAgentSdkEventWriter { this.#toolEnded.clear(); this.#toolParentMessage.clear(); this.#toolStarted.clear(); + this.#toolRetracted.clear(); } toolParentMessageId(toolCallId: string): string | null { return this.#toolParentMessage.get(toolCallId) ?? null; } - async endMessage(context: AgentDriverContext, messageId: string): Promise { + async settleMessage( + context: AgentDriverContext, + messageId: string, + settlement: ClaudeMessageSettlement, + ): Promise { if (!this.#messageStarted.has(messageId) || this.#messageEnded.has(messageId)) { return false; } + const event: DriverEventInput = + settlement.status === "failed" + ? { + kind: "message.failed", + payload: { error: settlement.error, messageId, role: "agent" }, + } + : { + kind: settlement.status === "completed" ? "message.completed" : "message.cancelled", + payload: { messageId, role: "agent" }, + }; + await this.#options.push( + context, + settlement.status === "completed" + ? "driver.claude.message.ended" + : `driver.claude.message.${settlement.status}`, + [event], + ); this.#messageEnded.add(messageId); - await this.#push(context, "driver.claude.message.ended", [ + return true; + } + + async retractMessage(context: AgentDriverContext, messageId: string): Promise { + if (!this.#messageStarted.has(messageId) || this.#messageRetracted.has(messageId)) { + return; + } + + await this.#options.push(context, "driver.claude.message.retracted", [ { - kind: "message.completed", - payload: { - messageId, - role: "agent", - }, + kind: "message.cancelled", + payload: { messageId, reason: "superseded", role: "agent" }, }, ]); - return true; + this.#messageRetracted.add(messageId); + this.#messageEnded.add(messageId); + } + + async retractTool(context: AgentDriverContext, toolCallId: string): Promise { + if (!this.#toolStarted.has(toolCallId) || this.#toolRetracted.has(toolCallId)) { + return; + } + + const ended = this.#toolEnded.has(toolCallId); + await this.#options.push(context, "driver.claude.tool.retracted", [ + { + kind: "tool.call.updated", + payload: { status: "cancelled", toolCallId }, + }, + ...(ended + ? [] + : [ + { + kind: "item.completed" as const, + payload: { itemId: toolCallId, itemType: "tool_call", status: "cancelled" }, + }, + ]), + ]); + this.#toolRetracted.add(toolCallId); + this.#toolEnded.add(toolCallId); } async ensureMessageStarted(context: AgentDriverContext, messageId: string): Promise { @@ -102,8 +221,7 @@ export class ClaudeAgentSdkEventWriter { return; } - this.#messageStarted.add(messageId); - await this.#push(context, "driver.claude.message.started", [ + await this.#options.push(context, "driver.claude.message.started", [ { kind: "message.started", payload: { @@ -112,6 +230,7 @@ export class ClaudeAgentSdkEventWriter { }, }, ]); + this.#messageStarted.add(messageId); } async ensureToolStarted({ @@ -124,16 +243,13 @@ export class ClaudeAgentSdkEventWriter { return; } - await this.ensureMessageStarted(context, parentMessageId); - this.#toolStarted.add(toolCallId); - this.#toolParentMessage.set(toolCallId, parentMessageId); - await this.#push(context, "driver.claude.tool.started", [ + const events: DriverEventInput[] = [ { kind: "item.started", payload: { itemId: toolCallId, itemType: "tool_call", - parentMessageId, + ...(parentMessageId === undefined ? {} : { parentMessageId }), title: toolCallName, }, }, @@ -141,96 +257,182 @@ export class ClaudeAgentSdkEventWriter { kind: "tool.call.updated", payload: { kind: "tool", - parentMessageId, + ...(parentMessageId === undefined ? {} : { parentMessageId }), status: "running", title: toolCallName, toolCallId, }, }, - ]); + ]; + + for (const event of events) { + assertClaudeDurableEventFits(event, "claude.tool_start_too_large", "tool start"); + } + + if (parentMessageId !== undefined) { + await this.ensureMessageStarted(context, parentMessageId); + } + await this.#options.push(context, "driver.claude.tool.started", events); + if (parentMessageId !== undefined) { + this.#toolParentMessage.set(toolCallId, parentMessageId); + } + this.#toolStarted.add(toolCallId); } async pushDiagnostic(context: AgentDriverContext, message: SDKMessage): Promise { - await this.pushRaw(context, "driver.claude.diagnostic", toClaudeDiagnosticEvent(message)); + await this.pushRawDiagnostic( + context, + "driver.claude.diagnostic", + toClaudeDiagnosticEvent(message), + ); } - async pushRaw(context: AgentDriverContext, reason: string, event: JsonObject): Promise { - await this.#push(context, reason, [ + async pushRawDiagnostic( + context: AgentDriverContext, + reason: string, + event: JsonObject, + options: { + readonly message?: string; + readonly severity?: "error" | "info" | "warn"; + } = {}, + ): Promise { + await this.#options.push(context, reason, [ { + delivery: "best_effort", kind: "diagnostic.reported", payload: { - message: reason, + message: options.message ?? reason, raw: event, - severity: "info", + severity: options.severity ?? "info", }, visibility: "owner_debug", }, ]); } - async pushRunError( - context: AgentDriverContext, + runError( runId: RunId, code: string, message: string, - ): Promise { - await this.#push(context, "driver.claude.turn.failed", [ - { - kind: "run.failed", - payload: { - error: { - code, - message, + retryable: boolean, + details?: JsonObject, + ): Extract { + const event: Extract = { + kind: "run.failed", + payload: { + error: { + code, + ...(details === undefined ? {} : { details }), + message, + retryable, + }, + recoverable: retryable, + }, + runId, + }; + + if (claudeDurableEventBytes(event) <= MAX_CLAUDE_DURABLE_EVENT_BYTES) { + return event; + } + + return { + kind: "run.failed", + payload: { + error: { + code, + details: { + ...(details === undefined + ? {} + : { originalDetailsUtf8Bytes: Buffer.byteLength(JSON.stringify(details), "utf8") }), + originalMessageUtf8Bytes: Buffer.byteLength(message, "utf8"), }, - recoverable: false, + message: "Claude Agent SDK failure exceeded durable event capacity.", + retryable, }, - runId, + recoverable: retryable, }, - ]); + runId, + }; } - async pushRunFinished( - context: AgentDriverContext, + runFinished( runId: RunId, - finalMessage: { id: MessageId; text: string } | null, - ): Promise { - await this.#push(context, "driver.claude.turn.completed", [ - { - runId, - kind: "run.completed", - payload: { - ...(finalMessage === null - ? {} - : { - finalMessageId: finalMessage.id, - finalMessageText: finalMessage.text, - }), - stopReason: "end_turn", - }, + finalMessage: { readonly id: MessageId } | null, + structuredOutput?: JsonValue, + ): Extract { + return { + runId, + kind: "run.completed", + payload: { + ...(finalMessage === null ? {} : { finalMessageId: finalMessage.id }), + stopReason: "end_turn", + ...(structuredOutput === undefined ? {} : { structuredOutput }), }, - ]); + }; + } + + runCancelled( + runId: RunId, + reason: string, + ): Extract { + const event: Extract = { + kind: "run.cancelled", + payload: { reason, stopReason: "cancelled" }, + runId, + }; + + if (claudeDurableEventBytes(event) <= MAX_CLAUDE_DURABLE_EVENT_BYTES) { + return event; + } + + return { + kind: "run.cancelled", + payload: { + originalReasonUtf8Bytes: Buffer.byteLength(reason, "utf8"), + reason: "Claude cancellation reason exceeded durable event capacity.", + stopReason: "cancelled", + }, + runId, + }; } async pushMessageSnapshot( context: AgentDriverContext, messageId: string, text: string, + metadata: JsonObject = {}, ): Promise { if (this.#messageEnded.has(messageId)) { return false; } await this.ensureMessageStarted(context, messageId); - await this.#push(context, "driver.claude.message.snapshot", [ + const chunks = chunkJsonText(text, MAX_CLAUDE_MESSAGE_CHUNK_BYTES); + const events: DriverEventInput[] = [ { kind: "message.added", payload: { - content: [{ text, type: "text" }], + ...metadata, + content: [{ text: chunks[0]!, type: "text" }], messageId, role: "agent", }, }, - ]); + ...chunks.slice(1).map((contentDelta): DriverEventInput => ({ + kind: "message.delta", + payload: { contentDelta, messageId, role: "agent" }, + })), + ]; + + for (const event of events) { + assertClaudeDurableEventFits( + event, + "claude.message_snapshot_too_large", + `message snapshot ${messageId}`, + ); + } + + await this.#options.push(context, "driver.claude.message.snapshot", events); return true; } @@ -238,11 +440,12 @@ export class ClaudeAgentSdkEventWriter { this.#messageSealed.add(messageId); } - async pushSessionInfoUpdated(context: AgentDriverContext): Promise { - await this.#push(context, "driver.claude.session.info", [ + async pushSessionInfoUpdated(context: AgentDriverContext, resetTitle = false): Promise { + await this.#options.push(context, "driver.claude.session.info", [ { kind: "session.info.updated", payload: { + ...(resetTitle ? { title: null } : {}), updatedAt: new Date().toISOString(), }, }, @@ -260,7 +463,7 @@ export class ClaudeAgentSdkEventWriter { } await this.ensureMessageStarted(context, messageId); - await this.#push(context, reason, [ + await this.#options.push(context, reason, [ { delivery: "best_effort", kind: "message.delta", @@ -279,8 +482,7 @@ export class ClaudeAgentSdkEventWriter { return; } - this.#thoughtStarted.add(thoughtId); - await this.#push(context, "driver.claude.thought.started", [ + await this.#options.push(context, "driver.claude.thought.started", [ { kind: "thought.started", payload: { @@ -289,6 +491,7 @@ export class ClaudeAgentSdkEventWriter { }, }, ]); + this.#thoughtStarted.add(thoughtId); } async pushThoughtDelta({ context, delta, thoughtId }: ClaudeThoughtDeltaEvent): Promise { @@ -297,7 +500,7 @@ export class ClaudeAgentSdkEventWriter { } await this.ensureThoughtStarted(context, thoughtId); - await this.#push(context, "driver.claude.thought.delta", [ + await this.#options.push(context, "driver.claude.thought.delta", [ { delivery: "best_effort", kind: "thought.delta", @@ -310,21 +513,22 @@ export class ClaudeAgentSdkEventWriter { ]); } - async endThought(context: AgentDriverContext, thoughtId: string): Promise { + async settleThought( + context: AgentDriverContext, + thoughtId: string, + status: "cancelled" | "completed", + ): Promise { if (!this.#thoughtStarted.has(thoughtId) || this.#thoughtEnded.has(thoughtId)) { return; } - this.#thoughtEnded.add(thoughtId); - await this.#push(context, "driver.claude.thought.completed", [ + await this.#options.push(context, `driver.claude.thought.${status}`, [ { - kind: "thought.completed", - payload: { - channel: "summary", - thoughtId, - }, + kind: status === "completed" ? "thought.completed" : "thought.cancelled", + payload: { channel: "summary", thoughtId }, }, ]); + this.#thoughtEnded.add(thoughtId); } async pushToolArguments({ @@ -337,12 +541,12 @@ export class ClaudeAgentSdkEventWriter { return; } - await this.#push(context, reason, [ + await this.#options.push(context, reason, [ { delivery: "best_effort", kind: "tool.call.updated", payload: { - rawInput: delta, + rawInputDelta: delta, status: "running", toolCallId, }, @@ -359,30 +563,29 @@ export class ClaudeAgentSdkEventWriter { return; } - await this.#push(context, "driver.claude.tool.snapshot", [ - { - kind: "tool.call.updated", - payload: { - rawInput, - status: "running", - toolCallId, - }, + const event: DriverEventInput = { + kind: "tool.call.updated", + payload: { + rawInput, + status: "running", + toolCallId, }, - ]); + }; + assertClaudeDurableEventFits(event, "claude.tool_input_too_large", `tool input ${toolCallId}`); + await this.#options.push(context, "driver.claude.tool.snapshot", [event]); } - async finishTools(context: AgentDriverContext, status: "completed" | "failed"): Promise { + async finishTools( + context: AgentDriverContext, + status: "cancelled" | "completed" | "failed", + ): Promise { const toolCallIds = [...this.#toolStarted].filter((id) => !this.#toolEnded.has(id)); if (toolCallIds.length === 0) { return; } - for (const toolCallId of toolCallIds) { - this.#toolEnded.add(toolCallId); - } - - await this.#push( + await this.#options.push( context, "driver.claude.tools.finished", toolCallIds.flatMap((toolCallId): DriverEventInput[] => [ @@ -396,16 +599,85 @@ export class ClaudeAgentSdkEventWriter { }, ]), ); + for (const toolCallId of toolCallIds) { + this.#toolEnded.add(toolCallId); + } + } + + prepareTurnClosure(status: "cancelled" | "completed" | "failed"): ClaudeTurnClosure { + const messageIds = [...this.#messageStarted].filter((id) => !this.#messageEnded.has(id)); + const thoughtIds = [...this.#thoughtStarted].filter((id) => !this.#thoughtEnded.has(id)); + const toolCallIds = [...this.#toolStarted].filter((id) => !this.#toolEnded.has(id)); + const events: DriverEventInput[] = [ + ...thoughtIds.map((thoughtId): DriverEventInput => ({ + kind: status === "completed" ? "thought.completed" : "thought.cancelled", + payload: { channel: "summary", thoughtId }, + })), + ...messageIds.map((messageId): DriverEventInput => ({ + kind: + status === "cancelled" + ? "message.cancelled" + : status === "failed" + ? "message.failed" + : "message.completed", + payload: + status === "failed" + ? { + error: { + code: "claude.turn_failed", + message: "Claude Agent SDK turn failed.", + retryable: false, + }, + messageId, + role: "agent", + } + : { messageId, role: "agent" }, + })), + ...toolCallIds.flatMap((toolCallId): DriverEventInput[] => [ + { + kind: "tool.call.updated", + payload: { status, toolCallId }, + }, + { + kind: "item.completed", + payload: { itemId: toolCallId, itemType: "tool_call", status }, + }, + ]), + ]; + + return { + commit: () => { + for (const thoughtId of thoughtIds) this.#thoughtEnded.add(thoughtId); + for (const messageId of messageIds) this.#messageEnded.add(messageId); + for (const toolCallId of toolCallIds) this.#toolEnded.add(toolCallId); + }, + events, + }; } async pushToolResult({ + agentId, + authoritative = false, content, context, + decisionReason, + decisionReasonType, messageId, + nonExecutionKind, + rawInput, status, + structuredOutput, toolCallId, + toolCallName, + userFeedback, }: ClaudeToolResultEvent): Promise { - if (this.#toolEnded.has(toolCallId)) { + if (this.#toolRetracted.has(toolCallId)) { + return; + } + + const ended = this.#toolEnded.has(toolCallId); + + if (ended && !authoritative) { return; } @@ -413,26 +685,43 @@ export class ClaudeAgentSdkEventWriter { { kind: "tool.call.updated", payload: { - content, - messageId, - rawOutput: content, + ...(agentId === undefined ? {} : { agentId }), + ...(content === undefined ? {} : { content }), + ...(decisionReason === undefined ? {} : { decisionReason }), + ...(decisionReasonType === undefined ? {} : { decisionReasonType }), + ...(messageId === undefined ? {} : { messageId }), + ...(nonExecutionKind === undefined ? {} : { nonExecutionKind }), + ...(rawInput === undefined ? {} : { rawInput }), status, + ...(structuredOutput === undefined ? {} : { structuredOutput }), + ...(toolCallName === undefined ? {} : { title: toolCallName }), toolCallId, + ...(userFeedback === undefined ? {} : { userFeedback }), }, }, ]; - this.#toolEnded.add(toolCallId); - events.push({ - kind: "item.completed", - payload: { - itemId: toolCallId, - itemType: "tool_call", - status, - }, - }); + assertClaudeDurableEventFits( + events[0]!, + "claude.tool_result_too_large", + `tool result ${toolCallId}`, + ); + + if (!ended && this.#toolStarted.has(toolCallId)) { + events.push({ + kind: "item.completed", + payload: { + itemId: toolCallId, + itemType: "tool_call", + status, + }, + }); + } - await this.#push(context, "driver.claude.tool.result", events); + await this.#options.push(context, "driver.claude.tool.result", events); + if (!ended && this.#toolStarted.has(toolCallId)) { + this.#toolEnded.add(toolCallId); + } } async pushUsage( @@ -446,14 +735,6 @@ export class ClaudeAgentSdkEventWriter { return; } - await this.#push(context, "driver.claude.usage.updated", events); - } - - async #push( - context: AgentDriverContext, - reason: string, - events: DriverEventInput[], - ): Promise { - await this.#options.push(context, reason, events); + await this.#options.push(context, "driver.claude.usage.updated", events); } } diff --git a/src/runtimes/claude/agent-sdk-json.ts b/src/runtimes/claude/agent-sdk-json.ts index af11f34..9cb726f 100644 --- a/src/runtimes/claude/agent-sdk-json.ts +++ b/src/runtimes/claude/agent-sdk-json.ts @@ -1,13 +1,12 @@ -import type { JsonObject } from "../provider-json"; - -export { isRecord, readRecord, readString, stringifyForDisplay } from "../provider-json"; +export { + isRecord, + readNumber, + readRecord, + readString, + stringifyForDisplay, +} from "../provider-json"; export type { JsonObject } from "../provider-json"; -export function readNumber(value: JsonObject | null, key: string): number | null { - const entry = value?.[key]; - return typeof entry === "number" && Number.isFinite(entry) ? entry : null; -} - export function toTokenCount(value: unknown): number | null { return typeof value === "number" && value >= 0 && Number.isSafeInteger(value) ? value : null; } diff --git a/src/runtimes/claude/agent-sdk-message-events.ts b/src/runtimes/claude/agent-sdk-message-events.ts index 6207d47..42ed75f 100644 --- a/src/runtimes/claude/agent-sdk-message-events.ts +++ b/src/runtimes/claude/agent-sdk-message-events.ts @@ -22,9 +22,11 @@ export function toClaudeFilesPersistedEvents(message: SDKFilesPersistedEvent): D if (message.failed.length > 0) { events.push({ + delivery: "best_effort", kind: "diagnostic.reported", payload: { - failed: message.failed, + failedCount: message.failed.length, + failedUtf8Bytes: Buffer.byteLength(JSON.stringify(message.failed), "utf8"), message: "Claude file persistence failed.", severity: "warn", }, @@ -46,12 +48,42 @@ export function toClaudeDiagnosticEvent(message: SDKMessage): JsonObject { }; } +function sumModelUsage(modelUsage: unknown, key: string): number | null { + if (!isRecord(modelUsage)) { + return null; + } + + const counts = Object.values(modelUsage).flatMap((value) => { + const count = isRecord(value) ? toTokenCount(value[key]) : null; + return count === null ? [] : [count]; + }); + return counts.length === 0 ? null : toTokenCount(counts.reduce((total, count) => total + count)); +} + +export function aggregateClaudeModelUsage(modelUsage: unknown): JsonObject | null { + const inputTokens = sumModelUsage(modelUsage, "inputTokens"); + const outputTokens = sumModelUsage(modelUsage, "outputTokens"); + const thinkingTokens = sumModelUsage(modelUsage, "thinkingTokens"); + const cacheReadTokens = sumModelUsage(modelUsage, "cacheReadInputTokens"); + const cacheCreationTokens = sumModelUsage(modelUsage, "cacheCreationInputTokens"); + const usage: JsonObject = { + ...(cacheCreationTokens === null ? {} : { cache_creation_input_tokens: cacheCreationTokens }), + ...(cacheReadTokens === null ? {} : { cache_read_input_tokens: cacheReadTokens }), + ...(inputTokens === null ? {} : { input_tokens: inputTokens }), + ...(outputTokens === null ? {} : { output_tokens: outputTokens }), + ...(thinkingTokens === null ? {} : { thinking_tokens: thinkingTokens }), + }; + + return Object.keys(usage).length === 0 ? null : usage; +} + export function toClaudeUsageUpdatedEvents( usage: JsonObject | null, costAmount: number | null, ): DriverEventInput[] { const inputTokens = toTokenCount(usage?.["input_tokens"]); const outputTokens = toTokenCount(usage?.["output_tokens"]); + const thoughtTokens = toTokenCount(usage?.["thinking_tokens"]); const cacheReadTokens = toTokenCount(usage?.["cache_read_input_tokens"]); const cacheCreationTokens = toTokenCount(usage?.["cache_creation_input_tokens"]); const totalTokens = sumTokenCounts(inputTokens, outputTokens); @@ -60,6 +92,7 @@ export function toClaudeUsageUpdatedEvents( if ( inputTokens === null && outputTokens === null && + thoughtTokens === null && cacheReadTokens === null && cacheCreationTokens === null && cost === null @@ -79,7 +112,7 @@ export function toClaudeUsageUpdatedEvents( outputTokens, size: null, source: "session_update", - thoughtTokens: null, + thoughtTokens, totalTokens, usageContract: "anthropic_bucketed", used: null, diff --git a/src/runtimes/claude/agent-sdk-message-state.ts b/src/runtimes/claude/agent-sdk-message-state.ts index a3002ab..c40ad7c 100644 --- a/src/runtimes/claude/agent-sdk-message-state.ts +++ b/src/runtimes/claude/agent-sdk-message-state.ts @@ -1,7 +1,7 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; -import type { MessageId, RunId } from "../../protocol/id"; -import { RuntimeAssistantMessageIdIndex } from "../runtime-turn-transcript"; +import type { MessageId, RunId, SessionId } from "../../protocol/id"; +import { createRuntimeAssistantMessageId } from "../runtime-turn-transcript"; import { isRecord, readRecord, readString } from "./agent-sdk-json"; interface ClaudeAssistantFinalCandidate { @@ -39,24 +39,30 @@ export class ClaudeAgentSdkMessageState { readonly #activeAssistantMessageIds = new Map(); readonly #activeThoughtIds = new Map(); readonly #authoritativeAssistantMessageIds = new Set(); - readonly #assistantMessageIds = new RuntimeAssistantMessageIdIndex(); readonly #assistantMessageOrdinals = new Map(); readonly #assistantMessageRunIds = new Map(); readonly #assistantMessageSequences = new Map(); readonly #blockToolCallIds = new Map>(); - readonly #assistantNativeAliases = new Map(); + readonly #assistantWireAliases = new Map(); readonly #lastCompletedAssistantMessages = new Map(); readonly #pendingAssistantStreamAnchors = new Map(); + readonly #sessionId: SessionId; readonly #streamedTextMessages = new Set(); readonly #streamingNativeMessageIds = new Map(); + readonly #streamingWireUuids = new Map(); readonly #textByAssistantMessageId = new Map(); + readonly #wireAssistantMessageIds = new Map(); + readonly #wireToolCallIds = new Map(); + + constructor(sessionId: SessionId) { + this.#sessionId = sessionId; + } reset(): void { this.#activeAssistantMessageIds.clear(); this.#activeThoughtIds.clear(); - this.#assistantNativeAliases.clear(); + this.#assistantWireAliases.clear(); this.#authoritativeAssistantMessageIds.clear(); - this.#assistantMessageIds.reset(); this.#assistantMessageOrdinals.clear(); this.#assistantMessageRunIds.clear(); this.#assistantMessageSequences.clear(); @@ -65,7 +71,10 @@ export class ClaudeAgentSdkMessageState { this.#pendingAssistantStreamAnchors.clear(); this.#streamedTextMessages.clear(); this.#streamingNativeMessageIds.clear(); + this.#streamingWireUuids.clear(); this.#textByAssistantMessageId.clear(); + this.#wireAssistantMessageIds.clear(); + this.#wireToolCallIds.clear(); } assistantMessageId(runId: RunId, nativeMessageId: string | null): MessageId { @@ -73,12 +82,20 @@ export class ClaudeAgentSdkMessageState { let messageId: MessageId; if (nativeMessageId !== null) { - messageId = this.#assistantMessageIds.getOrCreate(`${runId}:native:${nativeMessageId}`); + messageId = createRuntimeAssistantMessageId( + this.#sessionId, + "claude-assistant", + `${runId}:native:${nativeMessageId}`, + ); } else if (active !== undefined) { messageId = active; } else { const ordinal = this.#nextAssistantMessageSequence(runId); - messageId = this.#assistantMessageIds.getOrCreate(`${runId}:sequence:${ordinal}`); + messageId = createRuntimeAssistantMessageId( + this.#sessionId, + "claude-assistant", + `${runId}:sequence:${ordinal}`, + ); this.#assistantMessageOrdinals.set(messageId, ordinal); } @@ -88,10 +105,51 @@ export class ClaudeAgentSdkMessageState { return messageId; } + auxiliaryMessageId(runId: RunId, nativeMessageId: string): MessageId { + return createRuntimeAssistantMessageId( + this.#sessionId, + "claude-auxiliary", + `${runId}:${nativeMessageId}`, + ); + } + assistantMessages(): readonly (readonly [MessageId, RunId])[] { return [...this.#assistantMessageRunIds]; } + bindWireAssistantMessage(wireUuid: string, messageId: MessageId): void { + this.#wireAssistantMessageIds.set(wireUuid, messageId); + } + + bindWireToolCalls(wireUuid: string, toolCallIds: readonly string[]): void { + if (toolCallIds.length > 0) { + this.#wireToolCallIds.set(wireUuid, [...toolCallIds]); + } + } + + wireItems(wireUuid: string): { + readonly messageId: MessageId | null; + readonly toolCallIds: readonly string[]; + } { + return { + messageId: this.#wireAssistantMessageIds.get(wireUuid) ?? null, + toolCallIds: this.#wireToolCallIds.get(wireUuid) ?? [], + }; + } + + commitWireMessageRetraction(wireUuid: string, messageId: MessageId): void { + if (this.#wireAssistantMessageIds.get(wireUuid) !== messageId) { + return; + } + + this.#wireAssistantMessageIds.delete(wireUuid); + this.#retractAssistantMessage(messageId); + } + + commitWireToolRetractions(wireUuid: string): void { + this.#wireToolCallIds.delete(wireUuid); + } + activeAssistantMessageId(runId: RunId): MessageId | undefined { return this.#activeAssistantMessageIds.get(runId); } @@ -141,16 +199,20 @@ export class ClaudeAgentSdkMessageState { return thoughtId; } - takeThoughtId(messageId: string): string | undefined { - const thoughtId = this.#activeThoughtIds.get(messageId); + activeThoughts(): readonly (readonly [string, string])[] { + return [...this.#activeThoughtIds]; + } + + thoughtIdForMessage(messageId: string): string | undefined { + return this.#activeThoughtIds.get(messageId); + } + + deleteThoughtId(messageId: string): void { this.#activeThoughtIds.delete(messageId); - return thoughtId; } - takeAllThoughtIds(): readonly string[] { - const thoughtIds = [...this.#activeThoughtIds.values()]; + clearThoughtIds(): void { this.#activeThoughtIds.clear(); - return thoughtIds; } streamScopeKey(runId: RunId, message: SDKMessage): string { @@ -160,6 +222,7 @@ export class ClaudeAgentSdkMessageState { setStreamingNativeMessageId(scope: string, nativeMessageId: string): void { this.#streamingNativeMessageIds.set(scope, { confirmed: true, nativeId: nativeMessageId }); + this.#streamingWireUuids.delete(scope); this.#pendingAssistantStreamAnchors.delete(scope); } @@ -193,6 +256,7 @@ export class ClaudeAgentSdkMessageState { clearStreamingNativeMessageId(scope: string): void { const anchor = this.#streamingNativeMessageIds.get(scope); this.#streamingNativeMessageIds.delete(scope); + this.#streamingWireUuids.delete(scope); // The aggregated assistant envelope for this burst arrives after // message_stop; park the anchor so that envelope can bind to the streamed @@ -209,13 +273,15 @@ export class ClaudeAgentSdkMessageState { * anchor — one envelope aggregates one burst. A confirmed anchor proves * the stream's identity and the envelope's native id wins. */ - resolveAssistantMessageNativeId(scope: string, nativeMessageId: string | null): string | null { - if (nativeMessageId !== null) { - const alias = this.#assistantNativeAliases.get(nativeMessageId); - - if (alias !== undefined) { - return alias; - } + resolveAssistantMessageNativeId( + scope: string, + nativeMessageId: string | null, + wireUuid: string, + ): string { + const alias = this.#assistantWireAliases.get(wireUuid); + + if (alias !== undefined) { + return alias; } const pending = this.#pendingAssistantStreamAnchors.get(scope); @@ -223,33 +289,40 @@ export class ClaudeAgentSdkMessageState { if (pending !== undefined) { this.#pendingAssistantStreamAnchors.delete(scope); return pending.confirmed - ? (nativeMessageId ?? pending.nativeId) - : this.#bindAssistantNativeAlias(pending.nativeId, nativeMessageId); + ? nativeMessageId === pending.nativeId + ? this.#bindAssistantWireAlias(pending.nativeId, wireUuid) + : wireUuid + : this.#bindAssistantWireAlias(pending.nativeId, wireUuid); } const live = this.#streamingNativeMessageIds.get(scope); if (live === undefined) { - return nativeMessageId; + return wireUuid; } if (live.confirmed) { // The envelope arrived before message_stop; keep the confirmed anchor // so the remaining stream frames stay on the same message. - return nativeMessageId ?? live.nativeId; + const boundWireUuid = this.#streamingWireUuids.get(scope); + if ( + nativeMessageId === live.nativeId && + (boundWireUuid === undefined || boundWireUuid === wireUuid) + ) { + this.#streamingWireUuids.set(scope, wireUuid); + return this.#bindAssistantWireAlias(live.nativeId, wireUuid); + } + return wireUuid; } this.#streamingNativeMessageIds.delete(scope); - return this.#bindAssistantNativeAlias(live.nativeId, nativeMessageId); + return this.#bindAssistantWireAlias(live.nativeId, wireUuid); } - #bindAssistantNativeAlias(burstNativeId: string, nativeMessageId: string | null): string { - if (nativeMessageId !== null) { - // A replayed envelope re-presents the same native id after the anchor - // is consumed; remember the binding so it stays on the same message. - this.#assistantNativeAliases.set(nativeMessageId, burstNativeId); - } - + #bindAssistantWireAlias(burstNativeId: string, wireUuid: string): string { + // A replayed envelope re-presents the same wire uuid after the anchor is + // consumed; remember the binding so it stays on the same message. + this.#assistantWireAliases.set(wireUuid, burstNativeId); return burstNativeId; } @@ -313,6 +386,26 @@ export class ClaudeAgentSdkMessageState { return readString(readRecord(message, "message"), "id") ?? readString(message, "uuid"); } + #retractAssistantMessage(messageId: MessageId): void { + const runId = this.#assistantMessageRunIds.get(messageId); + + if (runId !== undefined) { + if (this.#activeAssistantMessageIds.get(runId) === messageId) { + this.#activeAssistantMessageIds.delete(runId); + } + + if (this.#lastCompletedAssistantMessages.get(runId)?.id === messageId) { + this.#lastCompletedAssistantMessages.delete(runId); + } + } + + this.#assistantMessageRunIds.delete(messageId); + this.#authoritativeAssistantMessageIds.delete(messageId); + this.#blockToolCallIds.delete(messageId); + this.#streamedTextMessages.delete(messageId); + this.#textByAssistantMessageId.delete(messageId); + } + #nextAssistantMessageSequence(runId: RunId): number { const sequence = (this.#assistantMessageSequences.get(runId) ?? 0) + 1; this.#assistantMessageSequences.set(runId, sequence); diff --git a/src/runtimes/claude/agent-sdk-message-translator.ts b/src/runtimes/claude/agent-sdk-message-translator.ts index 6e8cd1f..1b51074 100644 --- a/src/runtimes/claude/agent-sdk-message-translator.ts +++ b/src/runtimes/claude/agent-sdk-message-translator.ts @@ -1,9 +1,15 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import { jsonValueSchema } from "../../contract"; import type { DriverEventInput } from "../../protocol/events"; -import type { MessageId, RunId } from "../../protocol/id"; +import type { MessageId, RunId, SessionId } from "../../protocol/id"; import type { AgentDriverContext } from "../../core/agent-driver-backend"; -import { ClaudeAgentSdkEventWriter } from "./agent-sdk-event-writer"; +import { DriverCompletedTerminalSupersededError } from "../driver-event-publisher"; +import { + assertClaudeDurableEventFits, + ClaudeAgentSdkEventWriter, + type ClaudeTerminalOutcome, +} from "./agent-sdk-event-writer"; import { isRecord, readNumber, @@ -12,58 +18,173 @@ import { stringifyForDisplay, } from "./agent-sdk-json"; import type { JsonObject } from "./agent-sdk-json"; -import { toClaudeFilesPersistedEvents } from "./agent-sdk-message-events"; +import { + aggregateClaudeModelUsage, + toClaudeFilesPersistedEvents, +} from "./agent-sdk-message-events"; +import { + claudeAssistantOutcome, + claudePermissionDenialAdvisory, + claudePermissionDenials, + claudeResultErrorDetails, + claudeToolOutcome, + isClaudeResultCancelled, + isClaudeResultRetryable, + isClaudeResultSuccessful, +} from "./agent-sdk-outcomes"; import { ClaudeAgentSdkMessageState, readClaudeSdkSessionId } from "./agent-sdk-message-state"; +import { + claudeBackgroundTasksClosedEvent, + projectClaudeBackgroundTasksSnapshot, +} from "./agent-sdk-task-events"; import { isToolUseBlock, toToolCallId, toToolCallName, toToolResultText } from "./agent-sdk-tools"; +import type { ClaudePermissionDenialAdvisory } from "./agent-sdk-outcomes"; interface ClaudeMessageTranslatorOptions { + publicToolCallId(nativeToolCallId: string): string; + readonly sessionId: SessionId; push(context: AgentDriverContext, reason: string, events: DriverEventInput[]): Promise; + pushTerminal( + context: AgentDriverContext, + reason: string, + closures: readonly DriverEventInput[], + terminal: DriverEventInput, + ): Promise; recordNativeSessionId(context: AgentDriverContext, sessionId: string): Promise; + replaceNativeSessionId( + context: AgentDriverContext, + previousSessionId: string, + nextSessionId: string, + ): Promise; +} + +const MAX_CLAUDE_STRUCTURED_TERMINAL_BYTES = 512 * 1_024; + +function exhaustSdkMessage(value: never): unknown { + return value; +} + +export type { ClaudeTerminalOutcome } from "./agent-sdk-event-writer"; + +export interface ClaudePreparedResult { + readonly reason: string; + readonly terminal: ClaudeTerminalOutcome; + readonly toolStatus: "cancelled" | "completed" | "failed"; } export class ClaudeTerminalWriteError extends Error { override readonly name = "ClaudeTerminalWriteError"; + readonly terminalKind: ClaudeTerminalOutcome["kind"]; - constructor(cause: unknown) { + constructor(cause: unknown, terminalKind: ClaudeTerminalOutcome["kind"]) { super("Claude terminal delivery failed.", { cause }); + this.terminalKind = terminalKind; } } export class ClaudeAgentSdkMessageTranslator { readonly #events: ClaudeAgentSdkEventWriter; readonly #options: ClaudeMessageTranslatorOptions; - readonly #state = new ClaudeAgentSdkMessageState(); + readonly #permissionDenialAdvisories = new Map(); + readonly #state: ClaudeAgentSdkMessageState; + #turnClosureCommitted = false; constructor(options: ClaudeMessageTranslatorOptions) { this.#options = options; this.#events = new ClaudeAgentSdkEventWriter({ push: options.push }); + this.#state = new ClaudeAgentSdkMessageState(options.sessionId); } resetTurnMessageState(): void { this.#state.reset(); this.#events.resetTurnState(); + this.#permissionDenialAdvisories.clear(); + this.#turnClosureCommitted = false; } - async endActiveThought(context: AgentDriverContext): Promise { - for (const thoughtId of this.#state.takeAllThoughtIds()) { - await this.#events.endThought(context, thoughtId); + async #cancelOpenTurn(context: AgentDriverContext): Promise { + for (const [messageId, thoughtId] of this.#state.activeThoughts()) { + await this.#events.settleThought(context, thoughtId, "cancelled"); + this.#state.deleteThoughtId(messageId); } + + for (const [messageId, runId] of this.#state.assistantMessages()) { + await this.#events.settleMessage(context, messageId, { status: "cancelled" }); + this.#state.completeAssistantMessage(runId, messageId, false); + } + + await this.#events.finishTools(context, "cancelled"); + await this.#options.push(context, "driver.claude.tasks.finished", [ + claudeBackgroundTasksClosedEvent(), + ]); } - async finishTurn(context: AgentDriverContext, toolStatus: "completed" | "failed"): Promise { - await this.endActiveThought(context); + async finishTurnWithTerminal( + context: AgentDriverContext, + toolStatus: "cancelled" | "completed" | "failed", + terminal: DriverEventInput, + reason: string, + ): Promise { + const closure = this.#turnClosureCommitted ? null : this.#events.prepareTurnClosure(toolStatus); + const commit = () => { + if (closure === null) { + return; + } + closure.commit(); + this.#turnClosureCommitted = true; + this.#state.clearThoughtIds(); + for (const [messageId, runId] of this.#state.assistantMessages()) { + this.#state.completeAssistantMessage(runId, messageId, toolStatus === "completed"); + } + }; - for (const [messageId, runId] of this.#state.assistantMessages()) { - await this.#endAssistantMessage(context, runId, messageId); + try { + await this.#options.pushTerminal( + context, + reason, + closure === null ? [] : [...closure.events, claudeBackgroundTasksClosedEvent()], + terminal, + ); + } catch (error) { + if ( + terminal.kind === "run.completed" && + error instanceof DriverCompletedTerminalSupersededError + ) { + commit(); + } + throw error; } + commit(); + } - await this.#events.finishTools(context, toolStatus); + async cancelTurn(context: AgentDriverContext, runId: RunId, reason: string): Promise { + await this.finishTurnWithTerminal( + context, + "cancelled", + this.#events.runCancelled(runId, reason), + "driver.claude.turn.cancelled", + ); + } + + async failTurn( + context: AgentDriverContext, + runId: RunId, + code: string, + message: string, + ): Promise { + await this.finishTurnWithTerminal( + context, + "failed", + this.#events.runError(runId, code, message, false), + "driver.claude.turn.failed", + ); } async #endThought(context: AgentDriverContext, messageId: string): Promise { - const thoughtId = this.#state.takeThoughtId(messageId); + const thoughtId = this.#state.thoughtIdForMessage(messageId); if (thoughtId !== undefined) { - await this.#events.endThought(context, thoughtId); + await this.#events.settleThought(context, thoughtId, "completed"); + this.#state.deleteThoughtId(messageId); } } @@ -71,64 +192,108 @@ export class ClaudeAgentSdkMessageTranslator { context: AgentDriverContext, message: SDKMessage, runId: RunId, - ): Promise { + preserveOpenTurn = false, + ): Promise { + if (message.type === "result") { + return this.publishPreparedResult(context, await this.prepareResult(context, message, runId)); + } + const sessionId = readClaudeSdkSessionId(message); - if (sessionId) { + if (sessionId !== null && message.type !== "conversation_reset") { await this.#options.recordNativeSessionId(context, sessionId); } switch (message.type) { case "assistant": { await this.#handleAssistantMessage(context, message, runId); - return false; + return null; } case "auth_status": case "rate_limit_event": - case "tool_progress": case "tool_use_summary": { await this.#events.pushDiagnostic(context, message); - return false; + return null; + } + case "tool_progress": { + if (message.heartbeat !== true) { + await this.#events.pushDiagnostic(context, message); + } + return null; } - case "result": { - await this.#handleResultMessage(context, message, runId); - return true; + case "conversation_reset": { + await this.#handleConversationReset(context, message, preserveOpenTurn); + return null; } case "stream_event": { await this.#handleStreamEvent(context, message, runId); - return false; + return null; } case "system": { - await this.#handleSystemMessage(context, message); - return false; + await this.#handleSystemMessage(context, message, runId); + return null; } case "user": { await this.#handleUserMessage(context, message, runId); - return false; + return null; } case "prompt_suggestion": { - return false; + return null; } default: { - return false; + const unexpected = exhaustSdkMessage(message); + await this.#events.pushRawDiagnostic( + context, + "driver.claude.message.unknown", + isRecord(unexpected) ? unexpected : { value: String(unexpected) }, + ); + return null; } } } + async prepareResult( + context: AgentDriverContext, + message: Extract, + runId: RunId, + ): Promise { + const sessionId = readClaudeSdkSessionId(message); + if (sessionId !== null) { + await this.#options.recordNativeSessionId(context, sessionId); + } + return this.#prepareResultMessage(context, message, runId); + } + + async publishPreparedResult( + context: AgentDriverContext, + result: ClaudePreparedResult, + ): Promise { + try { + await this.finishTurnWithTerminal(context, result.toolStatus, result.terminal, result.reason); + } catch (error) { + throw new ClaudeTerminalWriteError(error, result.terminal.kind); + } + return result.terminal; + } + async #handleAssistantMessage( context: AgentDriverContext, message: Extract, runId: RunId, ): Promise { + await this.#retractWireItems(context, message.supersedes ?? []); + const outcome = claudeAssistantOutcome(message); const messageId = this.#state.assistantMessageId( runId, this.#state.resolveAssistantMessageNativeId( this.#state.streamScopeKey(runId, message), - this.#state.readNativeMessageId(message), + readString(readRecord(message, "message"), "id"), + message.uuid, ), ); const content = Array.isArray(message.message.content) ? message.message.content : []; const authoritativeText: string[] = []; + const toolCallIds: string[] = []; for (const [index, block] of content.entries()) { if (!isRecord(block)) { @@ -161,7 +326,8 @@ export class ClaudeAgentSdkMessageTranslator { } if (isToolUseBlock(block)) { - const toolCallId = toToolCallId(block, messageId, index); + const toolCallId = this.#options.publicToolCallId(toToolCallId(block, messageId, index)); + toolCallIds.push(toolCallId); if (!this.#events.hasToolStarted(toolCallId)) { // Claude content blocks are protocol-ordered; keep tool start/args/end in wire order. await this.#events.ensureToolStarted({ @@ -178,13 +344,45 @@ export class ClaudeAgentSdkMessageTranslator { } } + this.#state.bindWireAssistantMessage(message.uuid, messageId); + this.#state.bindWireToolCalls(message.uuid, toolCallIds); + if (authoritativeText.length > 0) { const text = authoritativeText.join(""); if (await this.#events.pushMessageSnapshot(context, messageId, text)) { - this.#state.markAuthoritative(messageId, text); + if (outcome.status === "completed") { + this.#state.markAuthoritative(messageId, text); + } } } + if (outcome.status === "cancelled") { + const thoughtId = this.#state.thoughtIdForMessage(messageId); + if (thoughtId !== undefined) { + await this.#events.settleThought(context, thoughtId, "cancelled"); + this.#state.deleteThoughtId(messageId); + } + await this.#events.ensureMessageStarted(context, messageId); + await this.#events.settleMessage(context, messageId, { status: "cancelled" }); + this.#state.completeAssistantMessage(runId, messageId, false); + return; + } + + if (outcome.status === "failed") { + await this.#endThought(context, messageId); + await this.#events.ensureMessageStarted(context, messageId); + await this.#events.settleMessage(context, messageId, { + error: { + code: `claude.${outcome.code}`, + message: outcome.message, + retryable: outcome.retryable, + }, + status: "failed", + }); + this.#state.completeAssistantMessage(runId, messageId, false); + return; + } + await this.#endAssistantMessage(context, runId, messageId); } @@ -301,10 +499,8 @@ export class ClaudeAgentSdkMessageTranslator { return; } - const toolCallId = toToolCallId( - block, - messageId, - index ?? this.#state.toolCallCount(messageId), + const toolCallId = this.#options.publicToolCallId( + toToolCallId(block, messageId, index ?? this.#state.toolCallCount(messageId)), ); await this.#events.ensureToolStarted({ context, @@ -318,13 +514,8 @@ export class ClaudeAgentSdkMessageTranslator { } const { input } = block; - if (input) { - await this.#events.pushToolArguments({ - context, - delta: stringifyForDisplay(input), - reason: "driver.claude.tool.args", - toolCallId, - }); + if (input && (!isRecord(input) || Object.keys(input).length > 0)) { + await this.#events.pushToolSnapshot(context, toolCallId, stringifyForDisplay(input)); } } @@ -395,18 +586,24 @@ export class ClaudeAgentSdkMessageTranslator { ): Promise { const content = isRecord(message.message) ? message.message.content : null; const blocks = Array.isArray(content) ? content : []; + const structuredOutput = jsonValueSchema.safeParse(message.tool_use_result); + const toolCallIds: string[] = []; for (const block of blocks) { if (!isRecord(block)) { continue; } - const toolCallId = readString(block, "tool_use_id"); + const nativeToolCallId = readString(block, "tool_use_id"); const resultText = toToolResultText(block); - if (!toolCallId || !resultText) { + if (!nativeToolCallId || resultText === null) { continue; } + const toolCallId = this.#options.publicToolCallId(nativeToolCallId); + toolCallIds.push(toolCallId); + + const outcome = claudeToolOutcome(message, block); // Tool results are emitted in transcript order so the live state reducer can attach them deterministically. await this.#events.pushToolResult({ @@ -417,10 +614,19 @@ export class ClaudeAgentSdkMessageTranslator { this.#state.activeAssistantMessageId(runId) ?? this.#state.lastCompletedAssistantMessageId(runId) ?? this.#state.assistantMessageId(runId, null), - status: block["is_error"] === true ? "failed" : "completed", + ...(outcome.nonExecutionKind === undefined + ? {} + : { nonExecutionKind: outcome.nonExecutionKind }), + status: outcome.status, + ...(structuredOutput.success ? { structuredOutput: structuredOutput.data } : {}), toolCallId, + ...(outcome.userFeedback === undefined ? {} : { userFeedback: outcome.userFeedback }), }); } + + if (message.uuid !== undefined) { + this.#state.bindWireToolCalls(message.uuid, toolCallIds); + } } async #endAssistantMessage( @@ -428,57 +634,359 @@ export class ClaudeAgentSdkMessageTranslator { runId: RunId, messageId: MessageId, ): Promise { - const ended = await this.#events.endMessage(context, messageId); + const ended = await this.#events.settleMessage(context, messageId, { status: "completed" }); this.#state.completeAssistantMessage(runId, messageId, ended); } async #handleSystemMessage( context: AgentDriverContext, message: Extract, + runId: RunId, ): Promise { - if (message.subtype === "init") { - await this.#options.recordNativeSessionId(context, message.session_id); - await this.#events.pushSessionInfoUpdated(context); - context.logger.info("driver.claude.session.initialized", { - mcpServerCount: message.mcp_servers.length, - model: message.model, - nativeSessionIdPresent: true, - toolCount: message.tools.length, - }); + switch (message.subtype) { + case "init": { + await this.#events.pushSessionInfoUpdated(context); + context.logger.info("driver.claude.session.initialized", { + mcpServerCount: message.mcp_servers.length, + model: message.model, + nativeSessionIdPresent: true, + toolCount: message.tools.length, + }); + return; + } + case "files_persisted": { + await this.#handleFilesPersisted(context, message); + return; + } + case "informational": { + const nativeToolCallId = message.tool_use_id; + await this.#pushStandaloneMessage(context, runId, message.uuid, message.content, { + level: message.level, + ...(message.prevent_continuation === undefined + ? {} + : { preventContinuation: message.prevent_continuation }), + subtype: message.subtype, + ...(nativeToolCallId === undefined + ? {} + : { toolCallId: this.#options.publicToolCallId(nativeToolCallId) }), + }); + return; + } + case "local_command_output": { + await this.#pushStandaloneMessage(context, runId, message.uuid, message.content, { + subtype: message.subtype, + }); + return; + } + case "mirror_error": { + await this.#events.pushRawDiagnostic( + context, + "driver.claude.mirror_error", + { + errorBytes: Buffer.byteLength(message.error, "utf8"), + kind: "claude.mirror_error", + }, + { message: "Claude transcript mirror write failed.", severity: "error" }, + ); + return; + } + case "model_refusal_fallback": { + await this.#retractWireItems(context, message.retracted_message_uuids ?? []); + await this.#events.pushDiagnostic(context, message); + return; + } + case "permission_denied": { + const denial = claudePermissionDenialAdvisory(message); + if (denial !== null) { + const toolCallId = this.#options.publicToolCallId(denial.toolCallId); + this.#permissionDenialAdvisories.set(`${runId}:${toolCallId}`, { + ...denial, + toolCallId, + }); + } + return; + } + case "api_retry": + case "commands_changed": + case "compact_boundary": + case "control_request_progress": + case "elicitation_complete": + case "hook_progress": + case "hook_response": + case "hook_started": + case "memory_recall": + case "model_refusal_no_fallback": + case "notification": + case "plugin_install": + case "session_state_changed": + case "status": + case "task_progress": + case "task_started": + case "task_updated": + case "thinking_tokens": + case "worker_shutting_down": { + await this.#events.pushDiagnostic(context, message); + return; + } + case "task_notification": { + await this.#handleTaskNotification(context, message, runId); + return; + } + case "background_tasks_changed": { + await this.#handleBackgroundTasksChanged(context, message); + return; + } + default: { + const unexpected = exhaustSdkMessage(message); + await this.#events.pushRawDiagnostic( + context, + "driver.claude.system.unknown", + isRecord(unexpected) ? unexpected : { value: String(unexpected) }, + ); + } + } + } + + async #handleBackgroundTasksChanged( + context: AgentDriverContext, + message: Extract, + ): Promise { + const { diagnostic, snapshot } = projectClaudeBackgroundTasksSnapshot(message); + await this.#options.push(context, "driver.claude.tasks.replaced", [ + ...(snapshot === undefined ? [] : [snapshot]), + ...(diagnostic === undefined ? [] : [diagnostic]), + ]); + } + + async #handleTaskNotification( + context: AgentDriverContext, + message: Extract, + runId: RunId, + ): Promise { + await this.#events.pushDiagnostic(context, message); + + if ( + message.status !== "completed" || + message.tool_use_id === undefined || + !message.resource_links?.length + ) { return; } - if (message.subtype === "files_persisted") { - await this.#handleFilesPersisted(context, message); + const structuredOutput = jsonValueSchema.safeParse({ resourceLinks: message.resource_links }); + if (!structuredOutput.success) { + return; } + + const toolCallId = this.#options.publicToolCallId(message.tool_use_id); + const messageId = + this.#events.toolParentMessageId(toolCallId) ?? + this.#state.activeAssistantMessageId(runId) ?? + this.#state.lastCompletedAssistantMessageId(runId) ?? + null; + await this.#events.ensureToolStarted({ + context, + ...(messageId === null ? {} : { parentMessageId: messageId }), + toolCallId, + toolCallName: "MCP tool", + }); + await this.#events.pushToolResult({ + authoritative: true, + context, + ...(messageId === null ? {} : { messageId }), + status: "completed", + structuredOutput: structuredOutput.data, + toolCallId, + }); } - async #handleFilesPersisted( + async #retractWireItems( context: AgentDriverContext, - message: Extract, + wireUuids: readonly string[], ): Promise { - await this.#options.push( + for (const wireUuid of wireUuids) { + const { messageId, toolCallIds } = this.#state.wireItems(wireUuid); + + if (messageId !== null) { + const thoughtId = this.#state.thoughtIdForMessage(messageId); + if (thoughtId !== undefined) { + await this.#events.settleThought(context, thoughtId, "cancelled"); + this.#state.deleteThoughtId(messageId); + } + await this.#events.retractMessage(context, messageId); + this.#state.commitWireMessageRetraction(wireUuid, messageId); + } + + for (const toolCallId of toolCallIds) { + await this.#events.retractTool(context, toolCallId); + } + this.#state.commitWireToolRetractions(wireUuid); + } + } + + async #pushStandaloneMessage( + context: AgentDriverContext, + runId: RunId, + nativeMessageId: string, + content: string, + metadata: JsonObject, + ): Promise { + if (content.length === 0) { + await this.#events.pushRawDiagnostic( + context, + `driver.claude.${String(metadata["subtype"] ?? "message")}`, + { content, ...metadata }, + { message: "Claude emitted an empty display message.", severity: "warn" }, + ); + return; + } + + const messageId = this.#state.auxiliaryMessageId(runId, nativeMessageId); + if (await this.#events.pushMessageSnapshot(context, messageId, content, metadata)) { + await this.#events.settleMessage(context, messageId, { status: "completed" }); + } + } + + async #handleConversationReset( + context: AgentDriverContext, + message: Extract, + preserveOpenTurn: boolean, + ): Promise { + if (!preserveOpenTurn) { + await this.#cancelOpenTurn(context); + } + await this.#options.replaceNativeSessionId( context, - "driver.claude.files.persisted", - toClaudeFilesPersistedEvents(message), + message.session_id, + message.new_conversation_id, ); + if (!preserveOpenTurn) { + this.resetTurnMessageState(); + } + await this.#events.pushSessionInfoUpdated(context, true); + } + + async #handleFilesPersisted( + context: AgentDriverContext, + message: Extract, + ): Promise { + const events = toClaudeFilesPersistedEvents(message); + for (const event of events) { + assertClaudeDurableEventFits( + event, + "claude.files_persisted_too_large", + "file persistence event", + ); + } + await this.#options.push(context, "driver.claude.files.persisted", events); } - async #handleResultMessage( + async #prepareResultMessage( context: AgentDriverContext, message: Extract, runId: RunId, - ): Promise { - await this.finishTurn(context, message.subtype === "success" ? "completed" : "failed"); + ): Promise { + const successful = isClaudeResultSuccessful(message); + const cancelled = isClaudeResultCancelled(message); + + for (const denial of claudePermissionDenials(message)) { + const toolCallId = this.#options.publicToolCallId(denial.toolCallId); + const advisory = this.#permissionDenialAdvisories.get(`${runId}:${toolCallId}`); + const messageId = + this.#events.toolParentMessageId(toolCallId) ?? + this.#state.activeAssistantMessageId(runId) ?? + this.#state.lastCompletedAssistantMessageId(runId) ?? + null; + await this.#events.ensureToolStarted({ + context, + ...(messageId === null ? {} : { parentMessageId: messageId }), + toolCallId, + toolCallName: denial.name, + }); + await this.#events.pushToolResult({ + ...(advisory?.agentId === undefined ? {} : { agentId: advisory.agentId }), + authoritative: true, + content: advisory?.message ?? denial.message, + context, + ...(advisory?.decisionReason === undefined + ? {} + : { decisionReason: advisory.decisionReason }), + ...(advisory?.decisionReasonType === undefined + ? {} + : { decisionReasonType: advisory.decisionReasonType }), + ...(messageId === null ? {} : { messageId }), + rawInput: stringifyForDisplay(denial.input), + status: "failed", + toolCallId, + toolCallName: denial.name, + }); + this.#permissionDenialAdvisories.delete(`${runId}:${toolCallId}`); + } + await this.#events.pushUsage( context, - isRecord(message.usage) ? message.usage : null, + aggregateClaudeModelUsage(message.modelUsage), message.total_cost_usd, ); + const prepareResult = ( + toolStatus: "cancelled" | "completed" | "failed", + terminal: ClaudeTerminalOutcome, + reason: string, + ): ClaudePreparedResult => ({ reason, terminal, toolStatus }); + + if (cancelled) { + return prepareResult( + "cancelled", + this.#events.runCancelled(runId, message.terminal_reason ?? "provider.aborted"), + "driver.claude.turn.cancelled", + ); + } - if (message.subtype === "success") { + if (message.subtype === "success" && successful) { const resultText = isRecord(message) ? readString(message, "result") : null; - if (resultText !== null && resultText.length > 0 && !this.#state.hasAssistantText(runId)) { + const structuredOutput = + message.structured_output === undefined + ? undefined + : jsonValueSchema.safeParse(message.structured_output); + + if (structuredOutput !== undefined && !structuredOutput.success) { + return prepareResult( + "failed", + this.#events.runError( + runId, + "claude.invalid_structured_output", + "Claude Agent SDK returned a non-JSON structured output.", + false, + ), + "driver.claude.turn.failed", + ); + } + + const completedTerminal = this.#events.runFinished(runId, null, structuredOutput?.data); + const structuredOutputTooLarge = + structuredOutput?.success === true && + Buffer.byteLength(JSON.stringify(completedTerminal), "utf8") > + MAX_CLAUDE_STRUCTURED_TERMINAL_BYTES; + + if (structuredOutputTooLarge) { + return prepareResult( + "failed", + this.#events.runError( + runId, + "claude.structured_output_too_large", + "Claude Agent SDK structured output exceeds the runtime terminal event limit.", + false, + ), + "driver.claude.turn.failed", + ); + } + + if ( + structuredOutput === undefined && + resultText !== null && + resultText.length > 0 && + !this.#state.hasAssistantText(runId) + ) { const messageId = this.#state.assistantMessageId(runId, null); if (await this.#events.pushMessageSnapshot(context, messageId, resultText)) { this.#state.markAuthoritative(messageId, resultText); @@ -490,25 +998,29 @@ export class ClaudeAgentSdkMessageTranslator { // resume can omit every assistant frame, so the result is materialized // above only when no competing text candidate exists. const finalMessage = - resultText === null ? null : this.#state.resolveFinalAssistantSnapshot(runId, resultText); - - try { - await this.#events.pushRunFinished(context, runId, finalMessage); - } catch (error) { - throw new ClaudeTerminalWriteError(error); - } - return; + structuredOutput !== undefined || resultText === null || resultText.trim().length === 0 + ? null + : this.#state.resolveFinalAssistantSnapshot(runId, resultText); + + return prepareResult( + "completed", + this.#events.runFinished(runId, finalMessage, structuredOutput?.data), + "driver.claude.turn.completed", + ); } - try { - await this.#events.pushRunError( - context, + return prepareResult( + "failed", + this.#events.runError( runId, - `claude.${message.subtype}`, - message.errors.join("\n") || "Claude Agent SDK turn failed.", - ); - } catch (error) { - throw new ClaudeTerminalWriteError(error); - } + message.subtype === "success" ? "claude.api_error" : `claude.${message.subtype}`, + message.subtype === "success" + ? message.result || "Claude Agent SDK API request failed." + : message.errors.join("\n") || "Claude Agent SDK turn failed.", + isClaudeResultRetryable(message), + claudeResultErrorDetails(message), + ), + "driver.claude.turn.failed", + ); } } diff --git a/src/runtimes/claude/agent-sdk-outcomes.ts b/src/runtimes/claude/agent-sdk-outcomes.ts new file mode 100644 index 0000000..deda4da --- /dev/null +++ b/src/runtimes/claude/agent-sdk-outcomes.ts @@ -0,0 +1,186 @@ +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; + +import { toRuntimePublicId } from "../runtime-public-id"; +import { isRecord, readString } from "./agent-sdk-json"; +import type { JsonObject } from "./agent-sdk-json"; + +const CANCELLED_TOOL_OUTCOMES = new Set(["cancelled", "interrupted"]); +const NON_EXECUTION_KINDS = new Set([ + "automode-blocked", + "automode-parsing-error", + "automode-unavailable", + "cancelled", + "interrupted", + "permission-rule", + "user-rejected", +]); +const RETRYABLE_ASSISTANT_ERRORS = new Set(["overloaded", "rate_limit", "server_error"]); + +export type ClaudeAssistantOutcome = + | { readonly status: "cancelled" } + | { readonly status: "completed" } + | { + readonly code: string; + readonly message: string; + readonly retryable: boolean; + readonly status: "failed"; + }; + +export interface ClaudePermissionDenial { + readonly input: JsonObject; + readonly message: string; + readonly name: string; + readonly toolCallId: string; +} + +export interface ClaudePermissionDenialAdvisory { + readonly agentId?: string; + readonly decisionReason?: string; + readonly decisionReasonType?: string; + readonly message: string; + readonly toolCallId: string; +} + +export interface ClaudeToolOutcome { + readonly nonExecutionKind?: string; + readonly status: "cancelled" | "completed" | "failed"; + readonly userFeedback?: string; +} + +export function claudeAssistantOutcome( + message: Extract, +): ClaudeAssistantOutcome { + if (message.aborted === true) { + return { status: "cancelled" }; + } + + if (message.error === undefined) { + return { status: "completed" }; + } + + return { + code: message.error, + message: `Assistant message failed: ${message.error}.`, + retryable: RETRYABLE_ASSISTANT_ERRORS.has(message.error), + status: "failed", + }; +} + +export function claudePermissionDenials( + message: Extract, +): ClaudePermissionDenial[] { + const denials = Array.isArray(message.permission_denials) ? message.permission_denials : []; + + return denials.flatMap((entry) => { + if (!isRecord(entry)) { + return []; + } + + const name = readString(entry, "tool_name"); + const toolCallId = readString(entry, "tool_use_id"); + const input = entry["tool_input"]; + + if (name === null || toolCallId === null || !isRecord(input)) { + return []; + } + + return [ + { + input, + message: `Permission denied for ${name}; the tool was not executed.`, + name, + toolCallId, + }, + ]; + }); +} + +export function claudePermissionDenialAdvisory( + message: Extract, +): ClaudePermissionDenialAdvisory | null { + if (message.subtype !== "permission_denied") { + return null; + } + + return { + ...(message.agent_id === undefined + ? {} + : { agentId: toRuntimePublicId(message.agent_id, "claude-agent") }), + ...(message.decision_reason === undefined ? {} : { decisionReason: message.decision_reason }), + ...(message.decision_reason_type === undefined + ? {} + : { decisionReasonType: message.decision_reason_type }), + message: message.message, + toolCallId: message.tool_use_id, + }; +} + +export function claudeResultErrorDetails( + message: Extract, +): Record | undefined { + const apiErrorStatus = message.subtype === "success" ? message.api_error_status : undefined; + + if (apiErrorStatus === undefined && message.terminal_reason === undefined) { + return undefined; + } + + return { + ...(apiErrorStatus === undefined ? {} : { apiErrorStatus }), + ...(message.terminal_reason === undefined ? {} : { terminalReason: message.terminal_reason }), + }; +} + +export function isClaudeResultRetryable(message: Extract): boolean { + const apiErrorStatus = message.subtype === "success" ? message.api_error_status : undefined; + + if (apiErrorStatus !== null && apiErrorStatus !== undefined) { + return ( + apiErrorStatus === 408 || + apiErrorStatus === 409 || + apiErrorStatus === 429 || + apiErrorStatus >= 500 + ); + } + + return ( + message.terminal_reason === "api_error" || + message.terminal_reason === "model_error" || + message.terminal_reason === "blocking_limit" || + message.terminal_reason === "rapid_refill_breaker" + ); +} + +export function isClaudeResultSuccessful( + message: Extract, +): boolean { + return message.subtype === "success" && !message.is_error; +} + +export function claudeToolOutcome( + message: Extract, + block: JsonObject, +): ClaudeToolOutcome { + const toolCallId = readString(block, "tool_use_id"); + const rawMetadata = (message as unknown as JsonObject)["tool_result_meta"]; + const metadata: unknown[] = Array.isArray(rawMetadata) ? rawMetadata : []; + const match = metadata.find((entry) => isRecord(entry) && readString(entry, "id") === toolCallId); + const metadataEntry = isRecord(match) ? match : null; + const nonExecutionKind = readString(metadataEntry, "non_execution_kind"); + + if (nonExecutionKind !== null && NON_EXECUTION_KINDS.has(nonExecutionKind)) { + const userFeedback = readString(metadataEntry, "user_feedback"); + return { + nonExecutionKind, + status: CANCELLED_TOOL_OUTCOMES.has(nonExecutionKind) ? "cancelled" : "failed", + ...(userFeedback === null ? {} : { userFeedback }), + }; + } + + return { status: block["is_error"] === true ? "failed" : "completed" }; +} + +export function isClaudeResultCancelled(message: Extract): boolean { + return ( + message.terminal_reason === "aborted_streaming" || message.terminal_reason === "aborted_tools" + ); +} diff --git a/src/runtimes/claude/agent-sdk-prewarm.ts b/src/runtimes/claude/agent-sdk-prewarm.ts index 1e4801e..09c9258 100644 --- a/src/runtimes/claude/agent-sdk-prewarm.ts +++ b/src/runtimes/claude/agent-sdk-prewarm.ts @@ -5,7 +5,7 @@ import { raceWithAbort } from "../../utils/async"; import type { AgentDriverContext } from "../../core/agent-driver-backend"; import { readProcessEnvString, toErrorMessage } from "./agent-sdk-json"; import type { createClaudeQueryOptions } from "./agent-sdk-query-options"; -import { drainClaudeTasks } from "./agent-sdk-tasks"; +import { settleClaudeTasks } from "./agent-sdk-tasks"; const CLAUDE_PREWARM_ENV = "AGENT_DRIVER_CLAUDE_PREWARM"; @@ -14,13 +14,18 @@ interface ClaudePrewarmState { readonly detach: () => void; readonly permissionTasks: Set>; readonly processTasks: Set>; + cleanupTask: Promise | null; + failure: { readonly error: unknown } | null; + permanentFailure: { readonly error: unknown } | null; query: WarmQuery | null; + task: Promise; } export interface ClaudeAgentSdkPrewarmOptions { readonly createQueryOptions: typeof createClaudeQueryOptions; readonly getNativeSessionId: () => string | null; readonly payload: DriverStartInput; + readonly publicToolCallId: (nativeToolCallId: string) => string; readonly startup: (input: { options: Awaited>; }) => Promise; @@ -41,16 +46,16 @@ export class ClaudeAgentSdkPrewarm { readonly #createQueryOptions: typeof createClaudeQueryOptions; readonly #getNativeSessionId: () => string | null; readonly #payload: DriverStartInput; + readonly #publicToolCallId: (nativeToolCallId: string) => string; readonly #startup: ClaudeAgentSdkPrewarmOptions["startup"]; - #failure: { readonly error: unknown } | null = null; #state: ClaudePrewarmState | null = null; #stopped = false; - #task: Promise | null = null; constructor(options: ClaudeAgentSdkPrewarmOptions) { this.#createQueryOptions = options.createQueryOptions; this.#getNativeSessionId = options.getNativeSessionId; this.#payload = options.payload; + this.#publicToolCallId = options.publicToolCallId; this.#startup = options.startup; } @@ -59,29 +64,38 @@ export class ClaudeAgentSdkPrewarm { return; } - const task = this.#run(context, signal); - this.#task = task; - const release = () => { - if (this.#task === task) { - this.#task = null; - } + const startedAtMs = Date.now(); + const abortController = new AbortController(); + const onAbort = () => abortController.abort(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) { + onAbort(); + } + const state: ClaudePrewarmState = { + abortController, + cleanupTask: null, + detach: () => signal.removeEventListener("abort", onAbort), + failure: null, + permissionTasks: new Set(), + permanentFailure: null, + processTasks: new Set(), + query: null, + task: Promise.resolve(), }; - void task.then(release, (error) => { - this.#failure ??= { error }; - release(); - }); + this.#state = state; + state.task = Promise.resolve().then(() => this.#run(context, state, startedAtMs)); + void state.task.catch(() => {}); } take(): ClaudePrewarmTake { - if (this.#failure !== null) { - throw this.#failure.error; - } - const state = this.#state; - this.#state = null; - state?.detach(); + if (state !== null && state.failure !== null) { + throw state.failure.error; + } - if (state?.query) { + if (state !== null && state.query !== null) { + this.#state = null; + state.detach(); return { abortController: state.abortController, permissionTasks: state.permissionTasks, @@ -90,6 +104,7 @@ export class ClaudeAgentSdkPrewarm { }; } + // A superseded startup still owns every task it may create until cleanup succeeds. state?.abortController.abort("driver.claude.prewarm.superseded"); return { abortController: new AbortController(), @@ -102,77 +117,46 @@ export class ClaudeAgentSdkPrewarm { async stop(context: AgentDriverContext, reason: string, signal: AbortSignal): Promise { this.#stopped = true; const state = this.#state; - const task = this.#task; - state?.detach(); - state?.abortController.abort(reason); - const warmQuery = state?.query; - if (state !== null) { - state.query = null; + if (state === null) { + signal.throwIfAborted(); + return; } - try { - warmQuery?.close(); - } catch (error) { - context.logger.debug("driver.claude.prewarm.close_failed", { - message: toErrorMessage(error, "prewarm close failed"), - reason, - }); - } + state.detach(); + state.abortController.abort(reason); + this.#close(context, state, reason); - if (task !== null) { - await raceWithAbort(task, signal); - } - if (state !== null && state.processTasks.size > 0) { - const retryingFailedCleanup = this.#failure !== null; - await raceWithAbort(drainClaudeTasks(state.processTasks), signal); - if (retryingFailedCleanup) { - this.#failure = null; - } - } - signal.throwIfAborted(); - if (this.#failure !== null) { - throw this.#failure.error; - } - if (this.#state === state) { - this.#state = null; - } + const settlement = + state.failure !== null + ? this.#cleanup(state) + : state.task.then(() => (this.#state === state ? this.#cleanup(state) : Promise.resolve())); + await raceWithAbort(settlement, signal); } - async #run(context: AgentDriverContext, signal: AbortSignal): Promise { - const startedAtMs = Date.now(); - const abortController = new AbortController(); - const onAbort = () => abortController.abort(signal.reason); - signal.addEventListener("abort", onAbort, { once: true }); - if (signal.aborted) { - onAbort(); - } - const state: ClaudePrewarmState = { - abortController, - detach: () => signal.removeEventListener("abort", onAbort), - permissionTasks: new Set(), - processTasks: new Set(), - query: null, - }; - this.#state = state; - + async #run( + context: AgentDriverContext, + state: ClaudePrewarmState, + startedAtMs: number, + ): Promise { try { const options = await this.#createQueryOptions({ - abortController, + abortController: state.abortController, context, nativeSessionId: this.#getNativeSessionId(), payload: this.#payload, permissionTasks: state.permissionTasks, processTasks: state.processTasks, + publicToolCallId: this.#publicToolCallId, }); - if (this.#stopped || abortController.signal.aborted || this.#state !== state) { + if (this.#stopped || state.abortController.signal.aborted || this.#state !== state) { return; } const warmQuery = await this.#startup({ options }); - if (this.#stopped || abortController.signal.aborted || this.#state !== state) { - warmQuery.close(); + if (this.#stopped || state.abortController.signal.aborted || this.#state !== state) { + this.#closeQuery(context, warmQuery, "driver.claude.prewarm.superseded"); return; } @@ -181,22 +165,69 @@ export class ClaudeAgentSdkPrewarm { prewarmMs: Date.now() - startedAtMs, }); } catch (error) { - if (!abortController.signal.aborted && !this.#stopped) { + if (!state.abortController.signal.aborted && !this.#stopped) { context.logger.debug("driver.claude.prewarm.failed", { message: toErrorMessage(error, "Claude prewarm failed."), }); } } finally { try { - if (state.query === null || abortController.signal.aborted || this.#stopped) { - await drainClaudeTasks(state.processTasks); + if (state.query === null || state.abortController.signal.aborted || this.#stopped) { + this.#close(context, state, "driver.claude.prewarm.finished"); + await this.#cleanup(state); } } finally { state.detach(); - if (this.#state === state && state.query === null && state.processTasks.size === 0) { - this.#state = null; - } } } } + + #cleanup(state: ClaudePrewarmState): Promise { + if (state.cleanupTask !== null) { + return state.cleanupTask; + } + + const task = (async () => { + const result = await settleClaudeTasks(state.processTasks); + if (result.status === "failed") { + state.failure = { error: result.firstFailure }; + state.permanentFailure ??= result.firstPermanentFailure; + throw result.firstFailure; + } + + state.failure = null; + if (state.permanentFailure !== null) { + state.failure = state.permanentFailure; + throw state.permanentFailure.error; + } + if (this.#state === state) { + this.#state = null; + } + })().finally(() => { + if (state.cleanupTask === task) { + state.cleanupTask = null; + } + }); + state.cleanupTask = task; + return task; + } + + #close(context: AgentDriverContext, state: ClaudePrewarmState, reason: string): void { + const query = state.query; + state.query = null; + if (query !== null) { + this.#closeQuery(context, query, reason); + } + } + + #closeQuery(context: AgentDriverContext, query: WarmQuery, reason: string): void { + try { + query.close(); + } catch (error) { + context.logger.debug("driver.claude.prewarm.close_failed", { + message: toErrorMessage(error, "prewarm close failed"), + reason, + }); + } + } } diff --git a/src/runtimes/claude/agent-sdk-query-options.ts b/src/runtimes/claude/agent-sdk-query-options.ts index 1610335..e2be11f 100644 --- a/src/runtimes/claude/agent-sdk-query-options.ts +++ b/src/runtimes/claude/agent-sdk-query-options.ts @@ -16,6 +16,7 @@ import type { AgentDriverContext } from "../../core/agent-driver-backend"; import { buildRuntimeChildProcessEnv } from "../child-process-env"; import { toMcpServerKey } from "../mcp/server-key"; import { mergeProviderOptions } from "../provider-options"; +import { toRuntimePublicId } from "../runtime-public-id"; import { buildNativeRuntimeSystemPrompt } from "../skill-bootstrap"; import { readProcessEnvString, stringifyForDisplay } from "./agent-sdk-json"; import { spawnClaudeCodeProcess } from "./agent-sdk-process"; @@ -53,6 +54,7 @@ const CLAUDE_PROVIDER_OPTION_KEYS = new Set([ function createCanUseTool( context: AgentDriverContext, permissionTasks: Set>, + publicToolCallId: (nativeToolCallId: string) => string, ): CanUseTool { return (toolName, input, options): Promise => { const task = (async () => { @@ -67,10 +69,21 @@ function createCanUseTool( const decision = await context.ports.permission.request( { + ...(options.agentID === undefined + ? {} + : { agentId: toRuntimePublicId(options.agentID, "claude-agent") }), + ...(options.blockedPath === undefined ? {} : { blockedPath: options.blockedPath }), + ...(options.decisionReason === undefined + ? {} + : { decisionReason: options.decisionReason }), + ...(options.description === undefined ? {} : { description: options.description }), + ...(options.matchedAskRule === undefined + ? {} + : { matchedAskRule: options.matchedAskRule }), rawInput: stringifyForDisplay(input), requestId: options.requestId, title: options.title ?? options.displayName ?? `Approve ${toolName}`, - toolCallId: options.toolUseID, + toolCallId: publicToolCallId(options.toolUseID), toolKind: toolName, }, options.signal, @@ -165,6 +178,7 @@ export async function createClaudeQueryOptions(input: { payload: DriverStartInput; permissionTasks?: Set>; processTasks?: Set>; + publicToolCallId?: (nativeToolCallId: string) => string; }): Promise { const claudeConfigDir = resolveClaudeConfigDir(input.payload); await mkdir(claudeConfigDir, { recursive: true }); @@ -174,7 +188,11 @@ export async function createClaudeQueryOptions(input: { const options: ClaudeQueryOptions = { abortController: input.abortController, additionalDirectories: input.payload.execution.session.additionalDirectories, - canUseTool: createCanUseTool(input.context, input.permissionTasks ?? new Set()), + canUseTool: createCanUseTool( + input.context, + input.permissionTasks ?? new Set(), + input.publicToolCallId ?? ((nativeToolCallId) => nativeToolCallId), + ), cwd: input.payload.execution.session.cwd, env: toClaudeEnv(input.payload, claudeConfigDir), includePartialMessages: true, diff --git a/src/runtimes/claude/agent-sdk-resume.ts b/src/runtimes/claude/agent-sdk-resume.ts index 78e71da..10c2615 100644 --- a/src/runtimes/claude/agent-sdk-resume.ts +++ b/src/runtimes/claude/agent-sdk-resume.ts @@ -1,5 +1,19 @@ import type { DriverStartInput } from "../../protocol/start"; +const MAX_CLAUDE_NATIVE_SESSION_ID_BYTES = 256; + +export function requireClaudeNativeSessionId(sessionId: string): string { + const bytes = Buffer.byteLength(sessionId, "utf8"); + + if (sessionId.trim().length === 0 || bytes > MAX_CLAUDE_NATIVE_SESSION_ID_BYTES) { + throw new RangeError( + `Claude native session ID must contain 1-${String(MAX_CLAUDE_NATIVE_SESSION_ID_BYTES)} UTF-8 bytes (received ${String(bytes)}).`, + ); + } + + return sessionId; +} + export function readClaudeNativeResumeSessionId(payload: DriverStartInput): string | null { const { nativeResumeRef } = payload.execution.session; @@ -14,5 +28,5 @@ export function readClaudeNativeResumeSessionId(payload: DriverStartInput): stri throw new Error("Claude runtime received an incompatible native resume ref."); } - return nativeResumeRef.value; + return requireClaudeNativeSessionId(nativeResumeRef.value); } diff --git a/src/runtimes/claude/agent-sdk-task-events.ts b/src/runtimes/claude/agent-sdk-task-events.ts new file mode 100644 index 0000000..f01981e --- /dev/null +++ b/src/runtimes/claude/agent-sdk-task-events.ts @@ -0,0 +1,132 @@ +import type { SDKBackgroundTasksChangedMessage } from "@anthropic-ai/claude-agent-sdk"; + +import type { DriverEventInput } from "../../protocol/events"; +import { toRuntimePublicId } from "../runtime-public-id"; +import { + assertClaudeDurableEventFits, + ClaudeDurableEventTooLargeError, +} from "./agent-sdk-event-writer"; + +const MAX_CLAUDE_BACKGROUND_TASK_ENTRIES = 1_024; +const MAX_CLAUDE_TASK_TEXT_LENGTH = 4_096; +const MAX_CLAUDE_VISIBLE_BACKGROUND_TASKS = 256; + +export interface ClaudeBackgroundTasksProjection { + readonly diagnostic?: DriverEventInput; + readonly snapshot?: DriverEventInput; +} + +function taskSnapshotDiagnostic(code: string, taskCount: number): DriverEventInput { + const event: DriverEventInput = { + delivery: "best_effort", + kind: "diagnostic.reported", + payload: { + code, + details: { taskCount }, + message: "Claude background task snapshot exceeded the supported task bound.", + severity: "warn", + source: "claude", + }, + visibility: "owner_debug", + }; + assertClaudeDurableEventFits(event, code, "background task snapshot diagnostic"); + return event; +} + +function rejectedTaskProjection(code: string, taskCount: number): ClaudeBackgroundTasksProjection { + return { + diagnostic: taskSnapshotDiagnostic(code, taskCount), + }; +} + +function publicTaskId(nativeTaskId: string): string | null { + if (nativeTaskId.length === 0) { + return null; + } + return toRuntimePublicId(nativeTaskId, "claude-task"); +} + +function boundedTaskText(value: string): string | undefined { + const text = value.slice(0, MAX_CLAUDE_TASK_TEXT_LENGTH); + const finalCodeUnit = text.charCodeAt(text.length - 1); + const bounded = finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff ? text.slice(0, -1) : text; + return bounded.length === 0 ? undefined : bounded; +} + +export function claudeBackgroundTasksClosedEvent(): DriverEventInput { + return { + delivery: "lossless", + kind: "agent.tasks.replaced", + payload: { tasks: [] }, + visibility: "participant", + }; +} + +export function projectClaudeBackgroundTasksSnapshot( + message: SDKBackgroundTasksChangedMessage, +): ClaudeBackgroundTasksProjection { + if (message.tasks.length > MAX_CLAUDE_BACKGROUND_TASK_ENTRIES) { + return rejectedTaskProjection( + "claude.background_tasks_snapshot_too_large", + message.tasks.length, + ); + } + + const tasks = new Map< + string, + { readonly taskId: string; readonly taskType?: string; readonly title?: string } + >(); + for (const task of message.tasks) { + if (task.ambient === true) { + continue; + } + + const taskId = publicTaskId(task.task_id); + if (taskId === null) { + continue; + } + + const taskType = boundedTaskText(task.task_type); + const title = boundedTaskText(task.description); + tasks.set(taskId, { + taskId, + ...(taskType === undefined ? {} : { taskType }), + ...(title === undefined ? {} : { title }), + }); + if (tasks.size > MAX_CLAUDE_VISIBLE_BACKGROUND_TASKS) { + return rejectedTaskProjection("claude.visible_background_tasks_too_many", tasks.size); + } + } + + const event: DriverEventInput = { + delivery: "lossless", + kind: "agent.tasks.replaced", + payload: { tasks: [...tasks.values()] }, + visibility: "participant", + }; + + try { + assertClaudeDurableEventFits(event, "claude.tasks_snapshot_too_large", "task snapshot"); + } catch (error) { + if (!(error instanceof ClaudeDurableEventTooLargeError)) { + throw error; + } + const membership: DriverEventInput = { + ...event, + payload: { + tasks: [...tasks.values()].map(({ taskId }) => ({ taskId })), + }, + }; + assertClaudeDurableEventFits( + membership, + "claude.tasks_membership_snapshot_too_large", + "task membership snapshot", + ); + return { + diagnostic: taskSnapshotDiagnostic("claude.tasks_snapshot_too_large", message.tasks.length), + snapshot: membership, + }; + } + + return { snapshot: event }; +} diff --git a/src/runtimes/claude/agent-sdk-tasks.ts b/src/runtimes/claude/agent-sdk-tasks.ts index 35ab0c4..59b193b 100644 --- a/src/runtimes/claude/agent-sdk-tasks.ts +++ b/src/runtimes/claude/agent-sdk-tasks.ts @@ -1,6 +1,14 @@ const taskRetries = new WeakMap, () => Promise>(); const retryableTaskFailures = new WeakSet>(); +type ClaudeTaskDrainResult = + | { readonly status: "completed" } + | { + readonly firstFailure: unknown; + readonly firstPermanentFailure: { readonly error: unknown } | null; + readonly status: "failed"; + }; + export function registerClaudeTaskRetry(task: Promise, retry: () => Promise): void { taskRetries.set(task, retry); } @@ -8,23 +16,32 @@ export function registerClaudeTaskRetry(task: Promise, retry: () => Promis export async function drainClaudeTasks( ...taskSets: ReadonlyArray>> ): Promise { + const result = await settleClaudeTasks(...taskSets); + if (result.status === "failed") { + throw result.firstFailure; + } +} + +export async function settleClaudeTasks( + ...taskSets: ReadonlyArray>> +): Promise { const failedRetryableTasks = new Set>(); let failed = false; let firstFailure: unknown; + let firstPermanentFailure: { readonly error: unknown } | null = null; for (;;) { const tasks = taskSets.flatMap((taskSet) => [...taskSet] .filter((task) => !failedRetryableTasks.has(task)) - .map((task) => ({ owner: taskSet, task })), + .map((task) => ({ owner: taskSet, retry: taskRetries.get(task), task })), ); if (tasks.length === 0) { break; } const results = await Promise.allSettled( - tasks.map(({ task }) => { - const retry = taskRetries.get(task); + tasks.map(({ retry, task }) => { if (retry !== undefined && retryableTaskFailures.has(task)) { return Promise.resolve().then(retry); } @@ -38,11 +55,12 @@ export async function drainClaudeTasks( taskRetries.delete(tracked.task); retryableTaskFailures.delete(tracked.task); } else { - if (taskRetries.has(tracked.task)) { + if (tracked.retry !== undefined) { retryableTaskFailures.add(tracked.task); failedRetryableTasks.add(tracked.task); } else { tracked.owner.delete(tracked.task); + firstPermanentFailure ??= { error: result.reason }; } if (!failed) { failed = true; @@ -53,6 +71,7 @@ export async function drainClaudeTasks( } if (failed) { - throw firstFailure; + return { firstFailure, firstPermanentFailure, status: "failed" }; } + return { status: "completed" }; } diff --git a/src/runtimes/claude/contract-adapter.ts b/src/runtimes/claude/contract-adapter.ts deleted file mode 100644 index 5e79b93..0000000 --- a/src/runtimes/claude/contract-adapter.ts +++ /dev/null @@ -1,153 +0,0 @@ -import type { PermissionResult, SDKMessage } from "@anthropic-ai/claude-agent-sdk"; - -import type { InteractionResolution, Run } from "../../contract"; -import { createDriverId } from "../../protocol/id"; -import { ContractProjection, type ContractProjectionOptions } from "../contract-projection"; -import { isRecord, readString } from "./agent-sdk-json"; -import { ClaudeContractPermissions, type ClaudePermissionOptions } from "./contract-permissions"; -import { ClaudeContractTranscript } from "./contract-transcript"; - -const DEFAULT_INTERACTION_TIMEOUT_MS = 5 * 60 * 1_000; -const DEFAULT_MAX_PENDING_PERMISSION_BYTES = 8 * 1_024 * 1_024; -const DEFAULT_MAX_TOOL_INPUT_BYTES = 1_024 * 1_024; - -export interface ClaudeContractAdapterOptions extends ContractProjectionOptions { - readonly createId?: (() => string) | undefined; - readonly interactionTimeoutMs?: number | undefined; - readonly maxPendingPermissionBytes?: number | undefined; - readonly maxToolInputBytes?: number | undefined; - readonly nativeSessionId?: string | undefined; -} - -export class ClaudeContractAdapter { - #disposed = false; - readonly #finishingRuns = new Set(); - #nativeSessionId: string | null; - readonly #permissions: ClaudeContractPermissions; - readonly #projection: ContractProjection; - readonly #transcript: ClaudeContractTranscript; - - constructor(options: ClaudeContractAdapterOptions) { - const createId = options.createId ?? createDriverId; - const interactionTimeoutMs = options.interactionTimeoutMs ?? DEFAULT_INTERACTION_TIMEOUT_MS; - const maxPendingPermissionBytes = - options.maxPendingPermissionBytes ?? DEFAULT_MAX_PENDING_PERMISSION_BYTES; - const maxToolInputBytes = options.maxToolInputBytes ?? DEFAULT_MAX_TOOL_INPUT_BYTES; - this.#nativeSessionId = options.nativeSessionId ?? null; - this.#projection = new ContractProjection(options); - this.#transcript = new ClaudeContractTranscript({ - createId, - maxToolInputBytes, - onRunReleased: (runId) => this.#releaseRun(runId), - projection: this.#projection, - }); - this.#permissions = new ClaudeContractPermissions({ - createId, - interactionTimeoutMs, - isRunFinishing: (runId) => this.#finishingRuns.has(runId), - maxPendingPermissionBytes, - projection: this.#projection, - transcript: this.#transcript, - }); - - if ( - [interactionTimeoutMs, maxPendingPermissionBytes, maxToolInputBytes].some( - (value) => !Number.isSafeInteger(value) || value < 1, - ) - ) { - throw new RangeError("Claude Agent SDK limits must be finite and positive."); - } - - if (this.#nativeSessionId !== null && this.#nativeSessionId.trim().length === 0) { - throw new Error("Claude Contract adapter requires a non-empty native session ID."); - } - } - - attachRun(run: Run): void { - this.#assertActive(); - this.#projection.attachRun(run); - } - - async handleMessage(message: SDKMessage, runId: string): Promise { - this.#assertActive(); - this.#assertNativeSession(message); - - if (this.#finishingRuns.has(runId) || this.#projection.run(runId)?.status !== "active") { - return false; - } - - if (message.type !== "result") { - return this.#transcript.handleMessage(message, runId); - } - - this.#finishingRuns.add(runId); - try { - return await this.#transcript.handleMessage(message, runId); - } catch (error) { - if (this.#projection.run(runId)?.status === "active") { - this.#finishingRuns.delete(runId); - } - throw error; - } - } - - async openPermission( - runId: string, - toolName: string, - input: Record, - options: ClaudePermissionOptions, - ): Promise { - this.#assertActive(); - return this.#permissions.openPermission(runId, toolName, input, options); - } - - async resolveInteraction( - interactionId: string, - resolution: InteractionResolution, - ): Promise { - this.#assertActive(); - return this.#permissions.resolveInteraction(interactionId, resolution); - } - - dispose(): void { - this.#disposed = true; - this.#finishingRuns.clear(); - this.#permissions.dispose(); - this.#transcript.dispose(); - this.#nativeSessionId = null; - this.#projection.dispose(); - } - - #assertNativeSession(message: SDKMessage): void { - const sessionId = isRecord(message) ? readString(message, "session_id") : null; - - if (sessionId === null) { - return; - } - - if (sessionId.trim().length === 0) { - throw new Error("Claude Agent SDK message has an empty native session ID."); - } - - if (this.#nativeSessionId === null) { - this.#nativeSessionId = sessionId; - return; - } - - if (sessionId !== this.#nativeSessionId) { - throw new Error("Claude Agent SDK message belongs to a different native session."); - } - } - - #releaseRun(runId: string): void { - this.#finishingRuns.delete(runId); - this.#transcript.releaseRun(runId); - this.#permissions.releaseRun(runId); - } - - #assertActive(): void { - if (this.#disposed) { - throw new Error("Claude Contract adapter is disposed."); - } - } -} diff --git a/src/runtimes/claude/contract-items.ts b/src/runtimes/claude/contract-items.ts deleted file mode 100644 index 2bbb930..0000000 --- a/src/runtimes/claude/contract-items.ts +++ /dev/null @@ -1,137 +0,0 @@ -import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; - -import type { ContentBlock, TokenUsage, ToolItem } from "../../contract"; -import { asJsonValue } from "../contract-adapter-meta"; -import { - isRecord, - readRecord, - readString, - sumTokenCounts, - toCostAmount, - toTokenCount, -} from "./agent-sdk-json"; - -export function toContentBlocks(value: unknown): ContentBlock[] { - const values = Array.isArray(value) ? value : [value]; - - return values.flatMap((entry) => { - if (typeof entry === "string") { - return entry.length === 0 ? [] : [{ text: entry, type: "text" }]; - } - - if (!isRecord(entry)) { - const json = asJsonValue(entry); - return json === undefined ? [] : [{ type: "json", value: json }]; - } - - if (entry["type"] === "text") { - const text = readString(entry, "text"); - return text === null ? [] : [{ text, type: "text" }]; - } - - const source = readRecord(entry, "source"); - - if (entry["type"] === "image" && source?.["type"] === "base64") { - const data = readString(source, "data"); - const mediaType = readString(source, "media_type"); - return data === null || mediaType === null ? [] : [{ data, mediaType, type: "inline_blob" }]; - } - - if (entry["type"] === "image" && source?.["type"] === "url") { - const uri = readString(source, "url"); - return uri !== null && URL.canParse(uri) ? [{ type: "resource_link", uri }] : []; - } - - const json = asJsonValue(entry); - return json === undefined ? [] : [{ type: "json", value: json }]; - }); -} - -export function toolCategory(name: string): ToolItem["category"] { - const normalized = name.toLowerCase(); - - if (normalized === "read") { - return "read"; - } - - if (["edit", "multiedit", "notebookedit", "write"].includes(normalized)) { - return "edit"; - } - - if (["glob", "grep"].includes(normalized)) { - return "search"; - } - - if (normalized === "bash") { - return "execute"; - } - - if (["webfetch", "websearch"].includes(normalized)) { - return "fetch"; - } - - if (["agent", "task", "sendmessage"].includes(normalized)) { - return "agent"; - } - - return "other"; -} - -export function toUsage(message: Extract): TokenUsage | undefined { - const raw = isRecord(message.usage) ? message.usage : null; - const cachedInput = toTokenCount(raw?.["cache_read_input_tokens"]); - const input = toTokenCount(raw?.["input_tokens"]); - const output = toTokenCount(raw?.["output_tokens"]); - const total = sumTokenCounts(input, output); - const cost = toCostAmount(message.total_cost_usd); - const usage = { - ...(cachedInput === null ? {} : { cachedInput }), - ...(cost === null ? {} : { cost: { amount: cost, currency: "USD" } }), - ...(input === null ? {} : { input }), - ...(output === null ? {} : { output }), - ...(total === null ? {} : { total }), - } satisfies TokenUsage; - - return Object.keys(usage).length === 0 ? undefined : usage; -} - -export function isLimit(message: Extract): boolean { - return ( - message.subtype === "error_max_turns" || - message.subtype === "error_max_budget_usd" || - message.subtype === "error_max_structured_output_retries" || - message.stop_reason === "max_tokens" || - message.terminal_reason === "max_turns" || - message.terminal_reason === "budget_exhausted" || - message.terminal_reason === "structured_output_retry_exhausted" - ); -} - -export function finishReason( - message: Extract, -): "limit" | "other" | "refusal" | "success" { - if (message.stop_reason === "refusal") { - return "refusal"; - } - - if (isLimit(message)) { - return "limit"; - } - - return message.terminal_reason === "background_requested" || - message.terminal_reason === "tool_deferred" || - message.terminal_reason === "tool_deferred_unavailable" - ? "other" - : "success"; -} - -export function isRetryable( - message: Exclude, { subtype: "success" }>, -): boolean { - return ( - message.terminal_reason === "api_error" || - message.terminal_reason === "model_error" || - message.terminal_reason === "blocking_limit" || - message.terminal_reason === "rapid_refill_breaker" - ); -} diff --git a/src/runtimes/claude/contract-permissions.ts b/src/runtimes/claude/contract-permissions.ts deleted file mode 100644 index 2b2f033..0000000 --- a/src/runtimes/claude/contract-permissions.ts +++ /dev/null @@ -1,526 +0,0 @@ -import { isDeepStrictEqual } from "node:util"; - -import type { CanUseTool, PermissionResult } from "@anthropic-ai/claude-agent-sdk"; - -import { - AuthorityOutcomeUnknownError, - itemSchema, - permissionInteractionSchema, -} from "../../contract"; -import type { - InteractionResolution, - PermissionInteraction, - PermissionOption, -} from "../../contract"; -import { createProviderMeta, ContractProjection, nonEmpty } from "../contract-projection"; -import { isRecord } from "./agent-sdk-json"; -import type { JsonObject } from "./agent-sdk-json"; -import { toolCategory } from "./contract-items"; -import type { ClaudeContractTranscript } from "./contract-transcript"; - -const { cause: providerCause, provenance } = createProviderMeta("anthropic"); - -export type ClaudePermissionOptions = Parameters[2]; -type PermissionOptions = ClaudePermissionOptions; - -interface PermissionCancellation { - onAbort: () => void; - readonly signals: Map void>; -} - -interface PendingPermission { - aborted: PermissionInteraction | null; - abortTask: Promise | null; - readonly bytes: number; - readonly cancellation: PermissionCancellation; - readonly interaction: PermissionInteraction; - readonly request: { - readonly input: Record; - readonly options: Record; - readonly toolName: string; - }; - readonly requestId: string; - readonly runId: string; - readonly sessionSuggestions: ClaudePermissionOptions["suggestions"]; - readonly toolUseId: string; -} - -interface OpeningPermission { - readonly bytes: number; - readonly cancellation: PermissionCancellation; - readonly request: PendingPermission["request"]; - readonly runId: string; - readonly task: Promise; - readonly toolUseId: string; -} - -function permissionOptions(options: ClaudePermissionOptions): PermissionOption[] { - const allowSession = options.suggestions?.some( - (suggestion) => suggestion.destination === "session", - ); - const result: PermissionOption[] = [ - { effect: "allow", id: "allow_once", label: "Allow once", scope: "once" }, - ]; - - if (allowSession) { - result.push({ - effect: "allow", - id: "allow_session", - label: "Allow for session", - scope: "session", - }); - } - - result.push({ effect: "deny", id: "deny_once", label: "Deny", scope: "once" }); - return result; -} - -export interface ClaudeContractPermissionsOptions { - readonly createId: () => string; - readonly interactionTimeoutMs: number; - readonly isRunFinishing: (runId: string) => boolean; - readonly maxPendingPermissionBytes: number; - readonly projection: ContractProjection; - readonly transcript: ClaudeContractTranscript; -} - -export class ClaudeContractPermissions { - readonly #createId: () => string; - #disposed = false; - readonly #interactionTimeoutMs: number; - readonly #isRunFinishing: (runId: string) => boolean; - readonly #maxPendingPermissionBytes: number; - readonly #openingPermissions = new Map(); - readonly #pendingPermissions = new Map(); - #pendingPermissionBytes = 0; - readonly #projection: ContractProjection; - readonly #textEncoder = new TextEncoder(); - readonly #transcript: ClaudeContractTranscript; - - constructor(options: ClaudeContractPermissionsOptions) { - this.#createId = options.createId; - this.#interactionTimeoutMs = options.interactionTimeoutMs; - this.#isRunFinishing = options.isRunFinishing; - this.#maxPendingPermissionBytes = options.maxPendingPermissionBytes; - this.#projection = options.projection; - this.#transcript = options.transcript; - } - - async openPermission( - runId: string, - toolName: string, - input: Record, - options: PermissionOptions, - ): Promise { - this.#assertActive(); - if (this.#isRunFinishing(runId)) { - throw new Error("Claude permission request outlived its active Run."); - } - const projectedInput = this.#transcript.toolInput(input); - - if (!isRecord(projectedInput)) { - throw new Error("Claude permission tool input must be finite JSON."); - } - - const request = { - input: projectedInput, - options: structuredClone( - Object.fromEntries(Object.entries(options).filter(([name]) => name !== "signal")), - ), - toolName, - }; - const opening = this.#openingPermissions.get(options.requestId); - - if (opening !== undefined) { - if ( - opening.runId !== runId || - opening.toolUseId !== options.toolUseID || - !isDeepStrictEqual(opening.request, request) - ) { - throw new Error( - `Claude permission request ${options.requestId} changed identity or content.`, - ); - } - - this.#trackSignal(opening.cancellation, options.signal); - return opening.task; - } - - const existingPermission = [...this.#pendingPermissions].find( - ([, pending]) => pending.requestId === options.requestId, - ); - - if (existingPermission !== undefined) { - const [interactionId, pending] = existingPermission; - - if ( - pending.runId !== runId || - pending.toolUseId !== options.toolUseID || - !isDeepStrictEqual(pending.request, request) - ) { - throw new Error( - `Claude permission request ${options.requestId} changed identity or content.`, - ); - } - - this.#trackSignal(pending.cancellation, options.signal); - const aborted = this.#abortedSignal(pending.cancellation); - if (aborted !== null) { - await this.#abortPermission(interactionId); - aborted.throwIfAborted(); - } - return interactionId; - } - - options.signal.throwIfAborted(); - const bytes = this.#textEncoder.encode(JSON.stringify(request)).byteLength; - - if (bytes > this.#maxPendingPermissionBytes - this.#pendingPermissionBytes) { - throw new RangeError("Claude pending permission budget is exhausted."); - } - - this.#pendingPermissionBytes += bytes; - const cancellation: PermissionCancellation = { - onAbort: () => {}, - signals: new Map(), - }; - this.#trackSignal(cancellation, options.signal); - const task = this.#createPermission( - runId, - toolName, - projectedInput, - options, - request, - bytes, - cancellation, - ); - const openingPermission: OpeningPermission = { - bytes, - cancellation, - request, - runId, - task, - toolUseId: options.toolUseID, - }; - this.#openingPermissions.set(options.requestId, openingPermission); - - try { - return await task; - } catch (error) { - this.#dropOpening(options.requestId, openingPermission); - throw error; - } - } - - async #createPermission( - runId: string, - toolName: string, - projectedInput: JsonObject, - options: PermissionOptions, - request: PendingPermission["request"], - bytes: number, - cancellation: PermissionCancellation, - ): Promise { - this.#throwIfAborted(cancellation); - const itemId = this.#transcript.id(runId, "tool", options.toolUseID); - const now = this.#projection.now(); - const current = this.#projection.item(runId, itemId); - const name = nonEmpty(toolName, "Tool"); - - if (current !== undefined && (current.kind !== "tool" || current.status !== "active")) { - throw new Error("Claude permission request references a terminal or non-tool item."); - } - - if (current === undefined) { - const item = itemSchema.parse({ - audience: "participants", - category: toolCategory(name), - createdAt: now.toISOString(), - id: itemId, - input: projectedInput, - kind: "tool", - name, - origin: name.startsWith("mcp__") ? "mcp" : "provider", - provenance: provenance("permission/requested", { - requestId: options.requestId, - toolUseId: options.toolUseID, - }), - runId, - status: "active", - title: nonEmpty(options.displayName ?? options.title, name), - updatedAt: now.toISOString(), - }); - await this.#retryUnknown(() => - this.#projection.putItem( - runId, - "permission/requested.tool", - providerCause("permission/requested", options.requestId), - item, - ), - ); - this.#transcript.markAuthoritativeToolInput(runId, itemId); - } - - this.#throwIfAborted(cancellation); - - const interactionId = this.#createId(); - const interaction = permissionInteractionSchema.parse({ - audience: "participants", - blocking: true, - createdAt: now.toISOString(), - expiresAt: new Date(now.getTime() + this.#interactionTimeoutMs).toISOString(), - id: interactionId, - itemId, - kind: "permission", - provenance: provenance("permission/requested", { - requestId: options.requestId, - toolUseId: options.toolUseID, - }), - request: { - ...(options.description === undefined ? {} : { description: options.description }), - options: permissionOptions(options), - subject: { itemId, type: "item" }, - title: nonEmpty(options.title ?? options.displayName, `Allow ${name}?`), - }, - runId, - status: "open", - }); - await this.#retryUnknown(() => - this.#projection.putInteraction( - runId, - "permission/requested", - providerCause("permission/requested", options.requestId), - interaction, - ), - ); - - this.#assertActive(); - if (this.#isRunFinishing(runId) || this.#projection.run(runId)?.status !== "active") { - throw new Error("Claude permission request outlived its active Run."); - } - - this.#openingPermissions.delete(options.requestId); - const pending: PendingPermission = { - aborted: null, - abortTask: null, - bytes, - cancellation, - interaction, - request, - requestId: options.requestId, - runId, - sessionSuggestions: options.suggestions - ?.filter((suggestion) => suggestion.destination === "session") - .map((suggestion) => structuredClone(suggestion)), - toolUseId: options.toolUseID, - }; - this.#pendingPermissions.set(interactionId, pending); - cancellation.onAbort = () => { - void this.#abortPermission(interactionId).catch(() => {}); - }; - const aborted = this.#abortedSignal(cancellation); - if (aborted !== null) { - await this.#abortPermission(interactionId); - aborted.throwIfAborted(); - } - return interactionId; - } - - async resolveInteraction( - interactionId: string, - resolution: InteractionResolution, - ): Promise { - this.#assertActive(); - const pending = this.#pendingPermissions.get(interactionId); - - if (pending === undefined) { - return null; - } - - if (this.#abortedSignal(pending.cancellation) !== null) { - await this.#abortPermission(interactionId); - return null; - } - - if (resolution.kind !== "permission") { - throw new Error("Claude permission interaction requires a permission resolution."); - } - - const selected = resolution.value.type === "selected" ? resolution.value.optionId : null; - const available = pending.interaction.request.options.some((option) => option.id === selected); - - if (selected !== null && !available) { - throw new Error("Claude permission resolution selected an unavailable option."); - } - - this.#projection.releaseInteraction(interactionId); - this.#dropPermission(interactionId); - - if (selected === "allow_once" || selected === "allow_session") { - return { - behavior: "allow", - toolUseID: pending.toolUseId, - updatedInput: pending.request.input, - ...(selected === "allow_session" && pending.sessionSuggestions !== undefined - ? { updatedPermissions: pending.sessionSuggestions } - : {}), - }; - } - - return { - behavior: "deny", - interrupt: resolution.value.type === "cancelled", - message: "Rejected by user.", - toolUseID: pending.toolUseId, - }; - } - - #abortedInteraction(interaction: PermissionInteraction): PermissionInteraction { - return permissionInteractionSchema.parse({ - ...interaction, - endedAt: this.#projection.now().toISOString(), - resolution: { type: "cancelled" }, - status: "resolved", - }); - } - - async #putAbortedInteraction( - interaction: PermissionInteraction, - requestId: string, - ): Promise { - await this.#retryUnknown(() => - this.#projection.putInteraction( - interaction.runId, - "permission/aborted", - providerCause("permission/aborted", requestId), - interaction, - ), - ); - this.#projection.releaseInteraction(interaction.id); - } - - #abortPermission(interactionId: string): Promise { - const pending = this.#pendingPermissions.get(interactionId); - - if (pending === undefined) { - return Promise.resolve(); - } - - if (pending.abortTask !== null) { - return pending.abortTask.catch(() => this.#abortPermission(interactionId)); - } - - if (this.#projection.run(pending.runId)?.status !== "active") { - this.#dropPermission(interactionId); - return Promise.resolve(); - } - - pending.aborted ??= this.#abortedInteraction(pending.interaction); - const task = this.#putAbortedInteraction(pending.aborted, pending.requestId) - .then(() => this.#dropPermission(interactionId)) - .finally(() => { - if (pending.abortTask === task) { - pending.abortTask = null; - } - }); - pending.abortTask = task; - return task; - } - - async #retryUnknown(operation: () => Promise): Promise { - try { - return await operation(); - } catch (error) { - if (!(error instanceof AuthorityOutcomeUnknownError)) { - throw error; - } - - return operation(); - } - } - - #trackSignal(cancellation: PermissionCancellation, signal: AbortSignal): void { - if (cancellation.signals.has(signal)) { - return; - } - - const onAbort = () => cancellation.onAbort(); - cancellation.signals.set(signal, onAbort); - signal.addEventListener("abort", onAbort, { once: true }); - if (signal.aborted) { - onAbort(); - } - } - - #abortedSignal(cancellation: PermissionCancellation): AbortSignal | null { - for (const signal of cancellation.signals.keys()) { - if (signal.aborted) { - return signal; - } - } - - return null; - } - - #throwIfAborted(cancellation: PermissionCancellation): void { - this.#abortedSignal(cancellation)?.throwIfAborted(); - } - - #clearSignals(cancellation: PermissionCancellation): void { - cancellation.onAbort = () => {}; - for (const [signal, onAbort] of cancellation.signals) { - signal.removeEventListener("abort", onAbort); - } - cancellation.signals.clear(); - } - - #dropOpening(requestId: string, opening: OpeningPermission): void { - if (this.#openingPermissions.get(requestId) !== opening) { - return; - } - - this.#openingPermissions.delete(requestId); - this.#pendingPermissionBytes -= opening.bytes; - this.#clearSignals(opening.cancellation); - } - - #dropPermission(interactionId: string): void { - const pending = this.#pendingPermissions.get(interactionId); - - if (pending !== undefined) { - this.#pendingPermissionBytes -= pending.bytes; - this.#pendingPermissions.delete(interactionId); - this.#clearSignals(pending.cancellation); - } - } - - releaseRun(runId: string): void { - for (const [requestId, opening] of this.#openingPermissions) { - if (opening.runId === runId) { - this.#dropOpening(requestId, opening); - } - } - for (const [id, pending] of this.#pendingPermissions) { - if (pending.runId === runId) { - this.#dropPermission(id); - } - } - } - - dispose(): void { - this.#disposed = true; - for (const [requestId, opening] of this.#openingPermissions) { - this.#dropOpening(requestId, opening); - } - for (const id of this.#pendingPermissions.keys()) { - this.#dropPermission(id); - } - this.#pendingPermissionBytes = 0; - } - - #assertActive(): void { - if (this.#disposed) { - throw new Error("Claude Contract permissions are disposed."); - } - } -} diff --git a/src/runtimes/claude/contract-result.ts b/src/runtimes/claude/contract-result.ts deleted file mode 100644 index c9552ff..0000000 --- a/src/runtimes/claude/contract-result.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; - -import { itemSchema } from "../../contract"; -import type { ProtocolError } from "../../contract"; -import { asJsonValue, type ContractProjection } from "../contract-projection"; -import { createProviderMeta } from "../contract-adapter-meta"; -import { finishReason, isLimit, isRetryable, toUsage } from "./contract-items"; - -const { cause: providerCause, provenance } = createProviderMeta("anthropic"); - -export interface FinishClaudeResultOptions { - readonly id: (runId: string, kind: string, nativeId: string) => string; - readonly message: Extract; - readonly onRunReleased: (runId: string) => void; - readonly projection: ContractProjection; - readonly runId: string; -} - -export async function finishClaudeResult(options: FinishClaudeResultOptions): Promise { - const { id, message, onRunReleased, projection, runId } = options; - const event = `result/${message.subtype}`; - const cause = providerCause(event, message.uuid); - const usage = toUsage(message); - if (usage !== undefined) { - await projection.updateUsage(runId, event, cause, usage); - } - - if (message.subtype === "success") { - const hasMessageText = projection - .items(runId) - .some( - (item) => - item.kind === "message" && - item.status === "completed" && - item.content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join("") === - message.result, - ); - if (message.result.length > 0 && !hasMessageText) { - const now = projection.now().toISOString(); - await projection.putItem( - runId, - `${event}/final`, - cause, - itemSchema.parse({ - audience: "participants", - content: [{ text: message.result, type: "text" }], - createdAt: now, - endedAt: now, - id: id(runId, "result", `${message.uuid}:final`), - kind: "message", - phase: "final", - provenance: provenance(event, { messageId: message.uuid }), - role: "agent", - runId, - status: "completed", - updatedAt: now, - }), - ); - } - - if (message.structured_output !== undefined) { - const now = projection.now().toISOString(); - const structured = asJsonValue(message.structured_output); - if (structured !== undefined) { - await projection.putItem( - runId, - `${event}/structured_output`, - cause, - itemSchema.parse({ - audience: "participants", - content: [{ type: "json", value: structured }], - createdAt: now, - endedAt: now, - id: id(runId, "structured", message.uuid), - kind: "artifact", - name: "structured-output.json", - provenance: provenance(event, { messageId: message.uuid }), - runId, - status: "completed", - updatedAt: now, - }), - ); - } - } - - await projection.finishRun({ - activeItemStatus: "cancelled", - cause, - event, - finishReason: finishReason(message), - runId, - status: "completed", - }); - onRunReleased(runId); - return; - } - - const cancelled = - message.terminal_reason === "aborted_streaming" || message.terminal_reason === "aborted_tools"; - - if (isLimit(message)) { - await projection.finishRun({ - activeItemStatus: "cancelled", - cause, - event, - finishReason: "limit", - runId, - status: "completed", - }); - onRunReleased(runId); - return; - } - - const error = { - code: `anthropic.${message.subtype}`, - ...(message.terminal_reason === undefined - ? {} - : { details: { terminalReason: message.terminal_reason } }), - message: message.errors.join("\n") || "Agent SDK run failed.", - retryable: isRetryable(message), - } satisfies ProtocolError; - await projection.finishRun({ - cause, - ...(cancelled ? {} : { error }), - event, - runId, - status: cancelled ? "cancelled" : "failed", - }); - onRunReleased(runId); -} diff --git a/src/runtimes/claude/contract-transcript-state.ts b/src/runtimes/claude/contract-transcript-state.ts deleted file mode 100644 index ddca1a2..0000000 --- a/src/runtimes/claude/contract-transcript-state.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { asJsonValue } from "../contract-projection"; - -export interface ToolInputBuffer { - readonly bytes: number; - readonly overflowed: boolean; - readonly text: string; -} - -export class ClaudeContractTranscriptState { - readonly #authoritativeToolInputs = new Set(); - readonly #blockToolIds = new Map(); - readonly #createId: () => string; - readonly #ids = new Map>(); - readonly #maxToolInputBytes: number; - readonly #textEncoder = new TextEncoder(); - #toolInputBytes = 0; - readonly #toolInputFragments = new Map(); - - constructor(createId: () => string, maxToolInputBytes: number) { - this.#createId = createId; - this.#maxToolInputBytes = maxToolInputBytes; - } - - id(runId: string, kind: string, nativeId: string): string { - const candidate = nativeId.length > 0 ? `${kind}:${nativeId}` : ""; - - if (candidate.length > 0 && candidate.length <= 256) { - return candidate; - } - - let ids = this.#ids.get(runId); - - if (ids === undefined) { - ids = new Map(); - this.#ids.set(runId, ids); - } - - const key = `${kind}:${nativeId}`; - let id = ids.get(key); - - if (id === undefined) { - id = this.#createId(); - ids.set(key, id); - } - - return id; - } - - reasoningId(runId: string, messageId: string): string { - return this.id(runId, "reasoning", `${messageId}:reasoning`); - } - - hasAuthoritativeToolInput(key: string): boolean { - return this.#authoritativeToolInputs.has(key); - } - - markAuthoritativeToolInput(runId: string, itemId: string): void { - this.#authoritativeToolInputs.add(`${runId}:${itemId}`); - } - - setBlockToolId(key: string, toolId: string): void { - this.#blockToolIds.set(key, toolId); - } - - blockToolId(key: string): string | undefined { - return this.#blockToolIds.get(key); - } - - deleteBlockToolId(key: string): void { - this.#blockToolIds.delete(key); - } - - deleteBlockToolIdsForTool(toolId: string): void { - for (const [key, value] of this.#blockToolIds) { - if (value === toolId) { - this.#blockToolIds.delete(key); - } - } - } - - appendToolInput(key: string, fragment: string): void { - const current = this.#toolInputFragments.get(key) ?? { - bytes: 0, - overflowed: false, - text: "", - }; - - if (current.overflowed) { - return; - } - - const addedBytes = this.#textEncoder.encode(fragment).byteLength; - - if (addedBytes > this.#maxToolInputBytes - this.#toolInputBytes) { - this.#toolInputBytes -= current.bytes; - this.#toolInputFragments.set(key, { bytes: 0, overflowed: true, text: "" }); - return; - } - - this.#toolInputBytes += addedBytes; - this.#toolInputFragments.set(key, { - bytes: current.bytes + addedBytes, - overflowed: false, - text: current.text + fragment, - }); - } - - toolInputBuffer(key: string): ToolInputBuffer | undefined { - return this.#toolInputFragments.get(key); - } - - dropToolInput(key: string): void { - const buffer = this.#toolInputFragments.get(key); - - if (buffer !== undefined) { - this.#toolInputBytes -= buffer.bytes; - this.#toolInputFragments.delete(key); - } - } - - toolInput(value: unknown) { - const input = asJsonValue(value); - - if ( - input !== undefined && - this.#textEncoder.encode(JSON.stringify(input)).byteLength > this.#maxToolInputBytes - ) { - throw new RangeError("Claude tool input exceeds its byte limit."); - } - - return input; - } - - releaseRun(runId: string): void { - this.#ids.delete(runId); - - for (const key of this.#authoritativeToolInputs) { - if (key.startsWith(`${runId}:`)) { - this.#authoritativeToolInputs.delete(key); - } - } - for (const key of this.#blockToolIds.keys()) { - if (key.startsWith(`${runId}:`)) { - this.#blockToolIds.delete(key); - } - } - for (const key of this.#toolInputFragments.keys()) { - if (key.startsWith(`${runId}:`)) { - this.dropToolInput(key); - } - } - } - - dispose(): void { - this.#authoritativeToolInputs.clear(); - this.#blockToolIds.clear(); - this.#ids.clear(); - this.#toolInputBytes = 0; - this.#toolInputFragments.clear(); - } -} diff --git a/src/runtimes/claude/contract-transcript.ts b/src/runtimes/claude/contract-transcript.ts deleted file mode 100644 index d043aa5..0000000 --- a/src/runtimes/claude/contract-transcript.ts +++ /dev/null @@ -1,763 +0,0 @@ -import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; - -import { itemSchema } from "../../contract"; -import type { ContentBlock } from "../../contract"; -import { asJsonValue, ContractProjection, nonEmpty } from "../contract-projection"; -import { isRecord, readNumber, readRecord, readString } from "./agent-sdk-json"; -import type { JsonObject } from "./agent-sdk-json"; -import { toContentBlocks, toolCategory } from "./contract-items"; -import { createProviderMeta } from "../contract-adapter-meta"; -import { ClaudeContractTranscriptState } from "./contract-transcript-state"; -import { finishClaudeResult } from "./contract-result"; - -const { cause: providerCause, provenance } = createProviderMeta("anthropic"); - -export interface ClaudeContractTranscriptOptions { - readonly createId: () => string; - readonly maxToolInputBytes: number; - readonly onRunReleased: (runId: string) => void; - readonly projection: ContractProjection; -} - -export class ClaudeContractTranscript { - readonly #createId: () => string; - readonly #onRunReleased: (runId: string) => void; - readonly #projection: ContractProjection; - readonly #state: ClaudeContractTranscriptState; - - constructor(options: ClaudeContractTranscriptOptions) { - this.#createId = options.createId; - this.#onRunReleased = options.onRunReleased; - this.#projection = options.projection; - this.#state = new ClaudeContractTranscriptState(options.createId, options.maxToolInputBytes); - } - - async handleMessage(message: SDKMessage, runId: string): Promise { - switch (message.type) { - case "assistant": - await this.#onAssistant(message, runId); - return false; - case "result": - await this.#onResult(message, runId); - return true; - case "stream_event": - await this.#onStreamEvent(message, runId); - return false; - case "tool_progress": - await this.#onToolProgress(message, runId); - return false; - case "user": - await this.#onUserMessage(message, runId); - return false; - case "system": - await this.#onSystemMessage(message, runId); - return false; - default: - return false; - } - } - - async #onAssistant( - message: Extract, - runId: string, - ): Promise { - const event = "assistant/message"; - const occurredAt = this.#projection.now().toISOString(); - const messageId = this.id(runId, "message", message.uuid); - const reasoningId = this.#reasoningId(runId, messageId); - const text: ContentBlock[] = []; - const reasoning: ContentBlock[] = []; - - for (const block of message.message.content) { - if (!isRecord(block)) { - continue; - } - - if (block["type"] === "text") { - const content = toContentBlocks(block); - - if (content.length > 0) { - await this.#ensureMessage(runId, messageId, event, message.uuid); - text.push(...content); - } - continue; - } - - if (block["type"] === "thinking") { - const thinking = readString(block, "thinking"); - if (thinking !== null) { - await this.#ensureReasoning(runId, messageId, event, message.uuid); - reasoning.push({ text: thinking, type: "text" }); - } - continue; - } - - if (this.#isToolUse(block)) { - await this.#putToolUse(runId, messageId, block, event, occurredAt); - continue; - } - - const toolUseId = readString(block, "tool_use_id"); - if (toolUseId !== null) { - await this.#completeTool(runId, toolUseId, block, undefined, event, occurredAt); - } - } - - if (text.length > 0 || this.#projection.item(runId, messageId)?.kind === "message") { - const current = this.#projection.item(runId, messageId); - if (current?.status === "active" || current === undefined) { - await this.#projection.putItem( - runId, - event, - providerCause(event, message.uuid), - itemSchema.parse({ - audience: "participants", - content: text, - createdAt: current?.createdAt ?? occurredAt, - endedAt: occurredAt, - ...(message.error === undefined - ? { status: "completed" } - : { - error: { - code: `anthropic.${message.error}`, - message: `Assistant message failed: ${message.error}.`, - retryable: - message.error === "overloaded" || - message.error === "rate_limit" || - message.error === "server_error", - }, - status: "failed", - }), - id: messageId, - kind: "message", - phase: "final", - provenance: provenance(event, { - messageId: message.uuid, - ...(message.parent_tool_use_id === null - ? {} - : { parentToolUseId: message.parent_tool_use_id }), - }), - role: "agent", - runId, - updatedAt: occurredAt, - ...(message.supersedes === undefined && message.timestamp === undefined - ? {} - : { - extensions: { - ...(message.timestamp === undefined - ? {} - : { "anthropic.agent-sdk/source-timestamp": message.timestamp }), - ...(message.supersedes === undefined - ? {} - : { "anthropic.agent-sdk/supersedes": message.supersedes }), - }, - }), - }), - ); - } - } - - const currentReasoning = this.#projection.item(runId, reasoningId); - if (reasoning.length > 0 || currentReasoning?.kind === "reasoning") { - if (currentReasoning?.status === "active" || currentReasoning === undefined) { - await this.#projection.putItem( - runId, - event, - providerCause(event, message.uuid), - itemSchema.parse({ - audience: "participants", - content: reasoning, - createdAt: currentReasoning?.createdAt ?? occurredAt, - endedAt: occurredAt, - id: reasoningId, - kind: "reasoning", - provenance: provenance(event, { messageId: message.uuid }), - runId, - status: "completed", - updatedAt: occurredAt, - ...(message.timestamp === undefined - ? {} - : { - extensions: { - "anthropic.agent-sdk/source-timestamp": message.timestamp, - }, - }), - }), - ); - } - } - } - - async #onStreamEvent( - message: Extract, - runId: string, - ): Promise { - const event = isRecord(message.event) ? message.event : null; - const eventType = readString(event, "type"); - const messageId = this.id(runId, "message", message.uuid); - - if (eventType === "message_start") { - return; - } - - if (eventType === "content_block_start") { - const block = readRecord(event, "content_block"); - const index = readNumber(event, "index"); - - if (block?.["type"] === "text") { - await this.#ensureMessage(runId, messageId, "stream/content_block_start", message.uuid); - const text = readString(block, "text"); - if (text !== null) { - await this.#appendText( - runId, - messageId, - "message.text", - text, - "stream/content_block_start", - ); - } - } else if (block?.["type"] === "thinking") { - const reasoningId = await this.#ensureReasoning( - runId, - messageId, - "stream/content_block_start", - message.uuid, - ); - const text = readString(block, "thinking"); - if (text !== null) { - await this.#appendText( - runId, - reasoningId, - "reasoning.text", - text, - "stream/content_block_start", - ); - } - } else if (block !== null && this.#isToolUse(block)) { - const toolId = await this.#putToolUse( - runId, - messageId, - block, - "stream/content_block_start", - this.#projection.now().toISOString(), - ); - if (index !== null && !this.#state.hasAuthoritativeToolInput(`${runId}:${toolId}`)) { - this.#state.setBlockToolId(`${runId}:${message.uuid}:${index}`, toolId); - } - } - return; - } - - if (eventType === "content_block_delta") { - const delta = readRecord(event, "delta"); - const deltaType = readString(delta, "type"); - - if (deltaType === "text_delta") { - await this.#ensureMessage(runId, messageId, "stream/text_delta", message.uuid); - await this.#appendText( - runId, - messageId, - "message.text", - readString(delta, "text") ?? "", - "stream/text_delta", - ); - } else if (deltaType === "thinking_delta") { - const reasoningId = await this.#ensureReasoning( - runId, - messageId, - "stream/thinking_delta", - message.uuid, - ); - await this.#appendText( - runId, - reasoningId, - "reasoning.text", - readString(delta, "thinking") ?? "", - "stream/thinking_delta", - ); - } else if (deltaType === "input_json_delta") { - const index = readNumber(event, "index"); - const toolId = - index === null ? undefined : this.#state.blockToolId(`${runId}:${message.uuid}:${index}`); - const fragment = readString(delta, "partial_json"); - if (toolId !== undefined && fragment !== null) { - this.#state.appendToolInput(`${runId}:${toolId}`, fragment); - } - } - return; - } - - if (eventType === "content_block_stop") { - const index = readNumber(event, "index"); - const key = index === null ? null : `${runId}:${message.uuid}:${index}`; - const toolId = key === null ? undefined : this.#state.blockToolId(key); - - if (toolId !== undefined) { - const item = this.#projection.item(runId, toolId); - const fragmentKey = `${runId}:${toolId}`; - const buffer = this.#state.toolInputBuffer(fragmentKey); - if ( - item?.kind === "tool" && - item.status === "active" && - buffer !== undefined && - !buffer.overflowed && - !this.#state.hasAuthoritativeToolInput(fragmentKey) - ) { - try { - const input = this.toolInput(JSON.parse(buffer.text)); - if (input !== undefined) { - await this.#projection.putItem( - runId, - "stream/input_json", - providerCause("stream/input_json", toolId), - itemSchema.parse({ - ...item, - input, - updatedAt: this.#projection.now().toISOString(), - }), - ); - } - } catch {} - } - this.#state.dropToolInput(fragmentKey); - } - if (key !== null) { - this.#state.deleteBlockToolId(key); - } - return; - } - } - - async #onUserMessage( - message: Extract, - runId: string, - ): Promise { - const blocks = Array.isArray(message.message.content) ? message.message.content : []; - const occurredAt = this.#projection.now().toISOString(); - - for (const block of blocks) { - if (!isRecord(block) || block["type"] !== "tool_result") { - continue; - } - - const toolUseId = readString(block, "tool_use_id"); - if (toolUseId !== null) { - await this.#completeTool( - runId, - toolUseId, - block, - message.tool_use_result, - "user/tool_result", - occurredAt, - ); - } - } - } - - async #onToolProgress( - message: Extract, - runId: string, - ): Promise { - const itemId = this.id(runId, "tool", message.tool_use_id); - const name = nonEmpty(message.tool_name, "Tool"); - let item = this.#projection.item(runId, itemId); - - if (item === undefined) { - const now = this.#projection.now().toISOString(); - item = await this.#projection.putItem( - runId, - "tool/progress", - providerCause("tool/progress", message.tool_use_id), - itemSchema.parse({ - audience: "participants", - category: toolCategory(name), - createdAt: now, - id: itemId, - kind: "tool", - name, - origin: name.startsWith("mcp__") ? "mcp" : "provider", - provenance: provenance("tool/progress", { toolUseId: message.tool_use_id }), - runId, - status: "active", - updatedAt: now, - }), - ); - } - - if (item.status === "active") { - await this.#projection.replacePreview({ - channel: "tool.progress", - itemId, - runId, - text: `${name} (${message.elapsed_time_seconds.toFixed(1)}s)`, - }); - } - } - - async #onSystemMessage( - message: Extract, - runId: string, - ): Promise { - if (message.subtype === "files_persisted" && message.files.length > 0) { - const changes = message.files.flatMap((file) => - file.filename.trim().length === 0 ? [] : [{ operation: "update", path: file.filename }], - ); - - if (changes.length === 0) { - return; - } - - const now = this.#projection.now().toISOString(); - const id = this.id(runId, "files", message.uuid); - - if (this.#projection.item(runId, id) !== undefined) { - return; - } - - await this.#projection.putItem( - runId, - "system/files_persisted", - providerCause("system/files_persisted", message.uuid), - itemSchema.parse({ - audience: "participants", - changes, - createdAt: now, - endedAt: now, - id, - kind: "change", - provenance: provenance("system/files_persisted", { messageId: message.uuid }), - runId, - status: "completed", - updatedAt: now, - }), - ); - return; - } - - if (message.subtype === "task_started") { - const now = this.#projection.now().toISOString(); - const id = this.id(runId, "task", message.task_id); - - if (this.#projection.item(runId, id) !== undefined) { - return; - } - - const name = nonEmpty( - message.subagent_type ?? message.workflow_name ?? message.task_type, - "Agent", - ); - await this.#projection.putItem( - runId, - "system/task_started", - providerCause("system/task_started", message.task_id), - itemSchema.parse({ - audience: message.skip_transcript === true ? "operators" : "participants", - category: "agent", - createdAt: now, - id, - input: message.prompt, - kind: "tool", - name, - origin: "provider", - provenance: provenance("system/task_started", { taskId: message.task_id }), - runId, - status: "active", - ...(message.description.trim().length === 0 ? {} : { title: message.description }), - updatedAt: now, - }), - ); - return; - } - - if (message.subtype === "task_progress") { - const id = this.id(runId, "task", message.task_id); - const item = this.#projection.item(runId, id); - if (item?.kind === "tool" && item.status === "active") { - await this.#projection.replacePreview({ - channel: "tool.progress", - itemId: id, - runId, - text: message.summary ?? message.description, - }); - } - return; - } - - if (message.subtype === "task_updated") { - const id = this.id(runId, "task", message.task_id); - const item = this.#projection.item(runId, id); - const status = message.patch.status; - if (item?.kind === "tool" && item.status === "active" && status !== undefined) { - await this.#projection.replacePreview({ - channel: "tool.progress", - itemId: id, - runId, - text: nonEmpty(message.patch.description, `Agent task ${status}`), - }); - } - return; - } - - if (message.subtype === "task_notification") { - const id = this.id(runId, "task", message.task_id); - const item = this.#projection.item(runId, id); - if (item?.kind === "tool" && item.status === "active") { - const now = this.#projection.now().toISOString(); - const failed = message.status === "failed"; - await this.#projection.putItem( - runId, - "system/task_notification", - providerCause("system/task_notification", message.task_id), - itemSchema.parse({ - ...item, - endedAt: now, - ...(failed - ? { - error: { - code: "anthropic.task_failed", - message: nonEmpty(message.summary, "Agent task failed."), - retryable: false, - }, - } - : {}), - output: toContentBlocks(message.summary), - status: failed ? "failed" : message.status === "stopped" ? "cancelled" : "completed", - structuredOutput: asJsonValue(message.usage), - updatedAt: now, - }), - ); - } - } - } - - async #onResult(message: Extract, runId: string): Promise { - await finishClaudeResult({ - id: (id, kind, nativeId) => this.id(id, kind, nativeId), - message, - onRunReleased: this.#onRunReleased, - projection: this.#projection, - runId, - }); - } - - async #ensureMessage( - runId: string, - messageId: string, - event: string, - nativeMessageId: string, - ): Promise { - if (this.#projection.item(runId, messageId) !== undefined) { - return; - } - - const now = this.#projection.now().toISOString(); - await this.#projection.putItem( - runId, - event, - providerCause(event, nativeMessageId), - itemSchema.parse({ - audience: "participants", - content: [], - createdAt: now, - id: messageId, - kind: "message", - phase: "final", - provenance: provenance(event, { messageId: nativeMessageId }), - role: "agent", - runId, - status: "active", - updatedAt: now, - }), - ); - } - - async #ensureReasoning( - runId: string, - messageId: string, - event: string, - nativeMessageId: string, - ): Promise { - const id = this.#reasoningId(runId, messageId); - - if (this.#projection.item(runId, id) === undefined) { - const now = this.#projection.now().toISOString(); - await this.#projection.putItem( - runId, - event, - providerCause(event, nativeMessageId), - itemSchema.parse({ - audience: "participants", - content: [], - createdAt: now, - id, - kind: "reasoning", - provenance: provenance(event, { messageId: nativeMessageId }), - runId, - status: "active", - updatedAt: now, - }), - ); - } - - return id; - } - - async #appendText( - runId: string, - itemId: string, - channel: "message.text" | "reasoning.text", - delta: string, - event: string, - ): Promise { - await this.#projection.appendText({ - cause: providerCause(event, itemId), - channel, - delta, - event, - itemId, - runId, - }); - } - - async #putToolUse( - runId: string, - parentMessageId: string, - block: JsonObject, - event: string, - occurredAt: string, - ): Promise { - const nativeId = readString(block, "id") ?? this.#createId(); - const id = this.id(runId, "tool", nativeId); - const existing = this.#projection.item(runId, id); - const inputKey = `${runId}:${id}`; - const authoritative = event === "assistant/message"; - - if (existing !== undefined && existing.status !== "active") { - return id; - } - - if (!authoritative && this.#state.hasAuthoritativeToolInput(inputKey)) { - return id; - } - - if (authoritative) { - this.#state.dropToolInput(inputKey); - this.#state.deleteBlockToolIdsForTool(id); - } - - const name = nonEmpty(readString(block, "name"), "Tool"); - const type = readString(block, "type"); - const server = readString(block, "server_name"); - const input = this.toolInput(block["input"]); - await this.#projection.putItem( - runId, - event, - providerCause(event, nativeId), - itemSchema.parse({ - audience: "participants", - category: toolCategory(name), - createdAt: existing?.createdAt ?? occurredAt, - id, - input, - kind: "tool", - name, - origin: type === "mcp_tool_use" || name.startsWith("mcp__") ? "mcp" : "provider", - provenance: provenance(event, { messageId: parentMessageId, toolUseId: nativeId }), - runId, - ...(server === null || server.trim().length === 0 ? {} : { server }), - status: "active", - title: name, - updatedAt: occurredAt, - }), - ); - if (authoritative) { - this.#state.markAuthoritativeToolInput(runId, id); - } - return id; - } - - async #completeTool( - runId: string, - nativeToolId: string, - block: JsonObject, - structuredOutput: unknown, - event: string, - occurredAt: string, - ): Promise { - const id = this.id(runId, "tool", nativeToolId); - const existing = this.#projection.item(runId, id); - - if (existing !== undefined && (existing.kind !== "tool" || existing.status !== "active")) { - return; - } - - const failed = block["is_error"] === true; - const output = toContentBlocks(block["content"]); - const structured = asJsonValue(structuredOutput); - const errorText = output - .flatMap((entry) => (entry.type === "text" ? [entry.text] : [])) - .join("\n"); - await this.#projection.putItem( - runId, - event, - providerCause(event, nativeToolId), - itemSchema.parse({ - audience: "participants", - category: existing?.kind === "tool" ? existing.category : "other", - createdAt: existing?.createdAt ?? occurredAt, - endedAt: occurredAt, - ...(failed - ? { - error: { - code: "anthropic.tool_failed", - message: errorText || "Tool failed.", - retryable: false, - }, - } - : {}), - id, - input: existing?.kind === "tool" ? existing.input : undefined, - kind: "tool", - name: existing?.kind === "tool" ? existing.name : "Tool", - origin: existing?.kind === "tool" ? existing.origin : "provider", - output, - provenance: provenance(event, { toolUseId: nativeToolId }), - runId, - status: failed ? "failed" : "completed", - ...(structured === undefined ? {} : { structuredOutput: structured }), - title: existing?.kind === "tool" ? existing.title : undefined, - updatedAt: occurredAt, - }), - ); - } - - #reasoningId(runId: string, messageId: string): string { - return this.#state.reasoningId(runId, messageId); - } - - id(runId: string, kind: string, nativeId: string): string { - return this.#state.id(runId, kind, nativeId); - } - - #isToolUse(block: JsonObject): boolean { - return ["mcp_tool_use", "server_tool_use", "tool_use"].includes( - readString(block, "type") ?? "", - ); - } - - markAuthoritativeToolInput(runId: string, itemId: string): void { - this.#state.markAuthoritativeToolInput(runId, itemId); - } - - toolInput(value: unknown) { - return this.#state.toolInput(value); - } - - releaseRun(runId: string): void { - this.#state.releaseRun(runId); - } - - dispose(): void { - this.#state.dispose(); - } -} diff --git a/src/runtimes/contract-adapter-meta.ts b/src/runtimes/contract-adapter-meta.ts deleted file mode 100644 index e11700f..0000000 --- a/src/runtimes/contract-adapter-meta.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { jsonValueSchema } from "../contract"; -import type { MutationCause } from "../contract"; - -export function asJsonValue(value: unknown) { - const parsed = jsonValueSchema.safeParse(value); - return parsed.success ? parsed.data : undefined; -} - -export function nonEmpty(value: string | null | undefined, fallback: string): string { - return value?.trim() || fallback; -} - -export function createProviderMeta(provider: string) { - return { - cause(event: string, id?: string): MutationCause { - return { - providerEventId: `${event}${id === undefined ? "" : `:${id}`}`.slice(0, 256), - type: "provider", - }; - }, - provenance(event: string, nativeIds?: Readonly>) { - const boundedIds = Object.fromEntries( - Object.entries(nativeIds ?? {}).filter( - ([, value]) => value.length > 0 && value.length <= 256, - ), - ); - - return { - event, - ...(Object.keys(boundedIds).length === 0 ? {} : { nativeIds: boundedIds }), - provider, - }; - }, - }; -} diff --git a/src/runtimes/contract-projection-authority.ts b/src/runtimes/contract-projection-authority.ts deleted file mode 100644 index 0349568..0000000 --- a/src/runtimes/contract-projection-authority.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { isDeepStrictEqual } from "node:util"; - -import { - assertProtocolAdmission, - AuthorityOutcomeUnknownError, - authorityContent, -} from "../contract"; -import type { AuthorityOperation, MutationCause, ProtocolAdmissionLimits } from "../contract"; -import { createDriverId } from "../protocol/id"; - -export const MAX_PENDING_MUTATION_BYTES = 32 * 1_024 * 1_024; -export const MAX_PENDING_MUTATIONS = 1_024; - -export interface ContractAuthorityUpdate { - readonly cause: MutationCause; - readonly event: string; - readonly mutationId: string; - readonly operations: readonly AuthorityOperation[]; - readonly runId: string; - readonly sessionId: string; -} - -export interface AuthorityWrite { - readonly intent: unknown; - readonly key: string; - readonly mutationId: string; - readonly update: Omit; -} - -export interface UnknownAuthorityWrite extends AuthorityWrite { - readonly error: AuthorityOutcomeUnknownError; -} - -export interface QueuedMutation { - readonly bytes: number; - readonly reject: (reason: unknown) => void; - readonly run: () => Promise; -} - -export function authorityKey(runId: string, event: string, cause: MutationCause): string { - const causeId = - cause.type === "command" - ? cause.commandId - : cause.type === "provider" - ? cause.providerEventId - : cause.type === "alarm" - ? cause.alarm - : cause.name; - return JSON.stringify([runId, event, cause.type, causeId]); -} - -export interface ContractProjectionAuthorityOptions { - readonly active: () => boolean; - readonly admissionLimits?: ProtocolAdmissionLimits | undefined; - readonly apply: (operations: readonly AuthorityOperation[]) => void; - readonly authority: (update: ContractAuthorityUpdate) => Promise; - readonly sessionId: string; -} - -export class ContractProjectionAuthority { - readonly #active: () => boolean; - readonly #admissionLimits: ProtocolAdmissionLimits | undefined; - readonly #apply: (operations: readonly AuthorityOperation[]) => void; - readonly #authority: (update: ContractAuthorityUpdate) => Promise; - readonly #sessionId: string; - #unknown: UnknownAuthorityWrite | undefined; - - constructor(options: ContractProjectionAuthorityOptions) { - this.#active = options.active; - this.#admissionLimits = options.admissionLimits; - this.#apply = options.apply; - this.#authority = options.authority; - this.#sessionId = options.sessionId; - } - - get unknown(): UnknownAuthorityWrite | undefined { - return this.#unknown; - } - - clear(): void { - this.#unknown = undefined; - } - - assertRetry( - key: string, - intent: unknown, - unknownAtEnqueue: UnknownAuthorityWrite | undefined, - ): void { - const unknown = this.#unknown; - - if (unknown === undefined) { - return; - } - - if (unknownAtEnqueue !== unknown || unknown.key !== key) { - throw unknown.error; - } - - if (!isDeepStrictEqual(unknown.intent, intent)) { - throw new AuthorityOutcomeUnknownError( - "Authority retry changed while its outcome was unknown.", - ); - } - } - - async commit( - runId: string, - event: string, - cause: MutationCause, - operations: readonly AuthorityOperation[], - intent: unknown, - reuseDerived = false, - ): Promise { - if (operations.length === 0) { - return operations; - } - - const update = { cause, event, operations, runId, sessionId: this.#sessionId }; - const key = authorityKey(runId, event, cause); - let pending: AuthorityWrite; - - if (this.#unknown === undefined) { - pending = { - intent: structuredClone(intent), - key, - mutationId: createDriverId(), - update, - }; - } else if (this.#unknown.key !== key) { - throw this.#unknown.error; - } else if ( - !isDeepStrictEqual(this.#unknown.intent, intent) || - (!reuseDerived && !isDeepStrictEqual(this.#unknown.update, update)) - ) { - throw new AuthorityOutcomeUnknownError( - `Authority write ${event} changed while its outcome was unknown.`, - ); - } else { - pending = this.#unknown; - } - - const submission = structuredClone({ ...pending.update, mutationId: pending.mutationId }); - - if (this.#admissionLimits !== undefined) { - assertProtocolAdmission( - submission, - this.#admissionLimits, - authorityContent(submission.operations), - ); - } - - try { - await this.#authority(submission); - } catch (error) { - if (error instanceof AuthorityOutcomeUnknownError && this.#active()) { - this.#unknown = { ...pending, error }; - } else { - this.#unknown = undefined; - } - throw error; - } - - if (!this.#active()) { - return pending.update.operations; - } - - try { - this.#apply(pending.update.operations); - } catch (error) { - const unknown = new AuthorityOutcomeUnknownError( - `Authority write ${event} committed but local apply failed.`, - { cause: error }, - ); - this.#unknown = { ...pending, error: unknown }; - throw unknown; - } - - this.#unknown = undefined; - return pending.update.operations; - } -} diff --git a/src/runtimes/contract-projection-preview.ts b/src/runtimes/contract-projection-preview.ts deleted file mode 100644 index 1ad260d..0000000 --- a/src/runtimes/contract-projection-preview.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { compareTimestamps, itemSchema } from "../contract"; -import type { - ContentBlock, - Item, - MutationCause, - PreviewUpdate, - ProtocolAdmissionLimits, -} from "../contract"; -import type { ContractAuthorityUpdate } from "./contract-projection-authority"; - -export interface ContractPreviewUpdate { - readonly runId: string; - readonly sessionId: string; - readonly update: PreviewUpdate; -} - -export interface ContractProjectionOptions { - readonly admissionLimits?: ProtocolAdmissionLimits | undefined; - readonly authority: (update: ContractAuthorityUpdate) => Promise; - readonly now?: (() => Date) | undefined; - readonly preview: (update: ContractPreviewUpdate) => void; - readonly previewCheckpointBytes?: number | undefined; - readonly previewReplaceIntervalMs?: number | undefined; - readonly sessionId: string; -} - -export function emitContractPreview( - preview: ContractProjectionOptions["preview"], - runId: string, - sessionId: string, - update: PreviewUpdate, -): void { - try { - preview({ runId, sessionId, update }); - } catch { - // Preview is best-effort; retained state repairs a dropped callback. - } -} - -export const DEFAULT_PREVIEW_CHECKPOINT_BYTES = 128 * 1_024; -export const DEFAULT_PREVIEW_REPLACE_INTERVAL_MS = 1_000; - -export interface PreviewStreamState { - bytes: number; - lastReplaceAtMs: number; - mode: "append" | "replace"; - segment: number; - sequence: number; - text: string; -} - -export type TextPreviewChannel = - | "message.text" - | "reasoning.text" - | "terminal.stderr" - | "terminal.stdout"; - -export interface AppendTextInput { - readonly cause: MutationCause; - readonly channel: TextPreviewChannel; - readonly delta: string; - readonly event: string; - readonly itemId: string; - readonly runId: string; -} - -export interface CheckpointTextInput { - readonly cause: MutationCause; - readonly channel: TextPreviewChannel; - readonly event: string; - readonly itemId: string; - readonly runId: string; -} - -export interface ReplacePreviewInput { - readonly channel: PreviewUpdate["channel"]; - readonly itemId: string; - readonly runId: string; - readonly text: string; -} - -export function itemKey(runId: string, itemId: string): string { - return `${runId}\u0000${itemId}`; -} - -export function streamKey(runId: string, itemId: string, channel: string): string { - return `${itemKey(runId, itemId)}\u0000${channel}`; -} - -export function replaceCheckpointCause( - runId: string, - itemId: string, - segment: number, -): MutationCause { - return { - providerEventId: `preview/replace:${runId}:${segment}:${itemId}`.slice(0, 256), - type: "provider", - }; -} - -export function latestTimestamp(previous: string, next: string): string { - return compareTimestamps(previous, next) > 0 ? previous : next; -} - -function textContent(text: string): ContentBlock[] { - return text.length === 0 ? [] : [{ text, type: "text" }]; -} - -export function appendItemText( - item: Item, - channel: TextPreviewChannel, - text: string, - updatedAt: string, -): Item { - if (item.kind === "message" && channel === "message.text") { - return itemSchema.parse({ - ...item, - content: [...item.content, ...textContent(text)], - updatedAt: latestTimestamp(item.updatedAt, updatedAt), - }); - } - - if (item.kind === "reasoning" && channel === "reasoning.text") { - return itemSchema.parse({ - ...item, - content: [...item.content, ...textContent(text)], - updatedAt: latestTimestamp(item.updatedAt, updatedAt), - }); - } - - if (item.kind === "terminal" && channel === "terminal.stdout") { - return itemSchema.parse({ - ...item, - stdout: [...item.stdout, ...textContent(text)], - updatedAt: latestTimestamp(item.updatedAt, updatedAt), - }); - } - - if (item.kind === "terminal" && channel === "terminal.stderr") { - return itemSchema.parse({ - ...item, - stderr: [...item.stderr, ...textContent(text)], - updatedAt: latestTimestamp(item.updatedAt, updatedAt), - }); - } - - return item; -} - -export function advancePreviews( - previews: Map, - runId: string, - itemId: string, - atMs: number, -): void { - const prefix = `${itemKey(runId, itemId)}\u0000`; - - for (const [key, preview] of previews) { - if (!key.startsWith(prefix)) { - continue; - } - - previews.set(key, { - bytes: 0, - lastReplaceAtMs: atMs, - mode: preview.mode, - segment: preview.segment + 1, - sequence: 0, - text: "", - }); - } -} - -export function flushItemText( - previews: ReadonlyMap, - item: Item, - updatedAt: string, -): Item { - let next = item; - - for (const channel of [ - "message.text", - "reasoning.text", - "terminal.stdout", - "terminal.stderr", - ] as const) { - const preview = previews.get(streamKey(item.runId, item.id, channel)); - if (preview?.mode === "replace" && preview.sequence > 0) { - next = replaceItemText(next, channel, preview.text, updatedAt); - } else if (preview?.text !== undefined && preview.text.length > 0) { - next = appendItemText(next, channel, preview.text, updatedAt); - } - } - - return next; -} - -export function replaceItemText( - item: Item, - channel: TextPreviewChannel, - text: string, - updatedAt: string, -): Item { - if (item.kind === "terminal" && channel === "terminal.stdout") { - return itemSchema.parse({ - ...item, - stdout: textContent(text), - updatedAt: latestTimestamp(item.updatedAt, updatedAt), - }); - } - - if (item.kind === "terminal" && channel === "terminal.stderr") { - return itemSchema.parse({ - ...item, - stderr: textContent(text), - updatedAt: latestTimestamp(item.updatedAt, updatedAt), - }); - } - - return appendItemText(item, channel, text, updatedAt); -} - -export function truncateUtf8(value: string, maxBytes: number): string { - const encoded = new TextEncoder().encode(value); - return new TextDecoder().decode(encoded.subarray(0, maxBytes), { stream: true }); -} - -export function itemText(item: Item, channel: TextPreviewChannel): string { - if (item.kind === "message" && channel === "message.text") { - return item.content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join(""); - } - - if (item.kind === "reasoning" && channel === "reasoning.text") { - return item.content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join(""); - } - - if (item.kind === "terminal" && channel === "terminal.stdout") { - return item.stdout.flatMap((block) => (block.type === "text" ? [block.text] : [])).join(""); - } - - if (item.kind === "terminal" && channel === "terminal.stderr") { - return item.stderr.flatMap((block) => (block.type === "text" ? [block.text] : [])).join(""); - } - - return ""; -} - -export function matchesPreviewChannel(item: Item, channel: PreviewUpdate["channel"]): boolean { - switch (channel) { - case "message.text": - return item.kind === "message"; - case "reasoning.text": - return item.kind === "reasoning"; - case "terminal.stderr": - case "terminal.stdout": - return item.kind === "terminal"; - case "tool.progress": - return item.kind === "tool"; - default: - return true; - } -} diff --git a/src/runtimes/contract-projection-state.ts b/src/runtimes/contract-projection-state.ts deleted file mode 100644 index a55fd58..0000000 --- a/src/runtimes/contract-projection-state.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { isDeepStrictEqual } from "node:util"; - -import { runSchema } from "../contract"; -import type { AuthorityOperation, Interaction, Item, Run } from "../contract"; -import { itemKey, latestTimestamp } from "./contract-projection-preview"; - -export interface ContractProjectionStateCallbacks { - readonly activeItem: (item: Item) => void; - readonly clearItem: (runId: string, itemId: string) => void; - readonly releaseRun: (runId: string) => void; -} - -export class ContractProjectionState { - readonly #childEndedAt = new Map(); - readonly #interactions = new Map(); - readonly #items = new Map(); - readonly #runs = new Map(); - - run(runId: string): Run | undefined { - return this.#runs.get(runId); - } - - item(runId: string, id: string): Item | undefined { - return this.#items.get(itemKey(runId, id)); - } - - interaction(id: string): Interaction | undefined { - return this.#interactions.get(id); - } - - releaseInteraction(id: string): void { - this.#interactions.delete(id); - } - - items(runId: string): Item[] { - return [...this.#items.values()].filter((item) => item.runId === runId); - } - - interactions(runId: string): Interaction[] { - return [...this.#interactions.values()].filter((interaction) => interaction.runId === runId); - } - - attachRun(value: Run): void { - const run = runSchema.parse(value); - - if (run.status !== "active") { - throw new Error(`Contract projection can only attach an active run ${run.id}.`); - } - - const existing = this.#runs.get(run.id); - - if (existing !== undefined) { - if (!isDeepStrictEqual(existing, run)) { - throw new Error( - `Contract projection run ${run.id} is already attached with different state.`, - ); - } - - return; - } - - this.#runs.set(run.id, run); - } - - latestChildEnd(runId: string): string | undefined { - return this.#childEndedAt.get(runId); - } - - requireRun(runId: string): Run { - const run = this.#runs.get(runId); - - if (run === undefined) { - throw new Error(`Contract projection references unknown run ${runId}.`); - } - - return run; - } - - apply( - operations: readonly AuthorityOperation[], - callbacks: ContractProjectionStateCallbacks, - ): void { - const terminalRuns: string[] = []; - - for (const operation of operations) { - if (operation.op === "remove") { - terminalRuns.push(operation.id); - continue; - } - - switch (operation.entity) { - case "session": - break; - case "run": - this.#runs.set(operation.value.id, operation.value); - if (operation.value.status !== "active") { - terminalRuns.push(operation.value.id); - } - break; - case "item": { - const item = operation.value; - this.#items.set(itemKey(item.runId, item.id), item); - if (item.status === "active") { - callbacks.activeItem(item); - } else { - callbacks.clearItem(item.runId, item.id); - } - break; - } - case "interaction": - this.#interactions.set(operation.value.id, operation.value); - break; - } - } - - for (const runId of terminalRuns) { - this.#releaseRun(runId); - callbacks.releaseRun(runId); - } - } - - clear(): void { - this.#childEndedAt.clear(); - this.#interactions.clear(); - this.#items.clear(); - this.#runs.clear(); - } - - #releaseRun(runId: string): void { - const run = this.#runs.get(runId); - - if (run !== undefined && run.status !== "active" && run.parentRunId !== undefined) { - const parent = this.#runs.get(run.parentRunId); - - if (parent?.status === "active") { - const previous = this.#childEndedAt.get(parent.id); - this.#childEndedAt.set( - parent.id, - previous === undefined ? run.endedAt : latestTimestamp(previous, run.endedAt), - ); - } - } - - this.#childEndedAt.delete(runId); - this.#runs.delete(runId); - - for (const key of this.#items.keys()) { - if (key.startsWith(`${runId}\u0000`)) { - this.#items.delete(key); - } - } - - for (const [id, interaction] of this.#interactions) { - if (interaction.runId === runId) { - this.#interactions.delete(id); - } - } - } -} diff --git a/src/runtimes/contract-projection.ts b/src/runtimes/contract-projection.ts deleted file mode 100644 index 399f850..0000000 --- a/src/runtimes/contract-projection.ts +++ /dev/null @@ -1,782 +0,0 @@ -import { isDeepStrictEqual } from "node:util"; - -import { interactionSchema, itemSchema, jsonByteLength, runSchema } from "../contract"; -import type { - AuthorityOperation, - Interaction, - Item, - MutationCause, - PreviewUpdate, - ProtocolError, - Run, - TokenUsage, -} from "../contract"; -import { - authorityKey, - ContractProjectionAuthority, - MAX_PENDING_MUTATION_BYTES, - MAX_PENDING_MUTATIONS, - type QueuedMutation, -} from "./contract-projection-authority"; -import { - advancePreviews, - appendItemText, - type AppendTextInput, - type CheckpointTextInput, - DEFAULT_PREVIEW_CHECKPOINT_BYTES, - DEFAULT_PREVIEW_REPLACE_INTERVAL_MS, - emitContractPreview, - flushItemText, - itemKey, - itemText, - latestTimestamp, - matchesPreviewChannel, - type ContractProjectionOptions, - type PreviewStreamState, - replaceCheckpointCause, - type ReplacePreviewInput, - replaceItemText, - streamKey, - type TextPreviewChannel, - truncateUtf8, -} from "./contract-projection-preview"; -import { ContractProjectionState } from "./contract-projection-state"; - -export { AuthorityOutcomeUnknownError } from "../contract"; -export { asJsonValue, createProviderMeta, nonEmpty } from "./contract-adapter-meta"; -export type { ContractAuthorityUpdate } from "./contract-projection-authority"; -export type { - ContractPreviewUpdate, - ContractProjectionOptions, -} from "./contract-projection-preview"; - -export class ContractProjection { - readonly #authority: ContractProjectionAuthority; - #disposed = false; - readonly #now: () => Date; - readonly #preview: ContractProjectionOptions["preview"]; - readonly #previewCheckpointBytes: number; - readonly #previewReplaceIntervalMs: number; - readonly #previewStreams = new Map(); - readonly #state = new ContractProjectionState(); - readonly #sessionId: string; - readonly #textEncoder = new TextEncoder(); - #mutationActive = false; - #mutationBytes = 0; - readonly #mutationQueue: QueuedMutation[] = []; - - constructor(options: ContractProjectionOptions) { - const admissionLimits = - options.admissionLimits === undefined ? undefined : { ...options.admissionLimits }; - this.#now = options.now ?? (() => new Date()); - this.#preview = options.preview; - this.#previewCheckpointBytes = - options.previewCheckpointBytes ?? DEFAULT_PREVIEW_CHECKPOINT_BYTES; - this.#previewReplaceIntervalMs = - options.previewReplaceIntervalMs ?? DEFAULT_PREVIEW_REPLACE_INTERVAL_MS; - this.#sessionId = options.sessionId; - - if ( - [ - this.#previewCheckpointBytes, - this.#previewReplaceIntervalMs, - ...(admissionLimits === undefined - ? [] - : [admissionLimits.maxBytes, admissionLimits.maxInlineBytes]), - ].some((value) => !Number.isSafeInteger(value) || value < 1) - ) { - throw new RangeError("Contract projection limits must be finite and positive."); - } - - this.#authority = new ContractProjectionAuthority({ - active: () => !this.#disposed, - admissionLimits, - apply: (operations) => this.#applyAuthorityOperations(operations), - authority: options.authority, - sessionId: options.sessionId, - }); - } - - now(): Date { - this.#assertActive(); - return this.#now(); - } - - run(runId: string): Run | undefined { - this.#assertActive(); - return this.#state.run(runId); - } - - item(runId: string, id: string): Item | undefined { - this.#assertActive(); - return this.#state.item(runId, id); - } - - interaction(id: string): Interaction | undefined { - this.#assertActive(); - return this.#state.interaction(id); - } - - releaseInteraction(id: string): void { - this.#assertActive(); - this.#state.releaseInteraction(id); - } - - items(runId: string): Item[] { - this.#assertActive(); - return this.#state.items(runId); - } - - interactions(runId: string): Interaction[] { - this.#assertActive(); - return this.#state.interactions(runId); - } - - attachRun(value: Run): void { - this.#assertActive(); - this.#state.attachRun(value); - } - - async putItem(runId: string, event: string, cause: MutationCause, value: Item): Promise { - const intent = { cause, event, runId, value }; - return this.#mutate(authorityKey(runId, event, cause), intent, (stable) => - this.#putItem(stable.runId, stable.event, stable.cause, stable.value, stable, false), - ); - } - - async #putItem( - runId: string, - event: string, - cause: MutationCause, - value: Item, - intent: unknown, - reuseDerived: boolean, - ): Promise { - this.#requireRun(runId); - const item = itemSchema.parse(value); - - if (item.runId !== runId) { - throw new Error(`Contract projection item ${item.id} belongs to a different run.`); - } - - const existing = this.#state.item(runId, item.id); - - if (existing !== undefined && isDeepStrictEqual(existing, item)) { - return existing; - } - - const operations = await this.#commit( - runId, - event, - cause, - [{ entity: "item", op: "put", value: item }], - intent, - reuseDerived, - ); - const operation = operations[0]; - - if (operation?.op !== "put" || operation.entity !== "item") { - throw new Error(`Authority write ${event} did not retain its Item intent.`); - } - - return this.#state.item(runId, operation.value.id) ?? operation.value; - } - - async putInteraction( - runId: string, - event: string, - cause: MutationCause, - value: Interaction, - ): Promise { - const intent = { cause, event, runId, value }; - return this.#mutate(authorityKey(runId, event, cause), intent, async (stable) => { - this.#requireRun(stable.runId); - const interaction = interactionSchema.parse(stable.value); - - if (interaction.runId !== stable.runId) { - throw new Error( - `Contract projection interaction ${interaction.id} belongs to a different run.`, - ); - } - - const existing = this.#state.interaction(interaction.id); - - if (existing !== undefined && isDeepStrictEqual(existing, interaction)) { - return existing; - } - - const operations = await this.#commit( - stable.runId, - stable.event, - stable.cause, - [{ entity: "interaction", op: "put", value: interaction }], - stable, - ); - const operation = operations[0]; - - if (operation?.op !== "put" || operation.entity !== "interaction") { - throw new Error(`Authority write ${stable.event} did not retain its Interaction intent.`); - } - - return this.#state.interaction(operation.value.id) ?? operation.value; - }); - } - - async updateUsage( - runId: string, - event: string, - cause: MutationCause, - usage: TokenUsage, - ): Promise { - const intent = { cause, event, runId, usage }; - return this.#mutate(authorityKey(runId, event, cause), intent, async (stable) => { - const current = this.#requireRun(stable.runId); - - if (current.status !== "active") { - return; - } - - const run = runSchema.parse({ ...current, usage: stable.usage }); - - if (isDeepStrictEqual(current, run)) { - return; - } - - const operations = await this.#commit( - stable.runId, - stable.event, - stable.cause, - [{ entity: "run", op: "put", value: run }], - stable, - ); - const operation = operations[0]; - - if (operation?.op !== "put" || operation.entity !== "run") { - throw new Error(`Authority write ${stable.event} did not retain its Run intent.`); - } - }); - } - - async appendText(input: AppendTextInput): Promise { - return this.#mutate( - authorityKey(input.runId, `${input.event}.checkpoint`, input.cause), - input, - async (input) => { - this.#assertActive(); - - if (input.delta.length === 0) { - return; - } - - const item = this.#state.item(input.runId, input.itemId); - - if ( - item === undefined || - item.status !== "active" || - !matchesPreviewChannel(item, input.channel) - ) { - return; - } - - const key = streamKey(input.runId, input.itemId, input.channel); - const now = this.#now(); - const current: PreviewStreamState = this.#previewStreams.get(key) ?? { - bytes: 0, - lastReplaceAtMs: now.getTime(), - mode: "append", - segment: 0, - sequence: 0, - text: "", - }; - - if (current.mode !== "append") { - throw new Error(`Preview stream ${input.channel} changed update mode.`); - } - const text = current.text + input.delta; - const bytes = current.bytes + this.#textEncoder.encode(input.delta).byteLength; - - if (bytes >= this.#previewCheckpointBytes) { - const checkpoint = appendItemText( - flushItemText(this.#previewStreams, item, now.toISOString()), - input.channel, - input.delta, - now.toISOString(), - ); - await this.#putItem( - input.runId, - `${input.event}.checkpoint`, - input.cause, - checkpoint, - input, - true, - ); - if (this.#disposed) { - return; - } - this.#previewStreams.set(key, { - bytes: 0, - lastReplaceAtMs: now.getTime(), - mode: "append", - segment: current.segment + 1, - sequence: 0, - text: "", - }); - return; - } - - const sequence = current.sequence + 1; - const replace = now.getTime() - current.lastReplaceAtMs >= this.#previewReplaceIntervalMs; - const update: PreviewUpdate = replace - ? { - channel: input.channel, - itemId: input.itemId, - op: "replace", - segment: current.segment, - streamId: input.channel, - text, - throughSequence: sequence, - } - : { - channel: input.channel, - fromSequence: sequence, - itemId: input.itemId, - op: "append", - segment: current.segment, - streamId: input.channel, - text: input.delta, - throughSequence: sequence, - }; - this.#previewStreams.set(key, { - bytes, - lastReplaceAtMs: replace ? now.getTime() : current.lastReplaceAtMs, - mode: "append", - segment: current.segment, - sequence, - text, - }); - this.#emitPreview(input.runId, update); - }, - ); - } - - async checkpointText(input: CheckpointTextInput): Promise { - return this.#mutate( - authorityKey(input.runId, input.event, input.cause), - input, - async (input) => { - this.#assertActive(); - const item = this.#state.item(input.runId, input.itemId); - - if ( - item === undefined || - item.status !== "active" || - !matchesPreviewChannel(item, input.channel) - ) { - return item; - } - - const key = streamKey(input.runId, input.itemId, input.channel); - const current = this.#previewStreams.get(key); - - if (current === undefined || current.text.length === 0) { - return item; - } - - if (current.mode !== "append") { - throw new Error(`Preview stream ${input.channel} changed update mode.`); - } - - const now = this.#now(); - const checkpoint = flushItemText(this.#previewStreams, item, now.toISOString()); - return this.#putItem(input.runId, input.event, input.cause, checkpoint, input, true); - }, - ); - } - - async replacePreview(input: ReplacePreviewInput): Promise { - return this.#mutate( - (stable) => { - const key = streamKey(stable.runId, stable.itemId, stable.channel); - const segment = (this.#previewStreams.get(key)?.segment ?? 0) + 1; - return authorityKey( - stable.runId, - "preview/replace.checkpoint", - replaceCheckpointCause(stable.runId, stable.itemId, segment), - ); - }, - input, - async (input) => { - this.#assertActive(); - const item = this.#state.item(input.runId, input.itemId); - - if ( - item === undefined || - item.status !== "active" || - !matchesPreviewChannel(item, input.channel) - ) { - return; - } - - const key = streamKey(input.runId, input.itemId, input.channel); - const current = this.#previewStreams.get(key); - if (current !== undefined && current.mode !== "replace") { - throw new Error(`Preview stream ${input.channel} changed update mode.`); - } - - const bytes = this.#textEncoder.encode(input.text).byteLength; - const canCheckpoint = - item.kind === "terminal" && - (input.channel === "terminal.stdout" || input.channel === "terminal.stderr"); - - if (bytes >= this.#previewCheckpointBytes && canCheckpoint) { - const now = this.#now(); - const nextSegment = (current?.segment ?? 0) + 1; - - if (itemText(item, input.channel) === input.text) { - this.#previewStreams.set(key, { - bytes: 0, - lastReplaceAtMs: now.getTime(), - mode: "replace", - segment: nextSegment, - sequence: 0, - text: "", - }); - return; - } - - const checkpoint = replaceItemText( - flushItemText(this.#previewStreams, item, now.toISOString()), - input.channel, - input.text, - now.toISOString(), - ); - await this.#putItem( - input.runId, - "preview/replace.checkpoint", - replaceCheckpointCause(input.runId, input.itemId, nextSegment), - checkpoint, - input, - true, - ); - if (this.#disposed) { - return; - } - this.#previewStreams.set(key, { - bytes: 0, - lastReplaceAtMs: now.getTime(), - mode: "replace", - segment: nextSegment, - sequence: 0, - text: "", - }); - return; - } - - const text = - bytes < this.#previewCheckpointBytes - ? input.text - : truncateUtf8(input.text, this.#previewCheckpointBytes - 1); - const sequence = (current?.sequence ?? 0) + 1; - const update = { - channel: input.channel, - itemId: input.itemId, - op: "replace", - segment: current?.segment ?? 0, - streamId: input.channel, - text, - throughSequence: sequence, - } satisfies PreviewUpdate; - this.#previewStreams.set(key, { - bytes: this.#textEncoder.encode(text).byteLength, - lastReplaceAtMs: this.#now().getTime(), - mode: "replace", - segment: current?.segment ?? 0, - sequence, - text, - }); - this.#emitPreview(input.runId, update); - }, - ); - } - - #emitPreview(runId: string, update: PreviewUpdate): void { - emitContractPreview(this.#preview, runId, this.#sessionId, update); - } - - materializedText(runId: string, itemId: string, channel: TextPreviewChannel): string { - this.#assertActive(); - const item = this.#state.item(runId, itemId); - const committed = item === undefined ? "" : itemText(item, channel); - const preview = this.#previewStreams.get(streamKey(runId, itemId, channel)); - - if (preview?.mode === "replace") { - return preview.sequence === 0 ? committed : preview.text; - } - - return committed + (preview?.text ?? ""); - } - - clearPreviews(runId: string, itemId: string): void { - this.#assertActive(); - const prefix = `${itemKey(runId, itemId)}\u0000`; - - for (const key of this.#previewStreams.keys()) { - if (key.startsWith(prefix)) { - this.#previewStreams.delete(key); - } - } - } - - async finishRun(input: { - readonly activeItemStatus?: "cancelled" | "completed" | undefined; - readonly cause: MutationCause; - readonly endedAt?: string | undefined; - readonly error?: ProtocolError | undefined; - readonly event: string; - readonly finishReason?: "success" | "limit" | "refusal" | "other" | undefined; - readonly terminalItems?: readonly Item[] | undefined; - readonly reason?: string | undefined; - readonly runId: string; - readonly status: "cancelled" | "completed" | "failed"; - }): Promise { - return this.#mutate( - authorityKey(input.runId, input.event, input.cause), - input, - async (input) => { - const current = this.#requireRun(input.runId); - - if (current.status !== "active") { - return; - } - - const terminalItems = (input.terminalItems ?? []).map((value) => itemSchema.parse(value)); - const terminalItemIds = new Set(); - - for (const item of terminalItems) { - if ( - item.runId !== input.runId || - item.status === "active" || - terminalItemIds.has(item.id) - ) { - throw new Error(`Contract projection received an invalid terminal Item ${item.id}.`); - } - - terminalItemIds.add(item.id); - } - - const currentItems = this.items(input.runId); - const requestedEnd = input.endedAt ?? this.#now().toISOString(); - const endedAt = this.interactions(input.runId).reduce( - (latest, interaction) => - latestTimestamp(latest, interaction.endedAt ?? interaction.createdAt), - [ - ...currentItems.filter((item) => !terminalItemIds.has(item.id)), - ...terminalItems, - ].reduce( - (latest, item) => - latestTimestamp( - latest, - latestTimestamp(item.updatedAt, item.endedAt ?? item.updatedAt), - ), - latestTimestamp( - latestTimestamp(current.startedAt, requestedEnd), - this.#state.latestChildEnd(input.runId) ?? current.startedAt, - ), - ), - ); - const error = input.error ?? { - code: "provider.run_failed", - message: "Provider run failed.", - retryable: false, - }; - const items = currentItems.flatMap((item): Item[] => { - if (item.status !== "active" || terminalItemIds.has(item.id)) { - return []; - } - - const withText = flushItemText(this.#previewStreams, item, endedAt); - return [ - itemSchema.parse( - input.status === "completed" - ? { - ...withText, - endedAt, - status: input.activeItemStatus ?? "completed", - updatedAt: endedAt, - } - : input.status === "cancelled" - ? { ...withText, endedAt, status: "cancelled", updatedAt: endedAt } - : { ...withText, endedAt, error, status: "failed", updatedAt: endedAt }, - ), - ]; - }); - const interactions = this.interactions(input.runId).flatMap( - (interaction): Interaction[] => { - if (interaction.status !== "open") { - return []; - } - - return [ - interactionSchema.parse({ - ...interaction, - endedAt, - status: "expired", - }), - ]; - }, - ); - const run = runSchema.parse( - input.status === "completed" - ? { - ...current, - endedAt, - finishReason: input.finishReason ?? "success", - status: "completed", - } - : input.status === "cancelled" - ? { - ...current, - endedAt, - ...(input.reason === undefined ? {} : { reason: input.reason }), - status: "cancelled", - } - : { ...current, endedAt, error, status: "failed" }, - ); - const operations: AuthorityOperation[] = [ - ...terminalItems.map((value) => ({ - entity: "item", - op: "put", - value, - })), - ...items.map((value) => ({ entity: "item", op: "put", value })), - ...interactions.map((value) => ({ - entity: "interaction", - op: "put", - value, - })), - { entity: "run", op: "put", value: run }, - ]; - await this.#commit(input.runId, input.event, input.cause, operations, input, true); - }, - ); - } - - dispose(): void { - this.#disposed = true; - const error = new Error("Contract projection is disposed."); - - for (const mutation of this.#mutationQueue.splice(0)) { - this.#mutationBytes -= mutation.bytes; - mutation.reject(error); - } - - this.#state.clear(); - this.#previewStreams.clear(); - this.#authority.clear(); - } - - #mutate( - key: string | ((intent: I) => string), - intent: I, - operation: (intent: I) => Promise | T, - ): Promise { - this.#assertActive(); - const bytes = jsonByteLength(intent); - const pending = this.#mutationQueue.length + (this.#mutationActive ? 1 : 0); - - if (pending >= MAX_PENDING_MUTATIONS) { - return Promise.reject( - new RangeError( - `Contract projection mutation queue exceeds ${MAX_PENDING_MUTATIONS} entries.`, - ), - ); - } - - if (bytes > MAX_PENDING_MUTATION_BYTES - this.#mutationBytes) { - return Promise.reject( - new RangeError( - `Contract projection mutation queue exceeds ${MAX_PENDING_MUTATION_BYTES} UTF-8 bytes.`, - ), - ); - } - - const stableIntent = structuredClone(intent); - const unknownAtEnqueue = this.#authority.unknown; - const task = Promise.withResolvers(); - this.#mutationQueue.push({ - bytes, - reject: task.reject, - run: async () => { - try { - this.#assertActive(); - const resolvedKey = typeof key === "function" ? key(stableIntent) : key; - this.#authority.assertRetry(resolvedKey, stableIntent, unknownAtEnqueue); - - task.resolve(await operation(stableIntent)); - } catch (error) { - task.reject(error); - } - }, - }); - this.#mutationBytes += bytes; - this.#drainMutations(); - return task.promise; - } - - #drainMutations(): void { - if (this.#mutationActive) { - return; - } - - // ponytail: the 1,024-entry hard cap keeps a native Array FIFO sufficient. - const mutation = this.#mutationQueue.shift(); - if (mutation === undefined) { - return; - } - - this.#mutationActive = true; - void mutation.run().finally(() => { - this.#mutationBytes -= mutation.bytes; - this.#mutationActive = false; - this.#drainMutations(); - }); - } - - async #commit( - runId: string, - event: string, - cause: MutationCause, - operations: readonly AuthorityOperation[], - intent: unknown, - reuseDerived = false, - ): Promise { - this.#assertActive(); - return this.#authority.commit(runId, event, cause, operations, intent, reuseDerived); - } - - #applyAuthorityOperations(operations: readonly AuthorityOperation[]): void { - this.#state.apply(operations, { - activeItem: (item) => - advancePreviews(this.#previewStreams, item.runId, item.id, Date.parse(item.updatedAt)), - clearItem: (runId, itemId) => this.clearPreviews(runId, itemId), - releaseRun: (runId) => { - for (const key of this.#previewStreams.keys()) { - if (key.startsWith(`${runId}\u0000`)) { - this.#previewStreams.delete(key); - } - } - }, - }); - } - - #requireRun(runId: string): Run { - this.#assertActive(); - return this.#state.requireRun(runId); - } - - #assertActive(): void { - if (this.#disposed) { - throw new Error("Contract projection is disposed."); - } - } -} diff --git a/src/runtimes/driver-event-admission.ts b/src/runtimes/driver-event-admission.ts index 6187d4c..ef40638 100644 --- a/src/runtimes/driver-event-admission.ts +++ b/src/runtimes/driver-event-admission.ts @@ -4,8 +4,8 @@ import type { RunId } from "../protocol/id"; const MAX_PENDING_DRIVER_EVENTS = 1_024; const MAX_PENDING_DRIVER_EVENT_BYTES = 32 * 1_024 * 1_024; -const MAX_RUN_TERMINAL_BATCH_EVENTS = 64; -const MAX_RUN_TERMINAL_BATCH_BYTES = 1_024 * 1_024; +export const MAX_RUN_TERMINAL_BATCH_EVENTS = MAX_PENDING_DRIVER_EVENTS; +export const MAX_RUN_TERMINAL_BATCH_BYTES = 1_024 * 1_024; export interface QueuedDriverEvent { readonly bytes: number; @@ -35,6 +35,29 @@ export interface AdmittedDriverEventPush { readonly terminalKey: string | null; } +const EMPTY_DRIVER_EVENT_ADMISSION_STATE: DriverEventAdmissionState = { + pendingLosslessBytes: 0, + pendingLosslessCount: 0, + pendingTerminalBatch: false, + queuedEventBytes: 0, + queuedEventCount: 0, + queuedLosslessBytes: 0, + queuedLosslessCount: 0, + queuedTerminalBatches: 0, +}; + +export function preflightDriverEventPush( + events: readonly DriverEventInput[], + activeRunId: RunId | null, +): void { + admitDriverEventPush( + events, + activeRunId, + Symbol("driver-event-preflight"), + EMPTY_DRIVER_EVENT_ADMISSION_STATE, + ); +} + export function driverEventBatchBytes(bytes: number, count: number): number { return count === 0 ? 0 : bytes + count + 1; } @@ -43,13 +66,28 @@ export function isLosslessDriverEvent(event: DriverEventInput): boolean { return event.delivery !== "best_effort"; } -function isRunTerminal(event: DriverEventInput): boolean { +export function losslessDriverEventRetryKey( + event: DriverEventInput, + activeRunId: RunId | null, +): string | null { + if (!isLosslessDriverEvent(event)) { + return null; + } + + const { sourceEventId: _, ...scoped } = scopeDriverEvent(event, activeRunId); + return JSON.stringify(scoped); +} + +export function isRunTerminalDriverEvent(event: DriverEventInput): boolean { return ( event.kind === "run.cancelled" || event.kind === "run.completed" || event.kind === "run.failed" ); } -function scopeEvent(event: DriverEventInput, activeRunId: RunId | null): DriverEventInput { +export function scopeDriverEvent( + event: DriverEventInput, + activeRunId: RunId | null, +): DriverEventInput { const { runId, sourceEventId, ...content } = event; const frozenRunId = runId === undefined ? activeRunId : runId; const explicitSourceId = @@ -58,7 +96,7 @@ function scopeEvent(event: DriverEventInput, activeRunId: RunId | null): DriverE return { ...content, ...(explicitSourceId === null ? {} : { sourceEventId: explicitSourceId }), - ...(frozenRunId === null ? {} : { runId: frozenRunId }), + ...(runId === null ? { runId: null } : frozenRunId === null ? {} : { runId: frozenRunId }), } as DriverEventInput; } @@ -70,14 +108,13 @@ export function terminalDriverEventRetryKey( if ( losslessEvents.length === 0 || - losslessEvents.length > MAX_RUN_TERMINAL_BATCH_EVENTS || - losslessEvents.filter(isRunTerminal).length !== 1 || - !isRunTerminal(losslessEvents.at(-1)!) + losslessEvents.filter(isRunTerminalDriverEvent).length !== 1 || + !isRunTerminalDriverEvent(losslessEvents.at(-1)!) ) { return null; } - const key = JSON.stringify(losslessEvents.map((event) => scopeEvent(event, activeRunId))); + const key = JSON.stringify(losslessEvents.map((event) => scopeDriverEvent(event, activeRunId))); return Buffer.byteLength(key, "utf8") <= MAX_RUN_TERMINAL_BATCH_BYTES ? key : null; } @@ -88,7 +125,7 @@ export function admitDriverEventPush( state: DriverEventAdmissionState, ): AdmittedDriverEventPush | null { const losslessEvents = events.filter(isLosslessDriverEvent); - const runTerminals = losslessEvents.filter(isRunTerminal); + const runTerminals = losslessEvents.filter(isRunTerminalDriverEvent); const terminalBatch = runTerminals.length > 0; if (runTerminals.length > 1) { @@ -100,19 +137,19 @@ export function admitDriverEventPush( throw new Error("Driver event run terminal slot is full."); } - if (losslessEvents.length > MAX_RUN_TERMINAL_BATCH_EVENTS) { - throw new Error( - `Driver event run terminal batch exceeds ${MAX_RUN_TERMINAL_BATCH_EVENTS} events.`, - ); - } - - if (!isRunTerminal(losslessEvents.at(-1)!)) { + if (!isRunTerminalDriverEvent(losslessEvents.at(-1)!)) { throw new Error("Driver event run terminal must be the final lossless event."); } } const losslessCount = terminalBatch ? 0 : losslessEvents.length; + if (terminalBatch && losslessEvents.length > MAX_RUN_TERMINAL_BATCH_EVENTS) { + throw new Error( + `Driver event run terminal batch exceeds ${MAX_RUN_TERMINAL_BATCH_EVENTS} events.`, + ); + } + if ( state.pendingLosslessCount + state.queuedLosslessCount + losslessCount > MAX_PENDING_DRIVER_EVENTS @@ -141,9 +178,13 @@ export function admitDriverEventPush( admittedEvents = includeBestEffort ? events : losslessEvents; } - const frozenEvents = admittedEvents.map((event) => scopeEvent(event, activeRunId)); - const terminalKey = terminalBatch ? JSON.stringify(frozenEvents) : null; + const frozenEvents = admittedEvents.map((event) => scopeDriverEvent(event, activeRunId)); const stampedEvents = withSourceEventIds(frozenEvents); + + if (new Set(stampedEvents.map((event) => event.sourceEventId)).size !== stampedEvents.length) { + throw new Error("Driver event push requires unique source event IDs."); + } + const serialized: (string | undefined)[] = []; const serializedBytes: (number | undefined)[] = []; let losslessByteSum = 0; @@ -226,6 +267,7 @@ export function admitDriverEventPush( eventCount = serializedLosslessCount; } + const terminalKey = terminalBatch ? JSON.stringify(frozenEvents) : null; const queuedEvents: QueuedDriverEvent[] = []; for (const [index, event] of stampedEvents.entries()) { diff --git a/src/runtimes/driver-event-publisher.ts b/src/runtimes/driver-event-publisher.ts index 495af93..fb4fbf8 100644 --- a/src/runtimes/driver-event-publisher.ts +++ b/src/runtimes/driver-event-publisher.ts @@ -3,24 +3,56 @@ import { DRIVER_EVENT_DELIVERY_TIMEOUT_MS, DriverEventRejectedError, pushLosslessEvents, + withSourceEventIds, } from "../core/driver-runtime-io"; +import { createHash } from "node:crypto"; import { summarizeDriverEventBatch } from "../observability/driver-debug"; import type { DriverEventInput } from "../protocol/events"; import type { RunId } from "../protocol/id"; import type { DriverEventReceipt } from "../protocol/orpc"; import type { DriverRuntime } from "../protocol/runtime"; +import { raceWithAbort } from "../utils/async"; import type { AgentDriverContext } from "../core/agent-driver-backend"; import { admitDriverEventPush, driverEventBatchBytes, isLosslessDriverEvent, + isRunTerminalDriverEvent, + losslessDriverEventRetryKey, + preflightDriverEventPush, + scopeDriverEvent, terminalDriverEventRetryKey, type QueuedDriverEvent, } from "./driver-event-admission"; +interface TerminalSettlement { + readonly acceptedSourceEventIds: Set; + readonly activeRunId: RunId; + readonly cancellationSignal: AbortSignal | null; + readonly events: readonly DriverEventInput[]; + readonly key: string; + signal: AbortSignal; + nextIndex: number; + rejection: unknown | null; + task: Promise | null; +} + +function terminalSettlementKey(events: readonly DriverEventInput[]): string { + const hash = createHash("sha256"); + + for (const event of events) { + const json = JSON.stringify(event); + hash.update(String(Buffer.byteLength(json, "utf8"))); + hash.update(":"); + hash.update(json); + } + + return hash.digest("hex"); +} interface QueuedPush { readonly awaitPending: boolean; readonly context: AgentDriverContext; + readonly deliverySignal: AbortSignal | null; readonly eventBytes: number; readonly eventCount: number; readonly events: QueuedDriverEvent[]; @@ -28,6 +60,7 @@ interface QueuedPush { readonly losslessBytes: number; readonly losslessCount: number; readonly promise: Promise; + readonly retrySourceEventIds: readonly string[]; readonly reason: string; readonly reject: (reason?: unknown) => void; readonly resolve: () => void; @@ -35,10 +68,47 @@ interface QueuedPush { readonly terminalKey: string | null; } +interface PendingAdoption { + readonly events: DriverEventInput[]; + readonly sourceEventIds: string[]; +} + +interface EventSettlement { + readonly promise: Promise; + readonly reject: (reason?: unknown) => void; + readonly resolve: () => void; +} + +class QueuedPushDeliveryError extends Error { + readonly deliveryCause: unknown; + readonly retainedSourceEventIds: readonly string[]; + + constructor(cause: unknown, retainedSourceEventIds: readonly string[]) { + super(cause instanceof Error ? cause.message : "Driver event delivery failed.", { cause }); + this.deliveryCause = cause; + this.name = "QueuedPushDeliveryError"; + this.retainedSourceEventIds = retainedSourceEventIds; + } +} + +export class DriverCompletedTerminalSupersededError extends Error { + constructor(reason: unknown) { + super("Driver completed terminal was superseded by cancellation.", { cause: reason }); + this.name = "DriverCompletedTerminalSupersededError"; + } +} + +function deliveryCause(error: unknown): unknown { + return error instanceof QueuedPushDeliveryError ? error.deliveryCause : error; +} + export class DriverEventPublisher { readonly #getSessionRef: () => string | null; readonly #runtime: DriverRuntime; + #acceptedInFlightSourceEventIds = new Set(); #drainTask: Promise | null = null; + #eventSettlements = new Map(); + #inFlightEvents: readonly QueuedDriverEvent[] | null = null; #lastAcceptedSeq = 0; #pendingEvents: QueuedDriverEvent[] = []; #pendingLosslessBytes = 0; @@ -52,6 +122,8 @@ export class DriverEventPublisher { #queuedLosslessBytes = 0; #queuedLosslessCount = 0; #queuedTerminalBatches = 0; + #rejectedInFlight = new Map(); + #terminalSettlement: TerminalSettlement | null = null; constructor(runtime: DriverRuntime, getSessionRef: () => string | null) { this.#getSessionRef = getSessionRef; @@ -62,40 +134,517 @@ export class DriverEventPublisher { context: AgentDriverContext, reason: string, events: readonly DriverEventInput[], + ): Promise { + if (events.some(isRunTerminalDriverEvent)) { + throw new Error("Driver run terminals must use pushTerminal()."); + } + + try { + return this.#submit(context, reason, events).catch((error: unknown) => { + throw deliveryCause(error); + }); + } catch (error) { + this.#requestPendingDrain(context, reason); + throw deliveryCause(error); + } + } + + async pushSession( + context: AgentDriverContext, + reason: string, + events: readonly DriverEventInput[], + ): Promise { + if (events.some((event) => event.runId !== undefined)) { + throw new Error("Driver session events cannot target a run."); + } + if (events.some(isRunTerminalDriverEvent)) { + throw new Error("Driver run terminals must use pushTerminal()."); + } + + await this.#terminalSettlement?.task; + + const settlement = this.#terminalSettlement; + if (settlement !== null && settlement.nextIndex < settlement.events.length) { + throw new Error("Driver event run terminal settlement slot is full."); + } + + const sessionEvents = events.map((event): DriverEventInput => ({ ...event, runId: null })); + try { + return this.#submit(context, reason, sessionEvents, undefined, true).catch( + (error: unknown) => { + throw deliveryCause(error); + }, + ); + } catch (error) { + this.#requestPendingDrain(context, reason); + throw deliveryCause(error); + } + } + + #submit( + context: AgentDriverContext, + reason: string, + events: readonly DriverEventInput[], + terminalSettlement?: TerminalSettlement, + sessionScoped = false, + deliverySignal = terminalSettlement?.signal, ): Promise { if (events.length === 0) { this.#requestPendingDrain(context, reason); - return; + return Promise.resolve(); + } + + const currentRunId = context.ports.eventSink.currentRunId(); + const activeRunId = sessionScoped ? null : currentRunId; + const explicitRunIds = new Set( + events.flatMap((event) => + event.runId === undefined || event.runId === null ? [] : [event.runId], + ), + ); + + if ( + terminalSettlement === undefined && + [...explicitRunIds].some((runId) => runId !== activeRunId) + ) { + if (events.some(isLosslessDriverEvent)) { + throw new Error("Driver event must target the active run."); + } + + return Promise.resolve(); + } + + const settlement = this.#terminalSettlement; + + if ( + settlement !== null && + settlement !== terminalSettlement && + settlement.nextIndex >= settlement.events.length && + currentRunId !== settlement.activeRunId + ) { + this.#terminalSettlement = null; + } + + const reserved = this.#terminalSettlement; + + if (reserved !== null && reserved !== terminalSettlement && !sessionScoped) { + if (events.some(isLosslessDriverEvent)) { + throw new Error( + `Driver event run terminal settlement slot is full (active=${String(activeRunId)}, reserved=${String(reserved.activeRunId)}, task=${reserved.task === null ? "idle" : "set"}).`, + ); + } + + return Promise.resolve(); + } + + if ( + this.#pendingTerminalKey !== null && + terminalDriverEventRetryKey(events, activeRunId) === this.#pendingTerminalKey + ) { + const retry = this.#createPendingRetry(context, reason, undefined, deliverySignal); + this.#enqueue(retry); + return retry.promise; + } + + if (this.#pendingTerminalBatch || this.#queuedTerminalBatches > 0) { + if (events.some(isLosslessDriverEvent)) { + throw new Error("Driver event run terminal slot is full."); + } + + this.#requestPendingDrain(context, reason); + return Promise.resolve(); + } + + const candidates = [ + ...(this.#inFlightEvents ?? this.#pendingEvents), + ...this.#queue.flatMap((queued) => queued.events), + ]; + const adoption = + terminalSettlement === undefined + ? this.#adoptPending(events, activeRunId, candidates) + : { events: [...events], sourceEventIds: [] }; + const entry = + adoption.events.length === 0 + ? null + : this.#admit(context, reason, adoption.events, activeRunId, deliverySignal); + + if (entry === null && adoption.sourceEventIds.length === 0) { + this.#requestPendingDrain(context, reason); + return Promise.resolve(); + } + + if (entry !== null && terminalSettlement === undefined) { + const unresolvedSourceEventIds = new Set(candidates.map(({ event }) => event.sourceEventId)); + + if (entry.events.some(({ event }) => unresolvedSourceEventIds.has(event.sourceEventId))) { + throw new Error("Driver source event ID conflicts with a pending event."); + } + } + + const deliveries: Promise[] = []; + + if (entry !== null) { + this.#enqueue(entry); + if (entry.losslessCount > 0 || entry.terminalBatch) { + deliveries.push(entry.promise); + } + } + + if (adoption.sourceEventIds.length > 0) { + const acknowledged = Promise.all( + adoption.sourceEventIds.map((sourceEventId) => { + const settlement = this.#eventSettlements.get(sourceEventId); + if (settlement === undefined) { + throw new Error("Driver pending event settlement is missing."); + } + return settlement.promise; + }), + ).then(() => undefined); + const retry = this.#createPendingRetry( + context, + reason, + adoption.sourceEventIds, + deliverySignal, + ); + this.#enqueue(retry); + deliveries.push(Promise.race([acknowledged, retry.promise.then(() => acknowledged)])); } + return deliveries.length === 0 + ? Promise.resolve() + : Promise.all(deliveries).then(() => undefined); + } + + #adoptPending( + events: readonly DriverEventInput[], + activeRunId: RunId | null, + candidates: readonly QueuedDriverEvent[], + ): PendingAdoption { + const adopted = new Set(); + const fresh: DriverEventInput[] = []; + const sourceEventIds: string[] = []; + + for (const event of events) { + if (!isLosslessDriverEvent(event)) { + fresh.push(event); + continue; + } + + const explicitSourceEventId = + typeof event.sourceEventId === "string" && event.sourceEventId.length > 0 + ? event.sourceEventId + : undefined; + if (explicitSourceEventId === undefined) { + fresh.push(event); + continue; + } + const candidateIndex = candidates.findIndex( + ({ event: candidate }) => candidate.sourceEventId === explicitSourceEventId, + ); + + if (candidateIndex < 0) { + fresh.push(event); + continue; + } + + const retryKey = losslessDriverEventRetryKey(event, activeRunId)!; + if (adopted.has(candidateIndex)) { + throw new Error("Driver event push requires unique source event IDs."); + } + + const candidate = candidates[candidateIndex]!.event; + if (losslessDriverEventRetryKey(candidate, activeRunId) !== retryKey) { + throw new Error("Driver source event ID conflicts with a pending event."); + } + + adopted.add(candidateIndex); + sourceEventIds.push(candidate.sourceEventId!); + } + + return { events: fresh, sourceEventIds }; + } + + async #retryPending( + context: AgentDriverContext, + reason: string, + sourceEventIds: readonly string[], + deliverySignal?: AbortSignal, + ): Promise { + const retry = this.#createPendingRetry(context, reason, sourceEventIds, deliverySignal); + this.#enqueue(retry); try { - const activeRunId = context.ports.eventSink.currentRunId?.() ?? null; + await retry.promise; + } catch (error) { + throw deliveryCause(error); + } + } + + pushTerminal( + context: AgentDriverContext, + reason: string, + closures: readonly DriverEventInput[], + terminal: DriverEventInput, + cancellationSignal?: AbortSignal, + ): Promise { + if (!isRunTerminalDriverEvent(terminal)) { + throw new Error("Driver terminal push requires a run terminal event."); + } + + if (!isLosslessDriverEvent(terminal)) { + throw new Error("Driver terminal push requires lossless events."); + } + if (cancellationSignal !== undefined && terminal.kind !== "run.completed") { + throw new Error("Only a completed driver terminal can lose to cancellation."); + } + + for (const closure of closures) { + if (!isLosslessDriverEvent(closure)) { + throw new Error("Driver terminal push requires lossless events."); + } + + if (isRunTerminalDriverEvent(closure)) { + throw new Error("Driver terminal closures cannot contain a run terminal event."); + } + } + + const activeRunId = context.ports.eventSink.currentRunId(); + + if (activeRunId === null) { + throw new Error("Driver terminal push requires an active run."); + } + + if (terminal.runId !== undefined && terminal.runId !== activeRunId) { + throw new Error("Driver terminal push must target the active run."); + } + + const targetRunId = activeRunId; + const settlementEvents = [...closures, terminal]; + + if ( + settlementEvents.some((event) => event.runId !== undefined && event.runId !== targetRunId) + ) { + throw new Error("Driver terminal push requires every event to target the same run."); + } + + preflightDriverEventPush(settlementEvents, targetRunId); + const scopedEvents = settlementEvents.map((event) => scopeDriverEvent(event, targetRunId)); + const key = terminalSettlementKey(scopedEvents); + const current = this.#terminalSettlement; + + if (current !== null) { + if (current.key === key && current.task !== null) { + return current.task; + } + + if (current.key === key) { + current.signal = AbortSignal.timeout(DRIVER_EVENT_DELIVERY_TIMEOUT_MS); + return this.#startTerminalSettlement(context, reason, current); + } + + const settled = current.nextIndex >= current.events.length; + const activeRunChanged = activeRunId !== current.activeRunId; + if (!settled || !activeRunChanged) { + throw new Error("Driver event run terminal settlement slot is full."); + } + + this.#terminalSettlement = null; + } + + cancellationSignal?.throwIfAborted(); + + const retryCandidates = [ + ...(this.#inFlightEvents ?? this.#pendingEvents), + ...this.#queue.flatMap((entry) => entry.events), + ].filter( + ({ event }, index, candidates) => + candidates.findIndex( + ({ event: candidate }) => candidate.sourceEventId === event.sourceEventId, + ) === index, + ); + const adoptedPending = new Set(); + const frozenClosures = scopedEvents.slice(0, -1).map((event) => { + if (event.sourceEventId !== undefined) { + return event; + } + + const retryKey = losslessDriverEventRetryKey(event, targetRunId); + const pendingIndex = retryCandidates.findIndex( + ({ event: pending }, index) => + !adoptedPending.has(index) && + losslessDriverEventRetryKey(pending, targetRunId) === retryKey, + ); + + if (pendingIndex < 0) { + return event; + } + + adoptedPending.add(pendingIndex); + return { + ...event, + sourceEventId: retryCandidates[pendingIndex]!.event.sourceEventId, + }; + }); + const events = structuredClone(withSourceEventIds([...frozenClosures, scopedEvents.at(-1)!])); + + if (new Set(events.map((event) => event.sourceEventId)).size !== events.length) { + throw new Error("Driver terminal push requires unique source event IDs."); + } + + for (const [index, event] of events.entries()) { + const candidate = retryCandidates.find( + ({ event: pending }) => pending.sourceEventId === event.sourceEventId, + ); if ( - this.#pendingTerminalKey !== null && - terminalDriverEventRetryKey(events, activeRunId) === this.#pendingTerminalKey + candidate !== undefined && + (index === events.length - 1 || + losslessDriverEventRetryKey(candidate.event, targetRunId) !== + losslessDriverEventRetryKey(event, targetRunId)) ) { - const retry = this.#createPendingRetry(context, reason); - this.#enqueue(retry); - return retry.promise; + throw new Error("Driver terminal source event ID conflicts with a pending event."); } + } - const entry = this.#admit(context, reason, events, activeRunId); + if (this.#terminalSettlement !== null) { + if (this.#terminalSettlement.key !== key) { + throw new Error("Driver event run terminal settlement slot is full."); + } + } - if (entry === null) { - this.#requestPendingDrain(context, reason); - return; + const rejectedSourceEventId = events.find( + ({ sourceEventId }) => + sourceEventId !== undefined && this.#rejectedInFlight.has(sourceEventId), + )?.sourceEventId; + const settlement: TerminalSettlement = { + acceptedSourceEventIds: new Set( + events + .map((event) => event.sourceEventId!) + .filter((sourceEventId) => this.#acceptedInFlightSourceEventIds.has(sourceEventId)), + ), + activeRunId, + cancellationSignal: cancellationSignal ?? null, + events, + key, + nextIndex: 0, + rejection: + rejectedSourceEventId === undefined + ? null + : this.#rejectedInFlight.get(rejectedSourceEventId)!, + signal: AbortSignal.timeout(DRIVER_EVENT_DELIVERY_TIMEOUT_MS), + task: null, + }; + this.#terminalSettlement = settlement; + return this.#startTerminalSettlement(context, reason, settlement); + } + + #startTerminalSettlement( + context: AgentDriverContext, + reason: string, + settlement: TerminalSettlement, + ): Promise { + const task = this.#deliverTerminalSettlement(context, reason, settlement).catch( + (error: unknown) => { + if (settlement.task === task) { + settlement.task = null; + } + if ( + settlement.cancellationSignal?.aborted === true && + settlement.nextIndex === settlement.events.length - 1 + ) { + this.#abandonCancelledTerminalSettlement(settlement); + throw new DriverCompletedTerminalSupersededError(settlement.cancellationSignal.reason); + } + + throw error; + }, + ); + settlement.task = task; + return task; + } + + async #deliverTerminalSettlement( + context: AgentDriverContext, + reason: string, + settlement: TerminalSettlement, + ): Promise { + const priorDrain = this.#drainTask; + + if (priorDrain !== null) { + await raceWithAbort(priorDrain, settlement.signal); + } + + if (settlement.rejection !== null) { + throw settlement.rejection; + } + + this.#advanceTerminalSettlement(settlement); + + while (settlement.nextIndex < settlement.events.length) { + const terminal = settlement.nextIndex === settlement.events.length - 1; + const event = settlement.events[settlement.nextIndex]!; + const deliveryReason = terminal ? reason : `${reason}.items`; + const retryReason = `${deliveryReason}.retry`; + const deliverySignal = + terminal && settlement.cancellationSignal !== null + ? AbortSignal.any([settlement.signal, settlement.cancellationSignal]) + : settlement.signal; + let delivery: Promise; + + try { + const retained = terminal + ? undefined + : this.#pendingEvents.find( + ({ event: pending }) => pending.sourceEventId === event.sourceEventId, + ); + delivery = + retained === undefined + ? this.#submit(context, deliveryReason, [event], settlement, false, deliverySignal) + : this.#retryPending( + context, + retryReason, + [retained.event.sourceEventId!], + deliverySignal, + ); + } catch (error) { + this.#requestPendingDrain(context, deliveryReason); + throw error; } - this.#enqueue(entry); - return entry.losslessCount === 0 && !entry.terminalBatch ? undefined : entry.promise; - } catch (error) { - this.#requestPendingDrain(context, reason); - throw error; + try { + await delivery; + } catch (error) { + if ( + !(error instanceof QueuedPushDeliveryError) || + error.retainedSourceEventIds.length === 0 + ) { + throw deliveryCause(error); + } + + await this.#retryPending( + context, + retryReason, + error.retainedSourceEventIds, + deliverySignal, + ); + } } } #enqueue(entry: QueuedPush): void { + for (const { event } of entry.events) { + const sourceEventId = event.sourceEventId; + if ( + !isLosslessDriverEvent(event) || + sourceEventId === undefined || + this.#eventSettlements.has(sourceEventId) + ) { + continue; + } + + const settlement = Promise.withResolvers(); + void settlement.promise.catch(() => {}); + this.#eventSettlements.set(sourceEventId, settlement); + } + this.#queue.push(entry); this.#queuedEventBytes += entry.eventBytes; this.#queuedEventCount += entry.eventCount; @@ -109,12 +658,20 @@ export class DriverEventPublisher { return this.#lastAcceptedSeq; } - #createPendingRetry(context: AgentDriverContext, reason: string): QueuedPush { + #createPendingRetry( + context: AgentDriverContext, + reason: string, + sourceEventIds: readonly string[] = this.#pendingEvents.map( + ({ event }) => event.sourceEventId!, + ), + deliverySignal?: AbortSignal, + ): QueuedPush { const deferred = Promise.withResolvers(); return { awaitPending: true, context, + deliverySignal: deliverySignal ?? null, eventBytes: 0, eventCount: 0, events: [], @@ -125,6 +682,7 @@ export class DriverEventPublisher { reason, reject: deferred.reject, resolve: deferred.resolve, + retrySourceEventIds: sourceEventIds, terminalBatch: false, terminalKey: null, }; @@ -135,6 +693,7 @@ export class DriverEventPublisher { reason: string, events: readonly DriverEventInput[], activeRunId: RunId | null, + deliverySignal?: AbortSignal, ): QueuedPush | null { const id = Symbol(reason); const admission = admitDriverEventPush(events, activeRunId, id, { @@ -157,11 +716,13 @@ export class DriverEventPublisher { ...admission, awaitPending: false, context, + deliverySignal: deliverySignal ?? null, id, promise: deferred.promise, reason, reject: deferred.reject, resolve: deferred.resolve, + retrySourceEventIds: [], }; } @@ -215,7 +776,27 @@ export class DriverEventPublisher { context: AgentDriverContext, wakeReason?: string, ): Promise { + const retryDeliverySignals = new Map(); + const deliverySignalsByOwner = new Map(); + + for (const entry of entries) { + if (entry.deliverySignal === null) { + continue; + } + + deliverySignalsByOwner.set(entry.id, entry.deliverySignal); + + if (entry.awaitPending) { + for (const sourceEventId of entry.retrySourceEventIds) { + retryDeliverySignals.set(sourceEventId, entry.deliverySignal); + } + } + } + const queuedEvents = [...this.#pendingEvents, ...entries.flatMap((entry) => entry.events)]; + this.#inFlightEvents = queuedEvents; + this.#acceptedInFlightSourceEventIds = new Set(); + this.#rejectedInFlight = new Map(); const remainingLossless = queuedEvents.filter(({ event }) => isLosslessDriverEvent(event)); const ownerErrors = new Map(); const reason = @@ -252,9 +833,16 @@ export class DriverEventPublisher { } const sameDelivery = queuedEvents.slice(index, end); + const deliverySignal = sameDelivery + .map(({ event, owner }) => + owner === null + ? retryDeliverySignals.get(event.sourceEventId!) + : deliverySignalsByOwner.get(owner), + ) + .find((signal) => signal !== undefined); if (lossless) { - const deadline = AbortSignal.timeout(DRIVER_EVENT_DELIVERY_TIMEOUT_MS); + const deadline = deliverySignal ?? AbortSignal.timeout(DRIVER_EVENT_DELIVERY_TIMEOUT_MS); let remaining = sameDelivery; while (remaining.length > 0) { @@ -265,9 +853,12 @@ export class DriverEventPublisher { context.ports.eventSink, remaining.map(({ event }) => event), (receipts) => { + const acceptedEvents = remaining + .slice(acceptedInAttempt, acceptedInAttempt + receipts.length) + .map(({ event }) => event); acceptedInAttempt += receipts.length; acceptedEventCount += receipts.length; - this.#rememberAcceptedReceipts(receipts); + this.#rememberAcceptedReceipts(receipts, acceptedEvents); remainingLossless.splice(0, receipts.length); }, deadline, @@ -296,6 +887,29 @@ export class DriverEventPublisher { } remainingLossless.splice(retainedIndex, 1); + this.#rejectedInFlight.set(error.sourceEventId, error); + this.#eventSettlements.get(error.sourceEventId)?.reject(error); + + const settlement = this.#terminalSettlement; + + if ( + settlement !== null && + settlement.events.some( + (event) => event.sourceEventId === rejected!.event.sourceEventId, + ) + ) { + settlement.rejection ??= error; + } + + for (const entry of entries) { + if ( + entry.awaitPending && + entry.retrySourceEventIds.includes(error.sourceEventId) && + !ownerErrors.has(entry.id) + ) { + ownerErrors.set(entry.id, error); + } + } if (rejected!.owner !== null && !ownerErrors.has(rejected!.owner)) { ownerErrors.set(rejected!.owner, error); @@ -312,7 +926,10 @@ export class DriverEventPublisher { }); assertDriverEventReceiptPrefix(events, result.accepted); acceptedEventCount += result.accepted.length; - this.#rememberAcceptedReceipts(result.accepted); + this.#rememberAcceptedReceipts( + result.accepted, + events.slice(0, result.accepted.length), + ); } catch { // Best-effort events are intentionally dropped on transport or receipt failure. } @@ -325,6 +942,16 @@ export class DriverEventPublisher { } this.#setPending(remainingLossless, terminalKey); + const pendingSourceEventIds = new Set( + this.#pendingEvents.map(({ event }) => event.sourceEventId), + ); + + for (const { event } of queuedEvents) { + const sourceEventId = event.sourceEventId; + if (sourceEventId !== undefined && !pendingSourceEventIds.has(sourceEventId)) { + this.#eventSettlements.delete(sourceEventId); + } + } context.logger.debug("driver.runtime.events.sent", { acceptedEventCount, @@ -337,10 +964,16 @@ export class DriverEventPublisher { }); for (const entry of entries) { + const retainedSourceEventIds = remainingLossless + .filter( + ({ event, owner }) => + owner === entry.id || + (entry.awaitPending && entry.retrySourceEventIds.includes(event.sourceEventId!)), + ) + .map(({ event }) => event.sourceEventId!); const error = ownerErrors.get(entry.id) ?? - (remainingLossless.some(({ owner }) => owner === entry.id) || - (entry.awaitPending && remainingLossless.some(({ owner }) => owner === null)) + (retainedSourceEventIds.length > 0 ? (deliveryError ?? new Error("Driver event delivery did not settle.")) : null); @@ -353,9 +986,13 @@ export class DriverEventPublisher { if (error === null) { entry.resolve(); } else { - entry.reject(error); + entry.reject(new QueuedPushDeliveryError(error, retainedSourceEventIds)); } } + + this.#inFlightEvents = null; + this.#acceptedInFlightSourceEventIds.clear(); + this.#rejectedInFlight.clear(); } #setPending(events: readonly QueuedDriverEvent[], terminalKey: string | null): void { @@ -379,9 +1016,87 @@ export class DriverEventPublisher { this.#pendingTerminalKey = terminalBatch ? terminalKey : null; } - #rememberAcceptedReceipts(receipts: readonly DriverEventReceipt[]): void { + #rememberAcceptedReceipts( + receipts: readonly DriverEventReceipt[], + events: readonly DriverEventInput[], + ): void { for (const receipt of receipts) { this.#lastAcceptedSeq = Math.max(this.#lastAcceptedSeq, receipt.seq); } + + for (const event of events) { + if (event.sourceEventId !== undefined) { + this.#acceptedInFlightSourceEventIds.add(event.sourceEventId); + this.#eventSettlements.get(event.sourceEventId)?.resolve(); + } + } + + const settlement = this.#terminalSettlement; + + if (settlement === null) { + return; + } + + for (const event of events) { + const sourceEventId = event.sourceEventId; + + if ( + sourceEventId !== undefined && + settlement.events.some((candidate) => candidate.sourceEventId === sourceEventId) + ) { + settlement.acceptedSourceEventIds.add(sourceEventId); + } + } + + this.#advanceTerminalSettlement(settlement); + } + + #advanceTerminalSettlement(settlement: TerminalSettlement): void { + while ( + settlement.acceptedSourceEventIds.delete( + settlement.events[settlement.nextIndex]?.sourceEventId ?? "", + ) + ) { + settlement.nextIndex += 1; + } + } + + #abandonCancelledTerminalSettlement(settlement: TerminalSettlement): void { + if (this.#terminalSettlement !== settlement) { + return; + } + if ( + settlement.task !== null || + settlement.nextIndex !== settlement.events.length - 1 || + settlement.acceptedSourceEventIds.size > 0 + ) { + throw new Error("Cannot abandon a selected driver run terminal."); + } + + const sourceEventId = settlement.events.at(-1)!.sourceEventId!; + if ( + this.#inFlightEvents?.some(({ event }) => event.sourceEventId === sourceEventId) === true || + this.#queue.some((entry) => + entry.events.some(({ event }) => event.sourceEventId === sourceEventId), + ) + ) { + throw new Error("Cannot abandon an in-flight driver terminal batch."); + } + + const pending = this.#pendingEvents.filter( + ({ event }) => event.sourceEventId !== sourceEventId, + ); + if (pending.some(({ terminalLane }) => terminalLane)) { + throw new Error("Cannot abandon a driver terminal while another terminal is pending."); + } + + this.#eventSettlements + .get(sourceEventId) + ?.reject( + settlement.cancellationSignal?.reason ?? new Error("Driver terminal was cancelled."), + ); + this.#eventSettlements.delete(sourceEventId); + this.#setPending(pending, null); + this.#terminalSettlement = null; } } diff --git a/src/runtimes/mcp/remote-http-mcp-executor.ts b/src/runtimes/mcp/remote-http-mcp-executor.ts index f11e607..e3f169d 100644 --- a/src/runtimes/mcp/remote-http-mcp-executor.ts +++ b/src/runtimes/mcp/remote-http-mcp-executor.ts @@ -1,29 +1,90 @@ import { Client, + InsufficientScopeError, + LATEST_PROTOCOL_VERSION, ProtocolError, ProtocolErrorCode, SdkError, SdkErrorCode, + SdkHttpError, StreamableHTTPClientTransport, + UnauthorizedError, } from "@modelcontextprotocol/client"; import type { AuthProvider, CallToolResult } from "@modelcontextprotocol/client"; import { AGENT_DRIVER_VERSION } from "../../core/version"; +import { AGENT_DRIVER_MCP_EXECUTE_TIMEOUT_MS } from "../../host-ports"; +import type { AgentDriverMcpExecution } from "../../host-ports"; +import type { Logger } from "../../observability"; import type { DriverStartInput } from "../../protocol/start"; -import type { - McpExecuteCommand, - McpExternalToolEffectExecution, - McpExternalToolExecutionResult, -} from "../../runtime-command"; +import type { McpExecuteCommand, McpExternalToolExecutionResult } from "../../runtime-command"; import { settlePromiseWithTimeout } from "../../utils/async"; type SessionMcpServer = DriverStartInput["execution"]["session"]["mcpServers"][number]; type ActiveMcpServer = Extract; -const MCP_REQUEST_TIMEOUT_MS = 60_000; +const MCP_CONNECT_TIMEOUT_MS = 60_000; const MCP_CLEANUP_TIMEOUT_MS = 2_000; +const MCP_RESPONSE_MAX_BYTES = 8 * 1_024 * 1_024; const MOSOO_TOOL_CALL_ID_HEADER = "X-Mosoo-Tool-Call-Id"; +class McpResponseTooLargeError extends RangeError { + constructor() { + super(`MCP response exceeds ${String(MCP_RESPONSE_MAX_BYTES)} bytes.`); + this.name = "McpResponseTooLargeError"; + } +} + +async function boundedMcpFetch( + input: string | URL | Request, + init?: RequestInit, +): Promise { + const response = await fetch(input, init); + const contentLength = response.headers.get("content-length"); + + if (contentLength !== null && Number(contentLength) > MCP_RESPONSE_MAX_BYTES) { + void response.body?.cancel().catch(() => {}); + const body = + response.body === null + ? null + : new ReadableStream({ + start(controller) { + controller.error(new McpResponseTooLargeError()); + }, + }); + + return new Response(body, { + headers: response.headers, + status: response.status, + statusText: response.statusText, + }); + } + if (response.body === null) { + return response; + } + + let bytes = 0; + const body = response.body.pipeThrough( + new TransformStream({ + transform(chunk, controller) { + if (chunk.byteLength > MCP_RESPONSE_MAX_BYTES - bytes) { + controller.error(new McpResponseTooLargeError()); + return; + } + + bytes += chunk.byteLength; + controller.enqueue(chunk); + }, + }), + ); + + return new Response(body, { + headers: response.headers, + status: response.status, + statusText: response.statusText, + }); +} + function parseToolArguments(command: McpExecuteCommand): Record { try { const parsed: unknown = JSON.parse(command.argumentsJson); @@ -77,21 +138,26 @@ function normalizeCallToolResult( result: CallToolResult, command: McpExecuteCommand, ): McpExternalToolExecutionResult { - const textContent = result.content - .flatMap((block) => (block.type === "text" ? [block.text] : [])) - .map((text) => text.trim()) - .filter((text) => text.length > 0); - - const outputText = - textContent.length > 0 - ? textContent.join("\n\n") - : result.structuredContent !== undefined - ? JSON.stringify(result.structuredContent, null, 2) - : result.content.length > 0 - ? JSON.stringify(result.content, null, 2) - : result.isError === true - ? `MCP tool ${command.toolName} reported an error without textual details.` - : ""; + const content = result.content[0]; + const plainText = + result.content.length === 1 && + content?.type === "text" && + Object.keys(content).every((key) => key === "type" || key === "text"); + const hasRichContent = !plainText || result.structuredContent !== undefined; + + const outputText = hasRichContent + ? JSON.stringify( + { + content: result.content, + ...(result.isError === undefined ? {} : { isError: result.isError }), + ...(result.structuredContent === undefined + ? {} + : { structuredContent: result.structuredContent }), + }, + null, + 2, + ) + : content.text; return { ...(result.isError === undefined ? {} : { isError: result.isError }), @@ -108,25 +174,64 @@ function mapMcpExecutionError( server: ActiveMcpServer, error: unknown, ): Error { - if (error instanceof SdkError) { - switch (error.code) { - case SdkErrorCode.ClientHttpAuthentication: { + if (error instanceof SdkHttpError) { + switch (error.status) { + case 400: + return new Error( + `MCP server ${server.name} rejected the request for ${command.toolName} (HTTP 400).`, + ); + case 401: return new Error( `MCP authorization for ${server.name} is no longer valid. Refresh or reconnect the credential and retry.`, ); - } - case SdkErrorCode.ClientHttpForbidden: { + case 403: return new Error( `MCP server ${server.name} rejected the credential for ${command.toolName}. Access may have been revoked.`, ); - } + case 404: + return new Error(`MCP HTTP endpoint for ${server.name} was not found.`); + case 405: + return new Error(`MCP server ${server.name} does not support HTTP tool execution.`); + case 408: + return new Error(`Timed out while calling MCP tool ${command.toolName} on ${server.name}.`); + case 409: + return new Error( + `MCP server ${server.name} reported a conflict while calling ${command.toolName}. Retry the request.`, + ); + case 429: + return new Error( + `MCP server ${server.name} rate limited ${command.toolName}. Retry the request later.`, + ); + default: + return error.status >= 500 + ? new Error( + `MCP server ${server.name} failed while calling ${command.toolName} (HTTP ${error.status}).`, + ) + : new Error( + `MCP server ${server.name} rejected ${command.toolName} with HTTP ${error.status}.`, + ); + } + } + + if (error instanceof UnauthorizedError) { + return new Error( + `MCP authorization for ${server.name} is no longer valid. Refresh or reconnect the credential and retry.`, + ); + } + + if (error instanceof InsufficientScopeError) { + return new Error( + `MCP credential for ${server.name} lacks the access required for ${command.toolName}. Reauthorize the credential and retry.`, + ); + } + + if (error instanceof SdkError) { + switch (error.code) { case SdkErrorCode.RequestTimeout: { return new Error(`Timed out while calling MCP tool ${command.toolName} on ${server.name}.`); } case SdkErrorCode.ConnectionClosed: case SdkErrorCode.SendFailed: - case SdkErrorCode.ClientHttpFailedToOpenStream: - case SdkErrorCode.ClientHttpUnexpectedContent: case SdkErrorCode.NotConnected: { return new Error(`Failed to reach MCP server ${server.name}: ${error.message}`); } @@ -135,12 +240,8 @@ function mapMcpExecutionError( `MCP server ${server.name} does not support the requested capability for ${command.toolName}.`, ); } - case SdkErrorCode.ClientHttpNotImplemented: { - return new Error(`MCP server ${server.name} does not support HTTP tool execution.`); - } case SdkErrorCode.AlreadyConnected: - case SdkErrorCode.NotInitialized: - case SdkErrorCode.ClientHttpFailedToTerminateSession: { + case SdkErrorCode.NotInitialized: { return new Error(`MCP client state error for ${server.name}: ${error.message}`); } default: { @@ -164,20 +265,6 @@ function mapMcpExecutionError( } if (error instanceof Error) { - const lowered = error.message.toLowerCase(); - - if (lowered.includes("unauthorized") || lowered.includes("401")) { - return new Error( - `MCP authorization for ${server.name} is no longer valid. Refresh or reconnect the credential and retry.`, - ); - } - - if (lowered.includes("forbidden") || lowered.includes("403")) { - return new Error( - `MCP server ${server.name} rejected the credential for ${command.toolName}. Access may have been revoked.`, - ); - } - return new Error( `Failed to execute MCP tool ${command.toolName} on ${server.name}: ${error.message}`, ); @@ -186,63 +273,217 @@ function mapMcpExecutionError( return new Error(`Failed to execute MCP tool ${command.toolName} on ${server.name}.`); } -export async function executeRemoteHttpMcpCommand( +function cleanupFailureMessage( + result: Awaited>, +): string { + if (result.status === "completed") { + return "Cleanup completed."; + } + + return result.error instanceof Error ? result.error.message : "Unknown cleanup failure."; +} + +function createMcpTransport( + proxyUrl: URL, + authProvider: AuthProvider, + command: McpExecuteCommand, + session?: { readonly protocolVersion?: string; readonly sessionId: string }, +): StreamableHTTPClientTransport { + return new StreamableHTTPClientTransport(proxyUrl, { + authProvider, + fetch: boundedMcpFetch, + requestInit: { + headers: { [MOSOO_TOOL_CALL_ID_HEADER]: command.toolCallId }, + }, + ...session, + }); +} + +async function closeMcpClient( + client: Client, + logger: Logger, + command: McpExecuteCommand, +): Promise { + const close = await settlePromiseWithTimeout( + Promise.resolve().then(() => client.close()), + { + label: "MCP client close", + timeoutMs: MCP_CLEANUP_TIMEOUT_MS, + }, + ); + if (close.status !== "completed") { + logger.warn("driver.mcp.client-close.failed", { + commandId: command.commandId, + message: cleanupFailureMessage(close), + serverId: command.serverId, + status: close.status, + }); + } +} + +async function terminateFailedConnectionSession( + proxyUrl: URL, + authProvider: AuthProvider, + session: { readonly protocolVersion?: string; readonly sessionId: string }, + logger: Logger, + command: McpExecuteCommand, +): Promise { + const cleanupTransport = createMcpTransport(proxyUrl, authProvider, command, session); + const termination = await settlePromiseWithTimeout( + Promise.resolve().then(async () => { + await cleanupTransport.start(); + await cleanupTransport.terminateSession(); + }), + { + label: "Failed MCP connection session termination", + timeoutMs: MCP_CLEANUP_TIMEOUT_MS, + }, + ); + const close = await settlePromiseWithTimeout( + Promise.resolve().then(() => cleanupTransport.close()), + { + label: "Failed MCP connection cleanup transport close", + timeoutMs: MCP_CLEANUP_TIMEOUT_MS, + }, + ); + + if (termination.status !== "completed") { + logger.warn("driver.mcp.session-termination.failed", { + commandId: command.commandId, + message: cleanupFailureMessage(termination), + serverId: command.serverId, + status: termination.status, + }); + } + if (close.status !== "completed") { + logger.warn("driver.mcp.cleanup-transport-close.failed", { + commandId: command.commandId, + message: cleanupFailureMessage(close), + serverId: command.serverId, + status: close.status, + }); + } +} + +async function closeMcpConnection( + client: Client, + transport: StreamableHTTPClientTransport, + logger: Logger, + command: McpExecuteCommand, +): Promise { + let termination = await settlePromiseWithTimeout( + Promise.resolve().then(() => transport.terminateSession()), + { + label: "MCP session termination", + timeoutMs: MCP_CLEANUP_TIMEOUT_MS, + }, + ); + + if (termination.status === "failed") { + termination = await settlePromiseWithTimeout( + Promise.resolve().then(() => transport.terminateSession()), + { + label: "MCP session termination retry", + timeoutMs: MCP_CLEANUP_TIMEOUT_MS, + }, + ); + } + if (termination.status !== "completed") { + logger.warn("driver.mcp.session-termination.failed", { + commandId: command.commandId, + message: cleanupFailureMessage(termination), + serverId: command.serverId, + status: termination.status, + }); + } + await closeMcpClient(client, logger, command); +} + +export async function prepareRemoteHttpMcpCommand( payload: DriverStartInput, command: McpExecuteCommand, signal: AbortSignal, - effect: McpExternalToolEffectExecution, -): Promise { + logger: Logger, +): Promise { signal.throwIfAborted(); const server = resolveActiveMcpServer(payload, command); const argumentsObject = parseToolArguments(command); + const proxyUrl = new URL(server.proxyUrl); const authProvider: AuthProvider = { token: async () => server.proxyGrantId, }; - const client = new Client({ - name: "mosoo-driver", - version: AGENT_DRIVER_VERSION, - }); - const transport = new StreamableHTTPClientTransport(new URL(server.proxyUrl), { - authProvider, - requestInit: { - headers: { [MOSOO_TOOL_CALL_ID_HEADER]: command.toolCallId }, + const client = new Client( + { + name: "mosoo-driver", + version: AGENT_DRIVER_VERSION, }, - }); - const requestSignal = AbortSignal.any([signal, AbortSignal.timeout(MCP_REQUEST_TIMEOUT_MS)]); + { + versionNegotiation: { mode: "auto" }, + }, + ); + const transport = createMcpTransport(proxyUrl, authProvider, command); + const connectSignal = AbortSignal.any([signal, AbortSignal.timeout(MCP_CONNECT_TIMEOUT_MS)]); try { await client.connect(transport, { - signal: requestSignal, - timeout: MCP_REQUEST_TIMEOUT_MS, + signal: connectSignal, + timeout: MCP_CONNECT_TIMEOUT_MS, }); - const result = await client.callTool( - { - _meta: { - "io.mosoo/idempotency-key": effect.idempotencyKey, - }, - arguments: argumentsObject, - name: command.toolName, - }, - { - signal: requestSignal, - timeout: MCP_REQUEST_TIMEOUT_MS, - }, - ); - - return normalizeCallToolResult(result, command); } catch (error) { - throw mapMcpExecutionError(command, server, error); - } finally { - if (!requestSignal.aborted) { - await settlePromiseWithTimeout(transport.terminateSession(), { - label: "MCP session termination", - timeoutMs: MCP_CLEANUP_TIMEOUT_MS, - }); - } + const failedSession = + transport.sessionId === undefined + ? undefined + : { + protocolVersion: transport.protocolVersion ?? LATEST_PROTOCOL_VERSION, + sessionId: transport.sessionId, + }; - await settlePromiseWithTimeout(client.close(), { - label: "MCP client close", - timeoutMs: MCP_CLEANUP_TIMEOUT_MS, - }); + await closeMcpClient(client, logger, command); + if (failedSession !== undefined) { + await terminateFailedConnectionSession( + proxyUrl, + authProvider, + failedSession, + logger, + command, + ); + } + throw mapMcpExecutionError(command, server, error); } + + let disposeTask: Promise | null = null; + let executed = false; + + return { + async execute(effect): Promise { + if (disposeTask !== null || executed) { + throw new Error(`Prepared MCP command ${command.commandId} can only be executed once.`); + } + executed = true; + const requestSignal = AbortSignal.timeout(AGENT_DRIVER_MCP_EXECUTE_TIMEOUT_MS); + + try { + const result = await client.callTool( + { + _meta: { + "io.mosoo/idempotency-key": effect.idempotencyKey, + }, + arguments: argumentsObject, + name: command.toolName, + }, + { + signal: requestSignal, + timeout: AGENT_DRIVER_MCP_EXECUTE_TIMEOUT_MS, + }, + ); + + return normalizeCallToolResult(result, command); + } catch (error) { + throw mapMcpExecutionError(command, server, error); + } + }, + async [Symbol.asyncDispose](): Promise { + return (disposeTask ??= closeMcpConnection(client, transport, logger, command)); + }, + }; } diff --git a/src/runtimes/mcp/server-key.ts b/src/runtimes/mcp/server-key.ts index 1415854..8c2109f 100644 --- a/src/runtimes/mcp/server-key.ts +++ b/src/runtimes/mcp/server-key.ts @@ -1,7 +1,10 @@ import type { DriverBootMcpServer } from "../../protocol/boot"; +const UNSAFE_OBJECT_KEYS = new Set(["__proto__", "constructor", "prototype"]); + export function toMcpServerKey(server: DriverBootMcpServer, usedNames: Set): string { - const baseName = server.name.trim() || server.serverId; + const name = server.name.trim(); + const baseName = name.length === 0 || UNSAFE_OBJECT_KEYS.has(name) ? server.serverId : name; let candidate = baseName; let suffix = 2; diff --git a/src/runtimes/openai/app-server-agent-task-events.ts b/src/runtimes/openai/app-server-agent-task-events.ts new file mode 100644 index 0000000..722ca65 --- /dev/null +++ b/src/runtimes/openai/app-server-agent-task-events.ts @@ -0,0 +1,156 @@ +import type { DriverEventInput } from "../../protocol/events"; +import { toRuntimePublicId } from "../runtime-public-id"; +import { + assertOpenAiDurableEventFits, + MAX_OPENAI_DURABLE_EVENT_BYTES, +} from "./app-server-event-state"; + +const MAX_OPENAI_ACTIVE_AGENT_TASKS = 1_024; +const MAX_OPENAI_CLOSED_AGENT_TASKS = 1_024; +const MAX_OPENAI_VISIBLE_AGENT_TASKS = 256; +const MAX_OPENAI_AGENT_TASK_TEXT_LENGTH = 4_096; +const OPENAI_AGENT_TASK_EVENT_ENVELOPE_RESERVE_BYTES = 1_024; + +export interface OpenAiSubAgentActivity { + readonly agentId: string; + readonly agentPath: string; + readonly kind: "completed" | "interacted" | "interrupted" | "started"; +} + +interface OpenAiAgentTask { + readonly taskId: string; + readonly taskType?: string; + readonly title?: string; +} + +interface OpenAiAgentTaskUpdate { + readonly commit: () => void; + readonly events: DriverEventInput[]; +} + +export function toOpenAiAgentTaskId(nativeTaskId: string): string { + return toRuntimePublicId(nativeTaskId, "openai-thread"); +} + +function boundedTaskText(value: string): string | undefined { + const text = value.slice(0, MAX_OPENAI_AGENT_TASK_TEXT_LENGTH); + const finalCodeUnit = text.charCodeAt(text.length - 1); + const bounded = finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff ? text.slice(0, -1) : text; + return bounded.length === 0 ? undefined : bounded; +} + +function taskSnapshot(tasks: readonly OpenAiAgentTask[]): DriverEventInput { + return { + delivery: "lossless", + kind: "agent.tasks.replaced", + payload: { tasks }, + visibility: "participant", + }; +} + +function taskSnapshotDiagnostic( + code: string, + taskCount: number, + message: string, +): DriverEventInput { + const event: DriverEventInput = { + delivery: "best_effort", + kind: "diagnostic.reported", + payload: { + code, + details: { taskCount }, + message, + severity: "warn", + source: "openai", + }, + visibility: "owner_debug", + }; + assertOpenAiDurableEventFits(event, "sub-agent task snapshot diagnostic"); + return event; +} + +export function openAiAgentTasksClosedEvent(): DriverEventInput { + return taskSnapshot([]); +} + +export class OpenAiAgentTaskState { + #closedTaskIds = new Set(); + #tasks = new Map(); + + prepare(activity: OpenAiSubAgentActivity): OpenAiAgentTaskUpdate { + const closedTaskIds = new Set(this.#closedTaskIds); + const tasks = new Map(this.#tasks); + const taskId = toOpenAiAgentTaskId(activity.agentId); + + if (activity.kind === "interacted") { + closedTaskIds.delete(taskId); + this.#upsertTask(tasks, taskId, activity.agentPath); + } else if (activity.kind === "started") { + if (!closedTaskIds.has(taskId)) { + this.#upsertTask(tasks, taskId, activity.agentPath); + } + } else { + tasks.delete(taskId); + if (!closedTaskIds.has(taskId) && closedTaskIds.size === MAX_OPENAI_CLOSED_AGENT_TASKS) { + throw new RangeError("OpenAI closed sub-agent count exceeds 1024."); + } + closedTaskIds.add(taskId); + } + + let snapshot = taskSnapshot([...tasks.values()]); + const events: DriverEventInput[] = []; + + if (tasks.size > MAX_OPENAI_VISIBLE_AGENT_TASKS) { + events.push( + taskSnapshotDiagnostic( + "openai.visible_agent_tasks_too_many", + tasks.size, + "OpenAI active sub-agent count exceeded the supported snapshot size.", + ), + ); + } else if ( + Buffer.byteLength(JSON.stringify(snapshot), "utf8") > + MAX_OPENAI_DURABLE_EVENT_BYTES - OPENAI_AGENT_TASK_EVENT_ENVELOPE_RESERVE_BYTES + ) { + snapshot = taskSnapshot([...tasks.values()].map(({ taskId: id }) => ({ taskId: id }))); + assertOpenAiDurableEventFits(snapshot, "sub-agent task membership snapshot"); + events.push( + taskSnapshotDiagnostic( + "openai.agent_tasks_snapshot_too_large", + tasks.size, + "OpenAI sub-agent task metadata exceeded the supported snapshot size.", + ), + snapshot, + ); + } else { + assertOpenAiDurableEventFits(snapshot, "sub-agent task snapshot"); + events.push(snapshot); + } + + return { + commit: () => { + this.#closedTaskIds = closedTaskIds; + this.#tasks = tasks; + }, + events, + }; + } + + reset(): void { + this.#closedTaskIds.clear(); + this.#tasks.clear(); + } + + #upsertTask(tasks: Map, taskId: string, agentPath: string): void { + if (!tasks.has(taskId) && tasks.size === MAX_OPENAI_ACTIVE_AGENT_TASKS) { + throw new RangeError("OpenAI active sub-agent count exceeds 1024."); + } + + const title = boundedTaskText(agentPath); + tasks.set(taskId, { + taskId, + taskType: "openai_subagent", + ...(title === undefined ? {} : { title }), + }); + } +} diff --git a/src/runtimes/openai/app-server-client.ts b/src/runtimes/openai/app-server-client.ts index 3a1e7c2..a4ae68c 100644 --- a/src/runtimes/openai/app-server-client.ts +++ b/src/runtimes/openai/app-server-client.ts @@ -1,7 +1,6 @@ import { spawn } from "node:child_process"; import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { once } from "node:events"; -import { mkdir } from "node:fs/promises"; import { createInterface } from "node:readline"; import type { Interface as ReadlineInterface } from "node:readline"; import { Transform } from "node:stream"; @@ -24,16 +23,12 @@ import { } from "../child-process"; import type { BoundSpawnedProcess } from "../child-process"; import { summarizeOpenAiProxyEnv } from "./app-server-env"; -import { - isRecord, - readNonEmptyString, - readRecord, - readString, - toJsonRpcId, -} from "./app-server-json"; +import { isRecord, readNonEmptyString, toJsonRpcId } from "./app-server-json"; import type { JsonObject } from "./app-server-json"; import { - materializeOpenAiApiKeyAuthState, + cleanupOpenAiRuntimeHome, + createOpenAiRuntimeHome, + materializeOpenAiAuthState, materializeOpenAiModelProviderConfig, } from "./auth-state"; import type { @@ -42,20 +37,28 @@ import type { ClientRequestResult, RequestId, ServerNotificationMethod, - ServerNotificationParams, -} from "./generated/app-server-protocol"; +} from "./app-server-protocol"; import { - CLIENT_REQUEST_RESULT_PARSERS, + CLIENT_RESULT_SCHEMAS, isServerNotificationMethod, isServerRequestMethod, - parseServerNotificationParams, -} from "./generated/app-server-protocol"; + parseServerNotification, + parseServerRequest, +} from "./app-server-protocol"; import { buildOpenAiMcpServerConfig } from "./mcp-config"; import { OpenAiAppServerRequestHandler } from "./app-server-request-handler"; +import { jsonRpcResponseSchema } from "./app-server-protocol-client-schemas"; +import { toOpenAiProtocolError } from "./app-server-event-mapping"; interface PendingJsonRpcRequest { + accept: ((value: unknown) => Promise) | null; method: string; + parse(value: unknown): unknown; + receiveAtWire: ((value: unknown) => void) | null; reject(error: Error): void; + rejectAtBarrier: ((error: Error) => Promise) | null; + releaseAtWire: (() => void) | null; + responseQueued: boolean; resolve(value: unknown): void; } @@ -68,12 +71,12 @@ interface OpenAiAppServerClientStartResult { readonly phases: readonly OpenAiAppServerClientStartPhase[]; } +type OpenAiRuntimeHomeState = Awaited>; + interface OpenAiClientContext extends AgentDriverContext { - handleNotification( - method: M, - params: ServerNotificationParams[M], - ): Promise; + handleNotification(method: ServerNotificationMethod, params: JsonObject): Promise; handleProtocolError(error: Error): Promise; + mapToolCallId(toolCallId: string): string; } function summarizeJsonRpcErrorData(value: unknown): JsonObject | null { @@ -114,6 +117,7 @@ function summarizeJsonRpcErrorData(value: unknown): JsonObject | null { } const OPENAI_RUNTIME_HOME_ENV_NAME = "CODEX_HOME"; +const OPENAI_SQLITE_HOME_ENV_NAME = "CODEX_SQLITE_HOME"; const DEFAULT_OPENAI_RUNTIME_EXECUTABLE = "codex"; const APP_SERVER_TERMINATE_TIMEOUT_MS = 2_000; const APP_SERVER_KILL_TIMEOUT_MS = 1_000; @@ -203,7 +207,11 @@ export class OpenAiAppServerClient { #processTarget: BoundSpawnedProcess | null = null; #processClosed: Promise | null = null; #processTreeMarker: string | null = null; + #protocolFailureCommit: Promise | null = null; + #protocolFailureTask: Promise | null = null; #readline: ReadlineInterface | null = null; + #runtimeHomeSetup: Promise | null = null; + #runtimeHomeState: OpenAiRuntimeHomeState | null = null; #serverMessageQueue: Promise = Promise.resolve(); #serverMessagesPaused = false; #startRequested = false; @@ -215,8 +223,11 @@ export class OpenAiAppServerClient { this.#context = context; this.#requestHandler = new OpenAiAppServerRequestHandler({ context, - handleError: async (error) => this.#failProtocol(error), + handleError: async (error) => { + void this.#failProtocol(error); + }, isStopped: () => this.#stopRequested, + mapToolCallId: context.mapToolCallId, respond: (id, result) => this.respond(id, result), respondError: (id, message) => this.respondError(id, message), }); @@ -229,36 +240,54 @@ export class OpenAiAppServerClient { } this.#startRequested = true; + try { + return await this.#performStart(signal); + } catch (error) { + try { + await this.stop(); + } catch (cleanupError) { + const startupMessage = error instanceof Error ? error.message : "unknown error"; + throw new AggregateError( + [error, cleanupError], + `OpenAi app-server startup failed (${startupMessage}) and cleanup also failed.`, + ); + } + throw error; + } + } + + async #performStart(signal?: AbortSignal): Promise { const mcpConfig = buildOpenAiMcpServerConfig(this.#payload.execution.session.mcpServers); - const processTree = createProcessTreeEnvironment( - buildRuntimeChildProcessEnv(this.#payload.execution.environment.paths, { - ...process.env, - ...this.#payload.execution.environment.variables, - ...mcpConfig.env, - [OPENAI_RUNTIME_HOME_ENV_NAME]: this.#payload.execution.session.homePath, - LOG_FORMAT: "json", - }), - ); - const env = processTree.env; - this.#processTreeMarker = processTree.marker; + const env = buildRuntimeChildProcessEnv(this.#payload.execution.environment.paths, { + ...process.env, + ...this.#payload.execution.environment.variables, + ...mcpConfig.env, + LOG_FORMAT: "json", + }); const onAbort = () => { this.#stopRequested = true; const child = this.#process; const target = this.#processTarget; + const marker = this.#processTreeMarker; - if (child !== null && target !== null) { - signalAppServerSession(target, processTree.marker, "SIGKILL"); + if (child !== null && target !== null && marker !== null) { + signalAppServerSession(target, marker, "SIGKILL"); } }; signal?.addEventListener("abort", onAbort, { once: true }); const phases: OpenAiAppServerClientStartPhase[] = []; - const measure = async (name: string, task: () => Promise): Promise => { + const measure = async ( + name: string, + task: () => Promise, + abortable = true, + ): Promise => { const startedAtMs = Date.now(); try { signal?.throwIfAborted(); - return await raceWithAbort(task(), signal); + const result = task(); + return await (abortable ? raceWithAbort(result, signal) : result); } finally { phases.push({ durationMs: toDurationMs(startedAtMs), @@ -266,25 +295,57 @@ export class OpenAiAppServerClient { }); } }; - const { homePath } = this.#payload.execution.session; - const runtimeHome = homePath; - await measure("app_server.home.mkdir", () => mkdir(runtimeHome, { recursive: true })); - - const authState = await measure("app_server.auth_state", () => - materializeOpenAiApiKeyAuthState({ - runtimeHome, - env, - }), - ); - const modelProviderConfig = await measure("app_server.config", () => - materializeOpenAiModelProviderConfig({ - env, - mcpServers: mcpConfig.mcpServers, - provider: this.#payload.execution.provider, - providerOptions: this.#payload.execution.providerOptions, - runtimeHome, - }), + const persistentRuntimeHome = this.#payload.execution.session.homePath; + const setupTask = (async () => { + const runtimeHomeState = await measure( + "app_server.home.create", + async () => { + const state = await createOpenAiRuntimeHome({ + driverGeneration: this.#payload.driverGeneration, + driverInstanceId: this.#payload.driverInstanceId, + persistentRuntimeHome, + ...(signal === undefined ? {} : { signal }), + }); + this.#runtimeHomeState = state; + return state; + }, + false, + ); + signal?.throwIfAborted(); + const { persistentRuntimeHome: sqliteHome, runtimeHome } = runtimeHomeState; + env[OPENAI_RUNTIME_HOME_ENV_NAME] = runtimeHome; + env[OPENAI_SQLITE_HOME_ENV_NAME] = sqliteHome; + const authState = await measure( + "app_server.auth_state", + () => materializeOpenAiAuthState({ env, runtimeHome }), + false, + ); + signal?.throwIfAborted(); + const modelProviderConfig = await measure( + "app_server.config", + () => + materializeOpenAiModelProviderConfig({ + env, + mcpServers: mcpConfig.mcpServers, + provider: this.#payload.execution.provider, + providerOptions: this.#payload.execution.providerOptions, + runtimeHome, + }), + false, + ); + return { authState, modelProviderConfig }; + })(); + const setupBarrier = setupTask.then( + () => undefined, + () => undefined, ); + this.#runtimeHomeSetup = setupBarrier; + void setupBarrier.finally(() => { + if (this.#runtimeHomeSetup === setupBarrier) { + this.#runtimeHomeSetup = null; + } + }); + const { authState, modelProviderConfig } = await raceWithAbort(setupTask, signal); this.#context.logger.debug("driver.openai.auth_state.prepared", { authJsonWritten: authState.written, @@ -306,12 +367,20 @@ export class OpenAiAppServerClient { await measure("app_server.spawn", async () => { const executable = readRuntimeExecutable(); - const child = spawn(executable, ["app-server"], { - cwd: this.#payload.execution.session.cwd, - detached: true, - env, - stdio: ["pipe", "pipe", "pipe"], - }); + const processTree = createProcessTreeEnvironment(env); + this.#processTreeMarker = processTree.marker; + let child: ChildProcessWithoutNullStreams; + try { + child = spawn(executable, ["app-server"], { + cwd: this.#payload.execution.session.cwd, + detached: true, + env: processTree.env, + stdio: ["pipe", "pipe", "pipe"], + }); + } catch (error) { + this.#releaseProcessTreeMarker(processTree.marker); + throw error; + } const target = bindSpawnedProcess(child, process.platform, processTree); this.#process = child; @@ -349,10 +418,10 @@ export class OpenAiAppServerClient { this.#onLine(line); }); limitedStdout.once("error", (error) => { - this.#failProtocol(error); + void this.#failProtocol(error); }); child.stdin.on("error", (error) => { - this.#failProtocol(error); + void this.#failProtocol(error); }); child.stderr.setEncoding("utf8"); child.stderr.on("data", (chunk: string) => { @@ -389,7 +458,7 @@ export class OpenAiAppServerClient { ? cleanupError : new Error("OpenAi app-server process-tree cleanup failed."); } - await this.#failAfterDrain(failure); + await this.#failProtocol(failure); })(); }); @@ -408,7 +477,7 @@ export class OpenAiAppServerClient { await waitForLinuxProcessMarkerExit(processTree.marker); })(), ); - this.#failProtocol(error); + void this.#failProtocol(error); throw error; } @@ -418,7 +487,7 @@ export class OpenAiAppServerClient { return; } signalLinuxProcessMarker(processTree.marker, "SIGKILL"); - this.#failProtocol(error); + void this.#failProtocol(error); }; void watchdog.cleanup.then( () => @@ -449,7 +518,7 @@ export class OpenAiAppServerClient { throw error; } child.on("error", (error) => { - this.#failProtocol(error); + void this.#failProtocol(error); }); }); @@ -478,7 +547,7 @@ export class OpenAiAppServerClient { return; } - this.notify("initialized", {}); + this.notify("initialized", undefined); }); return { phases }; @@ -489,40 +558,52 @@ export class OpenAiAppServerClient { params: ClientRequestParams[M], signal?: AbortSignal, ): Promise { - return this.#request(method, params, CLIENT_REQUEST_RESULT_PARSERS[method], signal); + return this.#request(method, params, CLIENT_RESULT_SCHEMAS[method].parse, null, signal); } - async cleanBackgroundTerminals(threadId: string, signal?: AbortSignal): Promise { - await this.#request( - "thread/backgroundTerminals/clean", - { threadId }, - (value) => { - if (!isRecord(value ?? {})) { - throw new Error("thread/backgroundTerminals/clean result must be an object."); - } - }, - signal, - ); + async requestAtWireBarrier( + method: M, + params: ClientRequestParams[M], + handlers: { + accept(result: ClientRequestResult[M]): Promise; + received(result: ClientRequestResult[M]): void; + reject(error: Error): Promise; + released(): void; + }, + signal?: AbortSignal, + ): Promise { + return this.#request(method, params, CLIENT_RESULT_SCHEMAS[method].parse, handlers, signal); } - async #request( + async #request( method: string, params: unknown, parseResult: (value: unknown) => T, + handlers: { + accept(result: T): Promise; + received(result: T): void; + reject(error: Error): Promise; + released(): void; + } | null, signal?: AbortSignal, - ): Promise { + ): Promise { signal?.throwIfAborted(); const id = this.#nextId; this.#nextId += 1; - const response = Promise.withResolvers(); - this.#pendingRequests.set(id, { + const response = Promise.withResolvers(); + const pending: PendingJsonRpcRequest = { + accept: handlers === null ? null : async (value: unknown) => handlers.accept(value as T), method, + parse: parseResult, + receiveAtWire: handlers === null ? null : (value: unknown) => handlers.received(value as T), reject: response.reject, - resolve: (value) => { - response.resolve(parseResult(value)); - }, - }); + rejectAtBarrier: handlers?.reject ?? null, + releaseAtWire: handlers?.released ?? null, + responseQueued: false, + resolve: response.resolve, + }; + this.#pendingRequests.set(id, pending); try { this.#send({ @@ -531,6 +612,7 @@ export class OpenAiAppServerClient { ...(params === undefined ? {} : { params }), }); } catch (error) { + this.#releaseWireBarrier(pending); this.#pendingRequests.delete(id); throw error; } @@ -547,11 +629,13 @@ export class OpenAiAppServerClient { } if (result.status === "timed_out") { - this.#failProtocol(result.error); + this.#releaseWireBarrier(pending); + await this.#failProtocol(result.error); } throw result.error; } finally { + this.#releaseWireBarrier(pending); this.#pendingRequests.delete(id); } } @@ -570,11 +654,16 @@ export class OpenAiAppServerClient { // Resuming stdout schedules the next buffered chunk on a later event-loop // turn. A microtask-only check can mistake that backpressure handoff for // an empty queue and return before the resumed notifications are admitted. - await new Promise((resolve) => { - setImmediate(resolve); - }); + if (!this.#stopRequested) { + await new Promise((resolve) => { + setImmediate(resolve); + }); + } if (tail === this.#serverMessageQueue) { + if (this.#fatalError !== null) { + throw this.#fatalError; + } return; } } @@ -617,6 +706,12 @@ export class OpenAiAppServerClient { } async #performStop(signal?: AbortSignal): Promise { + await this.#runtimeHomeSetup; + await this.#stopProcess(signal); + await this.#cleanupRuntimeHome(); + } + + async #stopProcess(signal?: AbortSignal): Promise { const child = this.#process; const target = this.#processTarget; const processClosed = this.#processClosed; @@ -693,11 +788,25 @@ export class OpenAiAppServerClient { } } + async #cleanupRuntimeHome(): Promise { + const state = this.#runtimeHomeState; + + if (state === null) { + return; + } + + await cleanupOpenAiRuntimeHome(state); + if (this.#runtimeHomeState === state) { + this.#runtimeHomeState = null; + } + } + #releaseProcessTreeMarker(marker: string): void { - releaseLinuxProcessMarker(marker); - if (this.#processTreeMarker === marker) { - this.#processTreeMarker = null; + if (this.#processTreeMarker !== marker) { + return; } + this.#processTreeMarker = null; + releaseLinuxProcessMarker(marker); } #send(message: Record): void { @@ -715,7 +824,7 @@ export class OpenAiAppServerClient { } #onLine(line: string): void { - if (this.#stopRequested) { + if (this.#stopRequested || this.#fatalError !== null) { return; } @@ -730,24 +839,38 @@ export class OpenAiAppServerClient { try { parsed = JSON.parse(trimmed); } catch { - this.#context.logger.debug("driver.openai.non_json_stdout", { - line: trimmed, - }); + void this.#failProtocol(new TypeError("OpenAi app-server stdout is not valid JSON.")); return; } if (!isRecord(parsed)) { + void this.#failProtocol( + new TypeError("OpenAi app-server protocol message must be an object."), + ); return; } const method = readNonEmptyString(parsed, "method"); const id = toJsonRpcId(parsed["id"]); + if ("result" in parsed || "error" in parsed) { + if (id === null) { + void this.#failProtocol(new TypeError("OpenAi app-server response requires a valid id.")); + return; + } + + this.#onResponse(id, parsed); + return; + } + if (method !== null) { const bytes = Buffer.byteLength(trimmed, "utf8"); - if (bytes > MAX_PENDING_SERVER_MESSAGE_BYTES - this.#pendingServerMessageBytes) { - this.#failProtocol(new Error("App-server message queue limit exceeded.")); + if ( + this.#pendingServerMessages >= MAX_PENDING_SERVER_MESSAGES || + bytes > MAX_PENDING_SERVER_MESSAGE_BYTES - this.#pendingServerMessageBytes + ) { + void this.#failProtocol(new Error("App-server message queue limit exceeded.")); return; } @@ -756,9 +879,9 @@ export class OpenAiAppServerClient { this.#pauseServerMessagesIfNeeded(); this.#serverMessageQueue = this.#processMessage( this.#serverMessageQueue, + parsed, method, id, - parsed["params"], ).finally(() => { this.#pendingServerMessages -= 1; this.#pendingServerMessageBytes -= bytes; @@ -769,7 +892,12 @@ export class OpenAiAppServerClient { if (id !== null) { this.#onResponse(id, parsed); + return; } + + void this.#failProtocol( + new TypeError("OpenAi app-server protocol message requires a valid method or id."), + ); } #pauseServerMessagesIfNeeded(): void { @@ -793,6 +921,7 @@ export class OpenAiAppServerClient { if ( !this.#serverMessagesPaused || this.#stopRequested || + this.#fatalError !== null || this.#pendingServerMessages > RESUME_PENDING_SERVER_MESSAGES || this.#pendingServerMessageBytes > RESUME_PENDING_SERVER_MESSAGE_BYTES ) { @@ -807,23 +936,59 @@ export class OpenAiAppServerClient { this.#process?.stdout.resume(); } - #failProtocol(error: Error): void { + #failProtocol(error: Error): Promise { if (this.#stopRequested) { - return; + return Promise.resolve(); } + if (this.#protocolFailureTask !== null) { + return this.#protocolFailureTask; + } + if (this.#fatalError !== null) { + return this.#protocolFailureCommit ?? Promise.resolve(); + } + + this.#releasePendingWireBarriers(); this.#fatalError = error; - this.#rejectPending(error); - void this.#notifyProtocolError(error); - void this.stop().catch(() => {}); + this.#process?.stdout.pause(); + const barrier = this.#serverMessageQueue; + const task = (async () => { + await barrier; + await this.#commitProtocolFailure(error); + })(); + this.#protocolFailureTask = task; + this.#serverMessageQueue = task; + return task; } - async #failAfterDrain(error: Error): Promise { - await this.drainServerMessages(); + async #failQueuedProtocol(error: Error): Promise { + if (this.#stopRequested) { + return; + } - if (!this.#stopRequested) { - this.#failProtocol(error); + // This message was admitted before any out-of-band failure waiting on the + // queue barrier, so its failure is authoritative in wire order. + if (this.#protocolFailureCommit === null) { + this.#fatalError = error; } + this.#process?.stdout.pause(); + await this.#commitProtocolFailure(this.#fatalError ?? error); + } + + #commitProtocolFailure(error: Error): Promise { + if (this.#protocolFailureCommit !== null) { + return this.#protocolFailureCommit; + } + + const task = this.#finishProtocolFailure(error); + this.#protocolFailureCommit = task; + return task; + } + + async #finishProtocolFailure(error: Error): Promise { + await this.#notifyProtocolError(error); + this.#rejectPending(error); + void this.stop().catch(() => {}); } async #notifyProtocolError(error: Error): Promise { @@ -840,9 +1005,9 @@ export class OpenAiAppServerClient { async #processMessage( previousMessage: Promise, + message: JsonObject, method: string, id: RequestId | null, - params: unknown, ): Promise { try { await previousMessage; @@ -851,20 +1016,26 @@ export class OpenAiAppServerClient { return; } - await this.#onServerMessage(method, id, params); + await this.#onServerMessage(message, method, id); } catch (error) { + const failure = + error instanceof Error ? error : new Error("OpenAi app-server protocol message failed."); this.#context.logger.error("driver.openai.server_message.failed", error, { method, }); + + if (id === null) { + await this.#failQueuedProtocol(failure); + return; + } + if (!this.#stopRequested) { - await this.#notifyProtocolError( - error instanceof Error ? error : new Error("OpenAi app-server protocol message failed."), - ); + await this.#notifyProtocolError(failure); } if (id !== null && !this.#stopRequested) { try { - this.respondError(id, error instanceof Error ? error.message : "Server request failed."); + this.respondError(id, failure.message); } catch (responseError) { this.#context.logger.error("driver.openai.server_error_response.failed", responseError, { method, @@ -875,45 +1046,173 @@ export class OpenAiAppServerClient { } #onResponse(id: RequestId, message: JsonObject): void { + const parsed = jsonRpcResponseSchema.safeParse(message); + + if (!parsed.success) { + void this.#failProtocol( + new TypeError("OpenAi app-server response envelope is invalid.", { + cause: parsed.error, + }), + ); + return; + } + const pending = this.#pendingRequests.get(id); - if (pending === undefined) { + if (pending === undefined || pending.responseQueued) { return; } + if (pending.accept === null) { + this.#settleResponse(id, pending, parsed.data); + return; + } + pending.responseQueued = true; + if ("error" in parsed.data) { + this.#releaseWireBarrier(pending); + } else { + try { + this.#receiveWireResult(pending, pending.parse(parsed.data.result)); + } catch (error) { + const failure = + error instanceof Error ? error : new Error("OpenAi app-server result parse failed."); + this.#releaseWireBarrier(pending); + void this.#failProtocol(failure); + return; + } + } + this.#serverMessageQueue = this.#processResponse( + this.#serverMessageQueue, + id, + pending, + parsed.data, + ); + } - this.#pendingRequests.delete(id); + async #processResponse( + previousMessage: Promise, + id: RequestId, + pending: PendingJsonRpcRequest, + response: + | { error: { code: number; data?: unknown; message: string }; id: RequestId } + | { + id: RequestId; + result: unknown; + }, + ): Promise { + await previousMessage; - const responseError = readRecord(message, "error"); + if (this.#stopRequested) { + return; + } + if (this.#pendingRequests.get(id) !== pending) { + return; + } + const accept = pending.accept; + const rejectAtBarrier = pending.rejectAtBarrier; + if (accept === null || rejectAtBarrier === null) { + await this.#failQueuedProtocol(new Error("Wire-barrier response handlers are missing.")); + return; + } - if (responseError !== null) { - const errorMessage = - readString(responseError, "message") ?? "OpenAi app-server request failed."; - const responseCode = responseError["code"]; - const errorCode = - typeof responseCode === "number" || typeof responseCode === "string" ? responseCode : null; + if ("error" in response) { + const error = this.#reportResponseError(pending, response.error); + this.#pendingRequests.delete(id); + try { + await rejectAtBarrier(error); + pending.reject(error); + } catch (rejectionError) { + const failure = + rejectionError instanceof Error + ? rejectionError + : new Error("OpenAi app-server response rejection failed."); + await this.#failQueuedProtocol(failure); + pending.reject(failure); + } + return; + } - this.#context.logger.error("driver.openai.client_request.failed", new Error(errorMessage), { - data: summarizeJsonRpcErrorData(responseError["data"]), - method: pending.method, - responseCode: errorCode, - }); - pending.reject(new Error(errorMessage)); + let result: unknown; + try { + result = pending.parse(response.result); + } catch (parseError) { + const error = + parseError instanceof Error + ? parseError + : new Error("OpenAi app-server result parse failed."); + await this.#failQueuedProtocol(error); + return; + } + + this.#pendingRequests.delete(id); + try { + pending.resolve(await accept(result)); + } catch (acceptError) { + const error = + acceptError instanceof Error + ? acceptError + : new Error("OpenAi app-server response acceptance failed."); + await this.#failQueuedProtocol(error); + pending.reject(error); + } + } + + #settleResponse( + id: RequestId, + pending: PendingJsonRpcRequest, + response: + | { error: { code: number; data?: unknown; message: string }; id: RequestId } + | { id: RequestId; result: unknown }, + ): void { + if ("error" in response) { + this.#rejectResponse(id, pending, response.error); return; } try { - pending.resolve(message["result"]); + pending.resolve(pending.parse(response.result)); + this.#pendingRequests.delete(id); } catch (parseError) { - pending.reject( + const error = parseError instanceof Error ? parseError - : new Error("OpenAi app-server result parse failed."), - ); + : new Error("OpenAi app-server result parse failed."); + void this.#failProtocol(error); } } - async #onServerMessage(method: string, id: RequestId | null, params: unknown): Promise { + #rejectResponse( + id: RequestId, + pending: PendingJsonRpcRequest, + error: { code: number; data?: unknown; message: string }, + ): void { + this.#pendingRequests.delete(id); + pending.reject(this.#reportResponseError(pending, error)); + } + + #reportResponseError( + pending: PendingJsonRpcRequest, + error: { code: number; data?: unknown; message: string }, + ): Error { + const failure = new Error(error.message); + this.#context.logger.error( + "driver.openai.client_request.failed", + new Error(toOpenAiProtocolError({ message: error.message }).message), + { + data: summarizeJsonRpcErrorData(error.data), + method: pending.method, + responseCode: error.code, + }, + ); + return failure; + } + + async #onServerMessage(message: JsonObject, method: string, id: RequestId | null): Promise { if (id === null) { + if (isServerRequestMethod(method)) { + parseServerRequest(message); + throw new Error(`OpenAi app-server request ${method} is missing a valid id.`); + } + if (!isServerNotificationMethod(method)) { this.#context.logger.debug("driver.openai.server_notification.ignored", { method, @@ -921,14 +1220,21 @@ export class OpenAiAppServerClient { return; } - if (method === "serverRequest/resolved") { - const parsed = parseServerNotificationParams(method, params); - await this.#requestHandler.resolveElsewhere(parsed.requestId); - await this.#context.handleNotification(method, parsed); + const notification = parseServerNotification(message)!; + + if (notification.method === "serverRequest/resolved") { + const requestId = notification.params["requestId"]; + + if (typeof requestId !== "number" && typeof requestId !== "string") { + throw new TypeError("serverRequest/resolved params.requestId is invalid."); + } + + await this.#requestHandler.resolveElsewhere(requestId); + await this.#context.handleNotification(notification.method, notification.params); return; } - await this.#context.handleNotification(method, parseServerNotificationParams(method, params)); + await this.#context.handleNotification(notification.method, notification.params); return; } @@ -937,14 +1243,51 @@ export class OpenAiAppServerClient { return; } - this.#requestHandler.dispatch(method, id, params); + const request = parseServerRequest(message)!; + + if (this.#requestHandler.isPending(request.id)) { + await this.#failQueuedProtocol( + new Error(`OpenAi app-server request ${String(request.id)} is already pending.`), + ); + return; + } + + this.#requestHandler.dispatch(request.method, request.id, request.params); } #rejectPending(error: Error): void { for (const pending of this.#pendingRequests.values()) { + this.#releaseWireBarrier(pending); pending.reject(error); } this.#pendingRequests.clear(); } + + #receiveWireResult(pending: PendingJsonRpcRequest, result: unknown): void { + const receive = pending.receiveAtWire; + const release = pending.releaseAtWire; + pending.receiveAtWire = null; + pending.releaseAtWire = null; + + try { + receive?.(result); + } catch (error) { + release?.(); + throw error; + } + } + + #releaseWireBarrier(pending: PendingJsonRpcRequest): void { + const release = pending.releaseAtWire; + pending.receiveAtWire = null; + pending.releaseAtWire = null; + release?.(); + } + + #releasePendingWireBarriers(): void { + for (const pending of this.#pendingRequests.values()) { + this.#releaseWireBarrier(pending); + } + } } diff --git a/src/runtimes/openai/app-server-driver-backend.ts b/src/runtimes/openai/app-server-driver-backend.ts index ece6e36..47aaf68 100644 --- a/src/runtimes/openai/app-server-driver-backend.ts +++ b/src/runtimes/openai/app-server-driver-backend.ts @@ -1,37 +1,41 @@ import { isDriverFullAccess } from "../../core/driver-permission-policy"; import { pushLosslessEvents } from "../../core/driver-runtime-io"; -import { DriverTurnCancelledError } from "../../core/driver-runtime-state"; +import { + DriverTurnCancelledError, + DriverTurnCancellationCleanupError, +} from "../../core/driver-runtime-state"; import { createTimingEvent, createTimingPhase, toDurationMs, } from "../../core/driver-runtime-timing"; import { summarizePath, summarizeRuntimeCommandInput } from "../../observability/driver-debug"; -import type { RunId } from "../../protocol/id"; +import { driverIdTimeMs, type RunId } from "../../protocol/id"; import type { DriverRuntime } from "../../protocol/runtime"; import type { DriverStartInput } from "../../protocol/start"; import type { RuntimeCommandInput } from "../../runtime-command"; -import { raceWithAbort } from "../../utils/async"; +import { raceWithAbort, settlePromiseWithTimeout } from "../../utils/async"; import type { AgentDriverBackend, AgentDriverContext } from "../../core/agent-driver-backend"; import { DriverEventPublisher } from "../driver-event-publisher"; +import { createRuntimeSourceEventId } from "../runtime-public-id"; import { buildNativeRuntimeSystemPrompt, computeRuntimeBootstrapDigest, writeSkillBootstrapArtifacts, } from "../skill-bootstrap"; import { OpenAiAppServerClient } from "./app-server-client"; +import { openAiAgentTasksClosedEvent } from "./app-server-agent-task-events"; import { MOSOO_OPENAI_RUNTIME_SANDBOX_MODE } from "./app-server-env"; import { OpenAiAppServerEventBridge } from "./app-server-event-bridge"; +import { toOpenAiProtocolError } from "./app-server-event-mapping"; import type { ApprovalPolicy, - JsonObject, + ThreadInjectItemsParams, ThreadResumeParams, ThreadStartParams, ThreadStartResponse, TurnStatus, - TurnStartParams, - TurnStartResponse, -} from "./generated/app-server-protocol"; +} from "./app-server-protocol"; /** * App-server approval policy derived from the driver permission policy. @@ -47,14 +51,6 @@ function resolveApprovalPolicy(payload: DriverStartInput): ApprovalPolicy { return isDriverFullAccess(payload) ? "never" : "untrusted"; } -interface OpenAiTurnStartInput { - readonly approvalPolicy: ApprovalPolicy; - readonly cwd: string; - readonly model: string; - readonly text: string; - readonly threadId: string; -} - interface OpenAiPhaseMeasure { (name: string, task: () => Promise): Promise; } @@ -63,6 +59,24 @@ const OPENAI_CLIENT_STOP_TIMEOUT_MS = 500; const OPENAI_SERVER_REQUEST_CANCEL_TIMEOUT_MS = 1_500; const OPENAI_TURN_CANCEL_EVENT_TIMEOUT_MS = 250; const OPENAI_BACKGROUND_TERMINAL_CLEAN_TIMEOUT_MS = 500; +const MAX_OPENAI_NATIVE_THREAD_ID_BYTES = 256; + +function validateOpenAiNativeThreadId(threadId: string): string { + const utf8Bytes = Buffer.byteLength(threadId, "utf8"); + + if (utf8Bytes === 0 || utf8Bytes > MAX_OPENAI_NATIVE_THREAD_ID_BYTES) { + throw new RangeError( + `OpenAI native thread ID must contain 1-${String(MAX_OPENAI_NATIVE_THREAD_ID_BYTES)} UTF-8 bytes (received ${String(utf8Bytes)}).`, + ); + } + + return threadId; +} + +function validateOpenAiThreadResponse(response: ThreadStartResponse): ThreadStartResponse { + validateOpenAiNativeThreadId(response.thread.id); + return response; +} async function joinCancellationCleanup(tasks: readonly Promise[]): Promise { const results = await Promise.allSettled(tasks); @@ -87,11 +101,7 @@ function readResumeThreadId(payload: DriverStartInput): string | null { throw new Error("OpenAI runtime received an incompatible native resume ref."); } - if (nativeResumeRef.value.length === 0) { - throw new Error("OpenAI runtime received an empty native resume thread id."); - } - - return nativeResumeRef.value; + return validateOpenAiNativeThreadId(nativeResumeRef.value); } function isTerminalTurn(status: TurnStatus): boolean { @@ -111,7 +121,7 @@ function isUnmaterializedRollout(error: unknown, threadId: string): boolean { function toRecoveryItems( messages: DriverStartInput["execution"]["session"]["recoveryMessages"], -): JsonObject[] { +): ThreadInjectItemsParams["items"] { return messages.map((message) => ({ content: [ { @@ -124,26 +134,11 @@ function toRecoveryItems( })); } -export function createTurnParams(input: OpenAiTurnStartInput): TurnStartParams { - return { - approvalPolicy: input.approvalPolicy, - cwd: input.cwd, - input: [ - { - text: input.text, - text_elements: [], - type: "text", - }, - ], - model: input.model, - threadId: input.threadId, - }; -} - export class OpenAiAppServerDriverBackend implements AgentDriverBackend { readonly runtime: DriverRuntime = "openai-runtime"; readonly #payload: DriverStartInput; readonly #eventPublisher = new DriverEventPublisher(this.runtime, () => this.#threadId); + #activeCancellationTask: Promise | null = null; #client: OpenAiAppServerClient | null = null; #clientStartupCancellation: AbortController | null = null; #clientStopRequested = false; @@ -151,6 +146,8 @@ export class OpenAiAppServerDriverBackend implements AgentDriverBackend { #pendingTurnStartCancellationEvent = false; #pendingTurnStartCancellationReason: string | null = null; #pendingTurnStartServerRequests: Promise | null = null; + #pendingTurnStartUpdates: Promise | null = null; + #pendingCancellationSettlement: Promise | null = null; #restartThreadId: string | null = null; #stopping = false; #threadId: string | null = null; @@ -172,6 +169,10 @@ export class OpenAiAppServerDriverBackend implements AgentDriverBackend { } }, push: async (context, reason, events) => this.#eventPublisher.push(context, reason, events), + pushSession: async (context, reason, events) => + this.#eventPublisher.pushSession(context, reason, events), + pushTerminal: async (context, reason, closures, terminal, cancellationSignal) => + this.#eventPublisher.pushTerminal(context, reason, closures, terminal, cancellationSignal), requireThreadId: () => this.#requireThreadId(), }); @@ -206,10 +207,10 @@ export class OpenAiAppServerDriverBackend implements AgentDriverBackend { })(); const skillBootstrapPromise = (async () => { const materializedSkills = await measureStartupPhase("skills.materialize", () => - context.ports.skill.materialize(this.#payload.execution), + context.ports.skill.materialize(this.#payload.execution, signal), ); const artifacts = await measureStartupPhase("skills.bootstrap", () => - writeSkillBootstrapArtifacts(this.#payload.execution), + writeSkillBootstrapArtifacts(this.#payload.execution, materializedSkills, signal), ); return { artifacts, count: materializedSkills.length }; })(); @@ -219,6 +220,7 @@ export class OpenAiAppServerDriverBackend implements AgentDriverBackend { [, bootstrapArtifacts] = await Promise.all([clientStartPromise, skillBootstrapPromise]); } catch (error) { await this.#cleanupFailedClient(context, client, "driver.openai.startup.cleanup.failed"); + await this.#publishAgentTasksClosed(context, "driver.openai.startup.failed"); signal.throwIfAborted(); throw error; } @@ -240,10 +242,14 @@ export class OpenAiAppServerDriverBackend implements AgentDriverBackend { ); } catch (error) { await this.#cleanupFailedClient(context, client, "driver.openai.startup.cleanup.failed"); + await this.#publishAgentTasksClosed(context, "driver.openai.startup.failed"); signal.throwIfAborted(); throw error; } - signal.throwIfAborted(); + if (signal.aborted) { + await this.#publishAgentTasksClosed(context, "driver.openai.startup.cancelled"); + signal.throwIfAborted(); + } this.#threadId = threadResult.thread.id; void this.#emitStartupTiming(context, startupStartedAt, startupPhases); @@ -267,21 +273,28 @@ export class OpenAiAppServerDriverBackend implements AgentDriverBackend { handleNotification: async (method, params) => this.#events.handleNotification(context, method, params), handleProtocolError: async (error) => { + const boundedError = new Error(toOpenAiProtocolError({ message: error.message }).message); + try { if (await this.#events.failActiveTurns(context, error)) { return; } + if (this.#events.hasAdmittedTerminalTurn()) { + context.lifecycle.fail(boundedError); + return; + } if (await this.#failTurnStart(context, error)) { return; } } catch (projectionError) { - this.#events.rejectActiveTurns(error); - context.lifecycle.fail(error); + this.#events.rejectActiveTurns(boundedError); + context.lifecycle.fail(boundedError); throw projectionError; } - context.lifecycle.fail(error); + context.lifecycle.fail(boundedError); }, + mapToolCallId: (toolCallId) => this.#events.mapToolCallId(toolCallId), }); } @@ -306,6 +319,25 @@ export class OpenAiAppServerDriverBackend implements AgentDriverBackend { } } + async #publishAgentTasksClosed( + context: AgentDriverContext, + reason: string, + runId = context.ports.eventSink.currentRunId(), + ): Promise { + if (runId === null) { + return; + } + + await this.#eventPublisher.push(context, reason, [ + { + ...openAiAgentTasksClosedEvent(), + runId, + sourceEventId: `${reason}:${runId}`, + }, + ]); + this.#events.releaseTurnState(); + } + async #startThread( context: AgentDriverContext, client: OpenAiAppServerClient, @@ -324,25 +356,31 @@ export class OpenAiAppServerDriverBackend implements AgentDriverBackend { const threadStartParams = { ...baseThreadParams, ...(developerInstructions === null ? {} : { developerInstructions }), + historyMode: "paginated", sessionStartSource: "startup", } satisfies ThreadStartParams; if (resumeThreadId === null) { - return measure("thread.start", () => - client.request("thread/start", threadStartParams, signal), + return validateOpenAiThreadResponse( + await measure("thread.start", () => + client.request("thread/start", threadStartParams, signal), + ), ); } try { - return await measure("thread.resume", () => - client.request( - "thread/resume", - { - ...baseThreadParams, - ...(developerInstructions === null ? {} : { developerInstructions }), - threadId: resumeThreadId, - } satisfies ThreadResumeParams, - signal, + return validateOpenAiThreadResponse( + await measure("thread.resume", () => + client.request( + "thread/resume", + { + ...baseThreadParams, + ...(developerInstructions === null ? {} : { developerInstructions }), + excludeTurns: true, + threadId: resumeThreadId, + } satisfies ThreadResumeParams, + signal, + ), ), ); } catch (error) { @@ -354,8 +392,10 @@ export class OpenAiAppServerDriverBackend implements AgentDriverBackend { context.logger.warn("driver.openai.native_resume_ref.missing_rollout", { nativeResumeRefPresent: true, }); - const threadResult = await measure("thread.start_after_missing_rollout", () => - client.request("thread/start", threadStartParams, signal), + const threadResult = validateOpenAiThreadResponse( + await measure("thread.start_after_missing_rollout", () => + client.request("thread/start", threadStartParams, signal), + ), ); const recoveryItems = toRecoveryItems(this.#payload.execution.session.recoveryMessages); @@ -460,6 +500,7 @@ export class OpenAiAppServerDriverBackend implements AgentDriverBackend { context: AgentDriverContext, input: RuntimeCommandInput, runId: RunId, + signal?: AbortSignal, ): Promise { this.#turnStartInFlight = true; this.#turnStartRunId = runId; @@ -467,16 +508,17 @@ export class OpenAiAppServerDriverBackend implements AgentDriverBackend { this.#pendingTurnStartCancellationEvent = false; this.#pendingTurnStartCancellationReason = null; this.#pendingTurnStartServerRequests = null; + this.#pendingTurnStartUpdates = null; - let client: OpenAiAppServerClient; - let threadId: string; - let turnResult: TurnStartResponse; - let turnStartRequestedAtMs: number; + let completion: Promise; + let turnAdmission: ReturnType | null = + this.#events.beginTurnAdmission(runId, signal); + const admission = turnAdmission; try { await this.#ensureClient(context); - client = this.#requireClient(); - threadId = this.#requireThreadId(); + const client = this.#requireClient(); + const threadId = this.#requireThreadId(); context.logger.info("driver.openai.prompt.sending", { textLength: input.text.length, @@ -487,91 +529,175 @@ export class OpenAiAppServerDriverBackend implements AgentDriverBackend { threadIdPresent: true, }); - turnStartRequestedAtMs = Date.now(); - turnResult = await client.request( + const turnStartRequestedAtMs = Date.now(); + this.#events.armTurnAdmission(admission); + const accepted = await client.requestAtWireBarrier( "turn/start", - createTurnParams({ + { approvalPolicy: resolveApprovalPolicy(this.#payload), cwd: this.#payload.execution.session.cwd, + input: [ + { + text: input.text, + text_elements: [], + type: "text", + }, + ], model: this.#payload.execution.model, - text: input.text, threadId, - }), + }, + { + accept: async (turnResult) => { + const turnId = turnResult.turn.id; + const terminalObserved = this.#events.hasTerminalTurn(turnId); + const turnCompletion = this.#events.claimTurnAdmission( + admission, + turnId, + runId, + signal, + ); + turnAdmission = null; + void turnCompletion.catch(() => {}); + this.#claimTurnStartResponse(); + + if (!terminalObserved) { + const publicTurnId = this.#events.publicTurnId(turnId); + const turnStartedAtMs = Date.now(); + await this.#eventPublisher.push(context, "driver.openai.provider.turn_start", [ + createTimingEvent({ + completedAt: new Date(turnStartedAtMs).toISOString(), + path: "unknown", + phases: [ + createTimingPhase( + "provider.turn_start", + toDurationMs(turnStartRequestedAtMs, turnStartedAtMs), + ), + ], + runId, + sessionId: context.payload.execution.run.sessionId, + stage: "driver_turn", + startedAt: new Date(turnStartRequestedAtMs).toISOString(), + native: { + eventName: "provider.turn_start", + provider: "openai", + turnId: publicTurnId, + }, + }), + ]); + + await this.#events.publishRunStarted(context, { runId, turnId }); + + if (isTerminalTurn(turnResult.turn.status)) { + await this.#events.handleNotification(context, "turn/completed", { + threadId, + turn: turnResult.turn, + }); + } + } + + return { completion: turnCompletion }; + }, + received: (turnResult) => { + this.#events.bindTurnAdmission(admission, turnResult.turn.id); + }, + reject: async (error) => { + this.#claimTurnStartResponse(); + await this.#publishTurnStartFailure(context, runId, error); + }, + released: () => { + this.#events.releaseTurnAdmissionSelection(admission); + }, + }, ); + completion = accepted.completion; } catch (error) { - this.#turnStartInFlight = false; - this.#turnStartRunId = null; - const publishCancellation = this.#pendingTurnStartCancellationEvent; - const pendingCancellationReason = this.#pendingTurnStartCancellationReason; - this.#pendingTurnStartCancellationEvent = false; - this.#pendingTurnStartCancellationReason = null; + const signalCancellation = + signal?.aborted === true && signal.reason instanceof DriverTurnCancelledError + ? signal.reason + : null; + const pendingCancellationReason = + this.#pendingTurnStartCancellationReason ?? signalCancellation?.message ?? null; + const publishCancellation = + this.#pendingTurnStartCancellationReason === null + ? (signalCancellation?.resumeAllowed ?? false) + : this.#pendingTurnStartCancellationEvent; + const failure = error instanceof Error ? error : new Error("OpenAI turn start failed."); if (pendingCancellationReason !== null) { - await this.#waitPendingTurnStartCleanup(); - await this.#waitPendingTurnStartServerRequests(); - if (publishCancellation) { - await this.#publishTurnStartCancellation(context, runId, pendingCancellationReason); + await this.#finishPendingTurnStartCancellation(); + const admittedTurnId = + turnAdmission === null ? null : this.#events.admittedTurnId(turnAdmission); + + try { + if (turnAdmission !== null && admittedTurnId !== null) { + const turnCompletion = this.#events.claimTurnAdmission( + turnAdmission, + admittedTurnId, + runId, + signal, + ); + turnAdmission = null; + void turnCompletion.catch(() => {}); + if (publishCancellation) { + await this.#events.cancelTurn(context, admittedTurnId, pendingCancellationReason); + } else { + this.#events.rejectTurn( + admittedTurnId, + new DriverTurnCancelledError(pendingCancellationReason), + ); + } + } else if (publishCancellation) { + await this.#publishTurnStartCancellation(context, runId, pendingCancellationReason); + } + } catch (cancellationError) { + throw new DriverTurnCancellationCleanupError( + `OpenAI pending turn cancellation settlement failed: ${ + cancellationError instanceof Error + ? cancellationError.message + : "unknown cancellation error" + }`, + cancellationError, + ); } + this.#events.releaseTurnState(); + this.#turnStartInFlight = false; + this.#turnStartRunId = null; + this.#pendingTurnStartCancellationEvent = false; + this.#pendingTurnStartCancellationReason = null; throw new DriverTurnCancelledError(pendingCancellationReason); } - throw error; - } - - const turnId = turnResult.turn.id; - const completion = this.#events.trackTurn(turnId, runId); - void completion.catch(() => {}); - this.#turnStartInFlight = false; - this.#turnStartRunId = null; - const publishCancellation = this.#pendingTurnStartCancellationEvent; - this.#pendingTurnStartCancellationEvent = false; - const pendingCancellationReason = this.#pendingTurnStartCancellationReason; - this.#pendingTurnStartCancellationReason = null; - - if (pendingCancellationReason !== null) { - this.#events.rejectTurn(turnId, new DriverTurnCancelledError(pendingCancellationReason)); - await this.#waitPendingTurnStartCleanup(); - await this.#waitPendingTurnStartServerRequests(); - if (publishCancellation) { - await this.#publishTurnStartCancellation(context, runId, pendingCancellationReason); + const admittedTurnId = + turnAdmission === null ? null : this.#events.admittedTurnId(turnAdmission); + if ( + turnAdmission !== null && + admittedTurnId !== null && + this.#events.hasTerminalTurn(admittedTurnId) + ) { + const terminalCompletion = this.#events.claimTurnAdmission( + turnAdmission, + admittedTurnId, + runId, + signal, + ); + turnAdmission = null; + this.#claimTurnStartResponse(); + return await terminalCompletion; } - this.#events.releaseTurnState(); - throw new DriverTurnCancelledError(pendingCancellationReason); - } - const turnStartedAtMs = Date.now(); - - await this.#eventPublisher.push(context, "driver.openai.provider.turn_start", [ - createTimingEvent({ - completedAt: new Date(turnStartedAtMs).toISOString(), - path: "unknown", - phases: [ - createTimingPhase( - "provider.turn_start", - toDurationMs(turnStartRequestedAtMs, turnStartedAtMs), - ), - ], - runId, - sessionId: context.payload.execution.run.sessionId, - sourceEventId: `openai.provider.turn_start:${turnId}`, - stage: "driver_turn", - startedAt: new Date(turnStartRequestedAtMs).toISOString(), - native: { - eventName: "provider.turn_start", - provider: "openai", - turnId, - }, - }), - ]); - - if (isTerminalTurn(turnResult.turn.status)) { - await this.#events.handleNotification(context, "turn/completed", { - threadId, - turn: turnResult.turn, - }); + try { + await this.#failTurnStart(context, failure); + } finally { + this.#turnStartInFlight = false; + this.#turnStartRunId = null; + } + throw new Error(toOpenAiProtocolError({ message: failure.message }).message); + } finally { + if (turnAdmission !== null) { + this.#events.releaseTurnAdmission(turnAdmission); + } } - await this.#events.publishRunStarted(context, { runId, turnId }); await completion; } @@ -580,29 +706,85 @@ export class OpenAiAppServerDriverBackend implements AgentDriverBackend { if (!this.#turnStartInFlight || runId === null) { return false; } + this.#turnStartRunId = null; + await this.#publishTurnStartFailure(context, runId, error); + return true; + } + #claimTurnStartResponse(): void { + this.#turnStartInFlight = false; this.#turnStartRunId = null; - await this.#eventPublisher.push(context, "driver.openai.provider.failed", [ - { - kind: "run.started", - payload: { - startedAt: new Date().toISOString(), + this.#pendingTurnStartCancellationEvent = false; + this.#pendingTurnStartCancellationReason = null; + } + + async #publishTurnStartFailure( + context: AgentDriverContext, + runId: RunId, + error: Error, + ): Promise { + const protocolError = { + ...toOpenAiProtocolError({ message: error.message }), + code: "openai.provider_failed", + } as const; + const [taskClosure, ...itemClosures] = this.#events.turnStartTerminalEvents({ + error: protocolError, + kind: "failed", + }); + const closureSourcePrefix = createRuntimeSourceEventId( + "openai.provider.failed.closure", + "run", + runId, + ); + + await this.#eventPublisher.pushTerminal( + context, + "driver.openai.provider.failed", + [ + { + ...taskClosure, + runId, + sourceEventId: createRuntimeSourceEventId( + "openai.derived", + closureSourcePrefix, + 0, + JSON.stringify({ ...taskClosure, runId }), + ), }, - runId, - }, + { + kind: "run.started", + payload: { + startedAt: new Date(driverIdTimeMs(runId)).toISOString(), + }, + runId, + sourceEventId: `openai.provider.failed.started:${runId}`, + }, + ...itemClosures.map((event, index) => ({ + ...event, + runId, + sourceEventId: + event.sourceEventId ?? + createRuntimeSourceEventId( + "openai.derived", + closureSourcePrefix, + index + 1, + JSON.stringify({ ...event, runId }), + ), + })), + ], { kind: "run.failed", payload: { error: { - code: "openai.provider_failed", - message: error.message, + ...protocolError, }, recoverable: false, }, runId, + sourceEventId: `openai.provider.failed.terminal:${runId}`, }, - ]); - return true; + ); + this.#events.releaseTurnState(); } async #publishTurnStartCancellation( @@ -610,16 +792,35 @@ export class OpenAiAppServerDriverBackend implements AgentDriverBackend { runId: RunId, reason: string, ): Promise { - const cancellationEvent = this.#eventPublisher.push( + const [taskClosure, ...itemClosures] = this.#events.turnStartTerminalEvents({ + kind: "cancelled", + }); + const closureSourcePrefix = createRuntimeSourceEventId( + "openai.turn_start.cancelled.closure", + "run", + runId, + ); + await this.#eventPublisher.pushTerminal( context, "driver.openai.turn_start.cancelled", [ + { + ...taskClosure, + runId, + sourceEventId: createRuntimeSourceEventId( + "openai.derived", + closureSourcePrefix, + 0, + JSON.stringify({ ...taskClosure, runId }), + ), + }, { kind: "run.started", payload: { - startedAt: new Date().toISOString(), + startedAt: new Date(driverIdTimeMs(runId)).toISOString(), }, runId, + sourceEventId: `openai.turn_start.cancelled.started:${runId}`, }, { kind: "run.cancel.requested", @@ -629,50 +830,94 @@ export class OpenAiAppServerDriverBackend implements AgentDriverBackend { targetRunId: runId, }, runId, + sourceEventId: `openai.turn_start.cancelled.requested:${runId}`, }, - { - kind: "run.cancelled", - payload: { - requestedBy: "user", - stopReason: "cancelled", - }, + ...itemClosures.map((event, index) => ({ + ...event, runId, - }, + sourceEventId: + event.sourceEventId ?? + createRuntimeSourceEventId( + "openai.derived", + closureSourcePrefix, + index + 1, + JSON.stringify({ ...event, runId }), + ), + })), ], + { + kind: "run.cancelled", + payload: { + requestedBy: "user", + stopReason: "cancelled", + }, + runId, + sourceEventId: `openai.turn_start.cancelled.terminal:${runId}`, + }, ); - void cancellationEvent.catch(() => {}); + } + async #finishPendingTurnStartCancellation(): Promise { try { - await raceWithAbort( - cancellationEvent, - AbortSignal.timeout(OPENAI_TURN_CANCEL_EVENT_TIMEOUT_MS), - ); + let pending = this.#pendingTurnStartCleanup; + this.#pendingTurnStartCleanup = null; + if (pending !== null) { + await pending; + } + pending = this.#pendingTurnStartServerRequests; + this.#pendingTurnStartServerRequests = null; + if (pending !== null) { + await pending; + } + + pending = this.#pendingTurnStartUpdates; + this.#pendingTurnStartUpdates = null; + if (pending !== null) { + await pending; + } } catch (error) { - context.logger.warn("driver.openai.turn_start.cancellation_event.failed", { - message: error instanceof Error ? error.message : "cancellation event failed", - reason, - }); + throw new DriverTurnCancellationCleanupError( + `OpenAI pending turn cancellation cleanup failed: ${ + error instanceof Error ? error.message : "unknown cleanup error" + }`, + error, + ); } } - async #waitPendingTurnStartServerRequests(): Promise { - const pending = this.#pendingTurnStartServerRequests; - this.#pendingTurnStartServerRequests = null; - if (pending !== null) { - await pending; - } + cancelActiveTurn(context: AgentDriverContext, reason: string): Promise { + return this.#startCancellation(context, reason, true); } - async #waitPendingTurnStartCleanup(): Promise { - const pending = this.#pendingTurnStartCleanup; - this.#pendingTurnStartCleanup = null; - if (pending !== null) { - await pending; + #startCancellation( + context: AgentDriverContext, + reason: string, + publishTurnStartCancellation: boolean, + ): Promise { + if (this.#activeCancellationTask !== null) { + return this.#activeCancellationTask; } - } - async cancelActiveTurn(context: AgentDriverContext, reason: string): Promise { - await this.#cancelActiveTurn(context, reason, true); + const task = this.#cancelActiveTurn(context, reason, publishTurnStartCancellation) + .catch((error: unknown) => { + if (error instanceof DriverTurnCancellationCleanupError) { + throw error; + } + + throw new DriverTurnCancellationCleanupError( + `OpenAI cancelled turn cleanup failed: ${ + error instanceof Error ? error.message : "unknown cleanup error" + }`, + error, + ); + }) + .finally(() => { + if (this.#activeCancellationTask === task) { + this.#activeCancellationTask = null; + } + }); + this.#activeCancellationTask = task; + return task; } async #cancelActiveTurn( @@ -695,13 +940,21 @@ export class OpenAiAppServerDriverBackend implements AgentDriverBackend { void serverRequests.catch(() => {}); if (this.#turnStartInFlight) { + if (!publishTurnStartCancellation) { + await this.#publishAgentTasksClosed( + context, + "driver.openai.turn_start.stopped", + this.#turnStartRunId, + ); + } this.#pendingTurnStartCancellationEvent = publishTurnStartCancellation; this.#pendingTurnStartCancellationReason = reason; this.#pendingTurnStartServerRequests = serverRequests; const cleanup = this.#closeClientForCancellation(context, client, threadId, reason); this.#pendingTurnStartCleanup = cleanup; + this.#pendingTurnStartUpdates = cleanup.then(() => client.drainServerMessages()); + void this.#pendingTurnStartUpdates.catch(() => {}); await cleanup; - this.#events.releaseTurnState(); return; } @@ -714,8 +967,9 @@ export class OpenAiAppServerDriverBackend implements AgentDriverBackend { if (activeTurnIds.length === 0) { try { - await client.cleanBackgroundTerminals( - threadId, + await client.request( + "thread/backgroundTerminals/clean", + { threadId }, AbortSignal.timeout(OPENAI_BACKGROUND_TERMINAL_CLEAN_TIMEOUT_MS), ); await serverRequests; @@ -740,28 +994,48 @@ export class OpenAiAppServerDriverBackend implements AgentDriverBackend { this.#closeClientForCancellation(context, client, threadId, reason), ]); } catch (error) { - this.#events.rejectActiveTurns( - error instanceof Error ? error : new Error("OpenAI cancellation cleanup failed."), + const cleanupError = new DriverTurnCancellationCleanupError( + `OpenAI cancelled turn cleanup failed: ${ + error instanceof Error ? error.message : "unknown cleanup error" + }`, + error, ); - throw error; + this.#events.rejectActiveTurns(cleanupError); + throw cleanupError; } + const updatesDrained = client.drainServerMessages(); const cancellationEvents = Promise.all( - activeTurnIds.map((turnId) => this.#events.cancelTurn(context, turnId, reason)), - ); - void cancellationEvents.catch(() => {}); - - try { - await raceWithAbort( - cancellationEvents, - AbortSignal.timeout(OPENAI_TURN_CANCEL_EVENT_TIMEOUT_MS), + activeTurnIds.map((turnId) => + this.#events.cancelTurn(context, turnId, reason, () => updatesDrained), + ), + ).then(() => undefined); + const cancellationSettlementTask = cancellationEvents.catch((error: unknown) => { + const cleanupError = new DriverTurnCancellationCleanupError( + `OpenAI cancellation event delivery failed: ${ + error instanceof Error ? error.message : "unknown delivery error" + }`, + error, ); - } catch (error) { - context.logger.warn("driver.openai.turn.cancellation_event.failed", { - message: error instanceof Error ? error.message : "cancellation event failed", + this.#events.rejectActiveTurns(cleanupError); + throw cleanupError; + }); + this.#pendingCancellationSettlement = cancellationSettlementTask; + void cancellationSettlementTask.catch(() => {}); + const cancellationSettlement = await settlePromiseWithTimeout(cancellationSettlementTask, { + label: "OpenAI turn cancellation event delivery", + timeoutMs: OPENAI_TURN_CANCEL_EVENT_TIMEOUT_MS, + }); + + if (cancellationSettlement.status === "failed") { + throw cancellationSettlement.error; + } + + if (cancellationSettlement.status === "timed_out") { + context.logger.warn("driver.openai.turn.cancellation_event.pending", { + message: cancellationSettlement.error.message, reason, }); } - this.#events.releaseTurnState(); } #boundServerRequestCancellation( @@ -801,9 +1075,6 @@ export class OpenAiAppServerDriverBackend implements AgentDriverBackend { } } catch (error) { context.logger.error("driver.openai.cancel.client_stop.failed", error, { reason }); - this.#events.rejectActiveTurns( - error instanceof Error ? error : new Error("OpenAI app-server cleanup failed."), - ); throw error; } } @@ -815,8 +1086,20 @@ export class OpenAiAppServerDriverBackend implements AgentDriverBackend { try { signal.throwIfAborted(); - if (!this.#clientStopRequested) { - await raceWithAbort(this.#cancelActiveTurn(context, reason, false), signal); + if (this.#activeCancellationTask !== null) { + await this.#activeCancellationTask; + } else if (!this.#clientStopRequested) { + await this.#startCancellation(context, reason, false); + } + const cancellationSettlement = this.#pendingCancellationSettlement; + if (cancellationSettlement !== null) { + try { + await cancellationSettlement; + } finally { + if (this.#pendingCancellationSettlement === cancellationSettlement) { + this.#pendingCancellationSettlement = null; + } + } } } finally { this.#events.rejectActiveTurns(new DriverTurnCancelledError(reason)); diff --git a/src/runtimes/openai/app-server-event-bridge.ts b/src/runtimes/openai/app-server-event-bridge.ts index 0cf972b..d4c5738 100644 --- a/src/runtimes/openai/app-server-event-bridge.ts +++ b/src/runtimes/openai/app-server-event-bridge.ts @@ -1,34 +1,141 @@ import { DriverTurnCancelledError } from "../../core/driver-runtime-state"; import type { DriverEventInput } from "../../protocol/events"; -import type { RunId } from "../../protocol/id"; +import { createDriverId, driverIdTimeMs, type RunId } from "../../protocol/id"; import type { AgentDriverContext } from "../../core/agent-driver-backend"; -import { toOpenAiErrorMessage, toOpenAiSessionUsageSummary } from "./app-server-event-mapping"; +import { toOpenAiProtocolError } from "./app-server-event-mapping"; import { OpenAiItemState, OpenAiMessageState, OpenAiPlanState, + OpenAiSessionUsageState, OpenAiToolState, + type OpenAiTerminalOutcome, } from "./app-server-event-state"; -import { OpenAiAppServerItemEventBridge } from "./app-server-item-events"; -import { isRecord, readNonEmptyString, readRecord, readString } from "./app-server-json"; +import { createRuntimeSourceEventId, toRuntimePublicId } from "../runtime-public-id"; +import { DriverCompletedTerminalSupersededError } from "../driver-event-publisher"; +import { chunkOpenAiText, OpenAiAppServerItemEventBridge } from "./app-server-item-events"; +import { isRecord, readArray, readNonEmptyString, readRecord, readString } from "./app-server-json"; import type { JsonObject } from "./app-server-json"; -import { OpenAiTurnTracker } from "./app-server-turn-tracker"; -import type { - ServerNotificationMethod, - ServerNotificationParams, -} from "./generated/app-server-protocol"; +import { OpenAiTurnTracker, type OpenAiTurnAdmission } from "./app-server-turn-tracker"; +import type { ServerNotificationMethod } from "./app-server-protocol"; interface OpenAiAppServerEventBridgeOptions { beforeInterruptedTurn?(context: AgentDriverContext, turnId: string): Promise; push(context: AgentDriverContext, reason: string, events: DriverEventInput[]): Promise; + pushSession( + context: AgentDriverContext, + reason: string, + events: DriverEventInput[], + ): Promise; + pushTerminal( + context: AgentDriverContext, + reason: string, + closures: readonly DriverEventInput[], + terminal: DriverEventInput, + cancellationSignal?: AbortSignal, + ): Promise; requireThreadId(): string; } -function turnEventId(eventName: string, turnId: string): string { - return `openai.${eventName}:${turnId}`; +const MAX_OPENAI_TELEMETRY_FIELD_BYTES = 4 * 1_024; +const MAX_WORLD_WRITABLE_SAMPLE_PATHS = 16; + +function jsonUtf8Bytes(value: unknown): number { + return Buffer.byteLength(JSON.stringify(value) ?? "null", "utf8"); } -function turnEventFields(input: { eventName: string; runId?: RunId | undefined; turnId: string }): { +function toBoundedOpenAiTelemetry(payload: JsonObject, fields: readonly string[]): JsonObject { + const summary: JsonObject = { utf8Bytes: jsonUtf8Bytes(payload) }; + + for (const field of fields) { + const value = payload[field]; + + if (typeof value === "string") { + const utf8Bytes = Buffer.byteLength(value, "utf8"); + if (utf8Bytes <= MAX_OPENAI_TELEMETRY_FIELD_BYTES) { + summary[field] = value; + } else { + summary[`${field}Utf8Bytes`] = utf8Bytes; + } + continue; + } + + if (Array.isArray(value)) { + const utf8Bytes = jsonUtf8Bytes(value); + if (utf8Bytes <= MAX_OPENAI_TELEMETRY_FIELD_BYTES) { + summary[field] = value; + } else { + summary[`${field}Count`] = value.length; + summary[`${field}Utf8Bytes`] = utf8Bytes; + } + continue; + } + + if (isRecord(value)) { + const utf8Bytes = jsonUtf8Bytes(value); + if (utf8Bytes <= MAX_OPENAI_TELEMETRY_FIELD_BYTES) { + summary[field] = value; + } else { + summary[`${field}Utf8Bytes`] = utf8Bytes; + } + continue; + } + + if ( + value === null || + typeof value === "boolean" || + (typeof value === "number" && Number.isFinite(value)) + ) { + summary[field] = value; + } + } + + return summary; +} + +function toOpenAiUserFacingEvents( + method: + | "guardianWarning" + | "modelProvider/authRecoveryCompleted" + | "modelProvider/authRecoveryStarted" + | "warning", + message: string, + details: JsonObject = {}, +): DriverEventInput[] { + const chunks = chunkOpenAiText(message); + const warning = method === "guardianWarning" || method === "warning"; + + return chunks.map((content, index) => ({ + delivery: warning ? "lossless" : "best_effort", + kind: "message.added", + payload: { + ...details, + ...(chunks.length === 1 ? {} : { chunkCount: chunks.length, chunkIndex: index }), + content, + level: warning ? "warning" : "info", + messageId: createDriverId(), + role: "agent", + subtype: + method === "guardianWarning" + ? "guardian_warning" + : method === "warning" + ? "warning" + : method === "modelProvider/authRecoveryStarted" + ? "model_provider_auth_recovery_started" + : "model_provider_auth_recovery_completed", + }, + })); +} + +function turnEventId(eventName: string, publicTurnId: string): string { + return `openai.${eventName}:${publicTurnId}`; +} + +function turnEventFields(input: { + eventName: string; + publicTurnId: string; + runId?: RunId | undefined; +}): { native: { eventName: string; provider: string; turnId: string }; runId?: RunId | undefined; sourceEventId: string; @@ -37,13 +144,43 @@ function turnEventFields(input: { eventName: string; runId?: RunId | undefined; native: { eventName: input.eventName, provider: "openai", - turnId: input.turnId, + turnId: input.publicTurnId, }, ...(input.runId === undefined ? {} : { runId: input.runId }), - sourceEventId: turnEventId(input.eventName, input.turnId), + sourceEventId: turnEventId(input.eventName, input.publicTurnId), }; } +function turnClosureEvents(input: { + eventName: string; + events: readonly DriverEventInput[]; + publicTurnId: string; + runId?: RunId | undefined; +}): DriverEventInput[] { + const sourcePrefix = createRuntimeSourceEventId( + `openai.${input.eventName}.closure`, + "turn", + input.publicTurnId, + ); + return input.events.map((event, index) => { + const scopedEvent = { + ...event, + ...(event.runId === undefined && input.runId !== undefined ? { runId: input.runId } : {}), + }; + return { + ...scopedEvent, + sourceEventId: + event.sourceEventId ?? + createRuntimeSourceEventId( + "openai.derived", + sourcePrefix, + index, + JSON.stringify(scopedEvent), + ), + }; + }); +} + export class OpenAiAppServerEventBridge { readonly #itemEvents: OpenAiAppServerItemEventBridge; readonly #items = new OpenAiItemState(); @@ -51,7 +188,9 @@ export class OpenAiAppServerEventBridge { readonly #options: OpenAiAppServerEventBridgeOptions; readonly #plans = new OpenAiPlanState(); readonly #tools = new OpenAiToolState(); + readonly #turnStarts = new Map>(); readonly #turns = new OpenAiTurnTracker(); + readonly #usage = new OpenAiSessionUsageState(); constructor(options: OpenAiAppServerEventBridgeOptions) { this.#options = options; @@ -60,6 +199,7 @@ export class OpenAiAppServerEventBridge { messages: this.#messages, plans: this.#plans, push: (context, reason, events) => this.#push(context, reason, events), + pushSession: options.pushSession, tools: this.#tools, }); } @@ -68,9 +208,60 @@ export class OpenAiAppServerEventBridge { return this.#turns.activeTurnIds(); } + beginTurnAdmission(runId: RunId, signal?: AbortSignal): OpenAiTurnAdmission { + return this.#turns.admitRootTurn(runId, signal); + } + + bindTurnAdmission(admission: OpenAiTurnAdmission, turnId: string): void { + this.#turns.bindRootTurn(admission, turnId); + } + + armTurnAdmission(admission: OpenAiTurnAdmission): void { + this.#turns.armRootTurn(admission); + } + + claimTurnAdmission( + admission: OpenAiTurnAdmission, + turnId: string, + runId: RunId, + signal?: AbortSignal, + ): Promise { + return this.#turns.claimRootTurn(admission, turnId, runId, signal); + } + + releaseTurnAdmission(admission: OpenAiTurnAdmission): void { + this.#turns.releaseRootTurn(admission); + } + + releaseTurnAdmissionSelection(admission: OpenAiTurnAdmission): void { + this.#turns.releaseRootTurnSelection(admission); + } + + admittedTurnId(admission: OpenAiTurnAdmission): string | null { + return this.#turns.admittedTurnId(admission); + } + + hasTerminalTurn(turnId: string): boolean { + return this.#turns.hasTerminal(turnId); + } + + hasAdmittedTerminalTurn(): boolean { + return this.#turns.hasAdmittedTerminalTurn(); + } + + mapToolCallId(toolCallId: string): string { + return this.#items.publicId(toolCallId); + } + + publicTurnId(turnId: string): string { + return this.#items.publicId(turnId, "turn"); + } + clearActiveTurns(): void { + this.#turnStarts.clear(); this.#turns.clearActiveTurns(); this.#itemEvents.reset(); + this.#usage.reset(); } async cancelTurn( @@ -79,45 +270,73 @@ export class OpenAiAppServerEventBridge { reason: string, drainUpdates?: () => Promise, ): Promise { + await drainUpdates?.(); const runId = this.#turns.activeRunId(turnId); if (runId === null) { return; } - if (!this.#turns.rejectTurn(turnId, new DriverTurnCancelledError(reason))) { + if (!this.#turns.beginSettlement(turnId)) { return; } - await drainUpdates?.(); - await this.#push(context, "driver.openai.turn.cancelled", [ - { - ...turnEventFields({ eventName: "turn.cancel.requested", runId, turnId }), - kind: "run.cancel.requested", - payload: { - reason, - requestedBy: "user", - targetRunId: runId, - }, - }, - ...this.#itemEvents.finishOpen(), - { - ...turnEventFields({ eventName: "turn.cancelled", runId, turnId }), - kind: "run.cancelled", - payload: { - requestedBy: "user", - stopReason: "cancelled", + const publicTurnId = this.publicTurnId(turnId); + const completionClosuresCommitted = this.#turns.completionClosuresCommitted(turnId); + const cancelledClosures = completionClosuresCommitted + ? [] + : this.#itemEvents.terminalEvents({ kind: "cancelled" }); + const [taskClosure, ...itemClosures] = cancelledClosures; + + try { + await this.#options.pushTerminal( + context, + "driver.openai.turn.cancelled", + turnClosureEvents({ + eventName: "turn.cancelled", + events: [ + ...(taskClosure === undefined ? [] : [taskClosure]), + { + ...turnEventFields({ eventName: "turn.cancel.requested", publicTurnId, runId }), + kind: "run.cancel.requested", + payload: { + reason, + requestedBy: "user", + targetRunId: runId, + }, + }, + ...itemClosures, + ], + publicTurnId, + runId, + }), + { + ...turnEventFields({ eventName: "turn.cancelled", publicTurnId, runId }), + kind: "run.cancelled", + payload: { + requestedBy: "user", + stopReason: "cancelled", + }, }, - }, - ]); + ); + this.#finishSettlement(turnId, { + error: new DriverTurnCancelledError(reason), + kind: "failed", + }); + } catch (pushError) { + this.#turns.cancelSettlement(turnId); + throw pushError; + } } rejectTurn(turnId: string, error: Error): void { this.#turns.rejectTurn(turnId, error); + this.#usage.release(turnId); } rejectActiveTurns(error: Error): void { this.#turns.rejectActiveTurns(error); this.#itemEvents.reset(); + this.#usage.reset(); } async failActiveTurns(context: AgentDriverContext, error: Error): Promise { @@ -130,21 +349,30 @@ export class OpenAiAppServerEventBridge { } try { - await this.#push(context, "driver.openai.provider.failed", [ - ...this.#itemEvents.finishOpen(), + const publicTurnId = this.publicTurnId(turnId); + const protocolError = { + ...toOpenAiProtocolError({ message: error.message }), + code: "openai.provider_failed", + }; + await this.#options.pushTerminal( + context, + "driver.openai.provider.failed", + turnClosureEvents({ + eventName: "provider.failed", + events: this.#itemEvents.terminalEvents({ error: protocolError, kind: "failed" }), + publicTurnId, + runId, + }), { - ...turnEventFields({ eventName: "provider.failed", runId, turnId }), + ...turnEventFields({ eventName: "provider.failed", publicTurnId, runId }), kind: "run.failed", payload: { - error: { - code: "openai.provider_failed", - message: error.message, - }, + error: protocolError, recoverable: false, }, }, - ]); - this.#finishSettlement(turnId, { error, kind: "failed" }); + ); + this.#finishSettlement(turnId, { error: new Error(protocolError.message), kind: "failed" }); failed = true; } catch (pushError) { this.#turns.cancelSettlement(turnId); @@ -159,19 +387,26 @@ export class OpenAiAppServerEventBridge { this.#itemEvents.reset(); } - async trackTurn(turnId: string, runId: RunId): Promise { - return this.#turns.track(turnId, runId); + turnStartTerminalEvents( + outcome: OpenAiTerminalOutcome, + ): [DriverEventInput, ...DriverEventInput[]] { + return this.#itemEvents.terminalEvents(outcome); } - async handleNotification( + async trackTurn(turnId: string, runId: RunId, signal?: AbortSignal): Promise { + return this.#turns.track(turnId, runId, signal); + } + + async handleNotification( context: AgentDriverContext, - method: M, - params: ServerNotificationParams[M], + method: ServerNotificationMethod, + params: JsonObject, ): Promise { const payload = isRecord(params) ? params : {}; const turnId = readNonEmptyString(payload, "turnId") ?? readNonEmptyString(readRecord(payload, "turn"), "id"); + const notificationMethod = method; // Native Codex subagents own child threads and turns. Their direct message // and lifecycle frames are provider-internal activity, not authoritative @@ -184,18 +419,49 @@ export class OpenAiAppServerEventBridge { ) { return; } + const item = readRecord(payload, "item"); + const postTerminalSubAgentActivity = + notificationMethod === "item/completed" && + readString(item, "type") === "subAgentActivity" && + (readString(item, "kind") === "completed" || readString(item, "kind") === "interrupted"); if (turnId !== null && this.#turns.hasTerminal(turnId)) { + if (postTerminalSubAgentActivity) { + await this.#itemEvents.onPostTerminalSubAgentActivity(context, payload); + } + return; + } + if ( + turnId !== null && + (!(await this.#turns.awaitRootTurnAdmission(turnId)) || !this.#turns.acceptsRootTurn(turnId)) + ) { return; } - switch (method) { + switch (notificationMethod) { case "configWarning": { this.#onConfigWarning(context, payload); return; } - case "warning": { - this.#onWarning(context, payload); + case "warning": + case "guardianWarning": { + await this.#onWarning(context, payload, notificationMethod); + return; + } + case "modelProvider/authRecoveryStarted": + case "modelProvider/authRecoveryCompleted": { + const message = readString(payload, "message"); + if (message !== null) { + await this.#push( + context, + "driver.openai.model_provider.auth_recovery", + toOpenAiUserFacingEvents( + notificationMethod, + message, + toBoundedOpenAiTelemetry(payload, ["provider"]), + ), + ); + } return; } case "remoteControl/status/changed": { @@ -218,10 +484,38 @@ export class OpenAiAppServerEventBridge { await this.#onTurnStarted(context, payload); return; } + case "hook/started": + case "hook/completed": { + await this.#onHook(context, payload, notificationMethod); + return; + } case "item/started": { await this.#itemEvents.onItemStarted(context, payload); return; } + case "item/autoApprovalReview/started": + case "item/autoApprovalReview/completed": { + await this.#onAutoApprovalReview(context, payload, notificationMethod); + return; + } + case "autoApprovalReview/strictReviewRequired": { + await this.#push(context, "driver.openai.autoApprovalReview.strictReviewRequired", [ + { + delivery: "lossless", + kind: "message.added", + payload: { + ...toBoundedOpenAiTelemetry(payload, ["startedAtMs"]), + content: + "This request requires additional safety checks; tool calls may take longer.", + level: "warning", + messageId: createDriverId(), + role: "agent", + subtype: "strict_review_required", + }, + }, + ]); + return; + } case "item/agentMessage/delta": { await this.#itemEvents.onMessageDelta(context, payload); return; @@ -239,6 +533,7 @@ export class OpenAiAppServerEventBridge { return; } case "item/reasoning/textDelta": { + // Raw reasoning is intentionally private; summary notifications own visible thought output. return; } case "item/completed": { @@ -254,6 +549,14 @@ export class OpenAiAppServerEventBridge { await this.#itemEvents.onFilePatch(context, payload); return; } + case "item/commandExecution/terminalInteraction": { + await this.#onTerminalInteraction(context, payload); + return; + } + case "item/mcpToolCall/progress": { + await this.#onMcpToolProgress(context, payload); + return; + } case "thread/tokenUsage/updated": { await this.#onUsage(context, payload); return; @@ -270,23 +573,141 @@ export class OpenAiAppServerEventBridge { await this.#itemEvents.onTurnPlan(context, payload); return; } + case "mcpServer/startupStatus/updated": { + await this.#push(context, "driver.openai.mcp.server.updated", [ + { + delivery: "best_effort", + kind: "mcp.server.updated", + payload: toBoundedOpenAiTelemetry(payload, [ + "error", + "failureReason", + "name", + "status", + "threadId", + ]), + }, + ]); + return; + } + case "model/rerouted": + case "model/safetyBuffering/updated": { + await this.#push(context, "driver.openai.model.routing_updated", [ + { + delivery: "best_effort", + kind: "model.routing.updated", + payload: toBoundedOpenAiTelemetry(payload, [ + "fasterModel", + "fromModel", + "model", + "reason", + "reasons", + "showBufferingUi", + "threadId", + "toModel", + "turnId", + "useCases", + ]), + }, + ]); + return; + } + case "model/verification": { + await this.#push(context, "driver.openai.model.verification", [ + { + delivery: "best_effort", + kind: "model.verification.updated", + payload: toBoundedOpenAiTelemetry(payload, ["threadId", "turnId", "verifications"]), + }, + ]); + return; + } case "error": { this.#onRuntimeError(context, payload); return; } - default: { + case "deprecationNotice": { + const message = + readString(payload, "summary") ?? + readString(payload, "message") ?? + "OpenAI app-server warning."; + const messageUtf8Bytes = Buffer.byteLength(message, "utf8"); + context.logger.warn("driver.openai.notification.warning", { + method: notificationMethod, + ...(messageUtf8Bytes <= MAX_OPENAI_TELEMETRY_FIELD_BYTES ? { message } : {}), + messageUtf8Bytes, + }); + return; + } + case "windows/worldWritableWarning": { + await this.#onWorldWritableWarning(context, payload); + return; + } + case "turn/moderationMetadata": + case "skills/changed": + case "thread/name/updated": + case "thread/goal/updated": + case "thread/goal/cleared": + case "thread/reverted": + case "thread/queue/changed": + case "project/changed": + case "thread/project/updated": + case "thread/environment/connected": + case "thread/environment/disconnected": + case "account/updated": + case "account/rateLimits/updated": + case "windowsSandbox/setupCompleted": { + context.logger.debug("driver.openai.notification.handled_without_public_event", { + method: notificationMethod, + }); + return; + } + case "thread/archived": + case "thread/deleted": + case "thread/unarchived": + case "thread/closed": + case "command/exec/outputDelta": + case "process/outputDelta": + case "process/exited": + case "serverRequest/resolved": + case "mcpServer/oauthLogin/completed": + case "mcpServer/event/stream/notification": + case "app/list/updated": + case "externalAgentConfig/import/progress": + case "externalAgentConfig/import/completed": + case "fs/changed": + case "thread/compacted": + case "fuzzyFileSearch/sessionUpdated": + case "fuzzyFileSearch/sessionCompleted": + case "thread/realtime/started": + case "thread/realtime/itemAdded": + case "thread/realtime/item/started": + case "thread/realtime/item/transcript/delta": + case "thread/realtime/item/completed": + case "thread/realtime/transcript/delta": + case "thread/realtime/transcript/done": + case "thread/realtime/outputAudio/delta": + case "thread/realtime/sdp": + case "thread/realtime/error": + case "thread/realtime/closed": + case "account/login/completed": + // Explicitly unsupported request surfaces, deprecated duplicates, or transport bookkeeping. return; + default: { + const exhaustive: never = notificationMethod; + return exhaustive; } } } async publishNativeResumeRef(context: AgentDriverContext): Promise { + const threadId = this.#options.requireThreadId(); + const publicThreadId = toRuntimePublicId(threadId, "openai-thread"); await this.#push(context, "driver.openai.native_resume_ref.updated", [ { kind: "runtime.resume.updated", payload: { resumePointer: this.#options.requireThreadId(), - threadId: this.#options.requireThreadId(), + threadId: publicThreadId, }, visibility: "owner_debug", }, @@ -297,20 +718,217 @@ export class OpenAiAppServerEventBridge { context: AgentDriverContext, input: { runId?: RunId | undefined; turnId: string }, ): Promise { - if (!this.#turns.markTurnStarted(input.turnId)) { + const identityRunId = input.runId ?? context.ports.eventSink.currentRunId(); + if (identityRunId === null) { + return; + } + if (this.#turns.hasTurnStarted(input.turnId) || this.#turns.hasTerminal(input.turnId)) { + return; + } + + const existing = this.#turnStarts.get(input.turnId); + if (existing !== undefined) { + await existing; return; } - await this.#push(context, "driver.openai.turn.started", [ + const starting = (async () => { + await this.#push(context, "driver.openai.turn.started", [ + { + ...turnEventFields({ + eventName: "turn.started", + publicTurnId: this.publicTurnId(input.turnId), + runId: input.runId, + }), + kind: "run.started", + payload: { + startedAt: new Date(driverIdTimeMs(identityRunId)).toISOString(), + }, + }, + ]); + this.#turns.markTurnStarted(input.turnId); + })(); + this.#turnStarts.set(input.turnId, starting); + + try { + await starting; + } finally { + if (this.#turnStarts.get(input.turnId) === starting) { + this.#turnStarts.delete(input.turnId); + } + } + } + + async #onAutoApprovalReview( + context: AgentDriverContext, + params: JsonObject, + method: "item/autoApprovalReview/completed" | "item/autoApprovalReview/started", + ): Promise { + const turnId = readNonEmptyString(params, "turnId"); + const runId = turnId === null ? null : this.#turns.activeRunId(turnId); + const action = readRecord(params, "action"); + const review = readRecord(params, "review"); + const targetItemId = readNonEmptyString(params, "targetItemId"); + const actionSummary = + action === null + ? null + : { + ...toBoundedOpenAiTelemetry(action, [ + "approvalId", + "argv", + "command", + "cwd", + "files", + "host", + "permissions", + "port", + "processId", + "program", + "protocol", + "reason", + "server", + "source", + "target", + "toolName", + "toolTitle", + "connectorId", + "connectorName", + "type", + ]), + ...(typeof action["stdin"] === "string" + ? { stdinUtf8Bytes: Buffer.byteLength(action["stdin"], "utf8") } + : {}), + }; + + await this.#push(context, `driver.openai.${method.replaceAll("/", ".")}`, [ { - ...turnEventFields({ - eventName: "turn.started", - runId: input.runId, - turnId: input.turnId, - }), - kind: "run.started", + delivery: "best_effort", + kind: + method === "item/autoApprovalReview/started" + ? "permission.review.started" + : "permission.review.completed", + payload: { + ...toBoundedOpenAiTelemetry(params, [ + "completedAtMs", + "decisionSource", + "reviewId", + "startedAtMs", + "threadId", + "turnId", + ]), + ...(targetItemId === null ? {} : { targetItemId: this.mapToolCallId(targetItemId) }), + ...(actionSummary === null ? {} : { action: actionSummary }), + ...(review === null + ? {} + : { + review: toBoundedOpenAiTelemetry(review, [ + ...(readString(action, "type") === "writeStdin" ? [] : ["rationale"]), + "riskLevel", + "status", + "userAuthorization", + ]), + }), + }, + ...(runId === null ? {} : { runId }), + }, + ]); + } + + async #onHook( + context: AgentDriverContext, + params: JsonObject, + method: "hook/completed" | "hook/started", + ): Promise { + const turnId = readNonEmptyString(params, "turnId"); + const runId = turnId === null ? null : this.#turns.activeRunId(turnId); + const run = readRecord(params, "run"); + + await this.#push(context, `driver.openai.${method.replace("/", ".")}`, [ + { + delivery: "best_effort", + kind: method === "hook/started" ? "hook.started" : "hook.completed", + payload: { + ...toBoundedOpenAiTelemetry(params, ["threadId", "turnId"]), + ...(run === null + ? {} + : { + run: { + ...toBoundedOpenAiTelemetry(run, [ + "completedAt", + "displayOrder", + "durationMs", + "eventName", + "executionMode", + "handlerType", + "id", + "scope", + "source", + "sourcePath", + "startedAt", + "status", + "statusMessage", + ]), + entriesCount: readArray(run, "entries").length, + }, + }), + }, + ...(runId === null ? {} : { runId }), + }, + ]); + } + + async #onMcpToolProgress(context: AgentDriverContext, params: JsonObject): Promise { + const itemId = readNonEmptyString(params, "itemId"); + const message = readString(params, "message"); + + if (itemId === null || message === null) { + return; + } + const publicToolCallId = this.#tools.publicToolCallId(itemId); + + if (publicToolCallId === null) { + return; + } + const parentMessageId = this.#tools.parentMessage(itemId); + await this.#push(context, "driver.openai.mcp_tool.progress", [ + { + delivery: "best_effort", + kind: "tool.call.updated", + payload: { + ...(parentMessageId === null ? {} : { messageId: parentMessageId }), + ...toBoundedOpenAiTelemetry({ rawOutput: message }, ["rawOutput"]), + status: "running", + toolCallId: publicToolCallId, + }, + }, + ]); + } + + async #onTerminalInteraction(context: AgentDriverContext, params: JsonObject): Promise { + const itemId = readNonEmptyString(params, "itemId"); + const processId = readNonEmptyString(params, "processId"); + const threadId = readNonEmptyString(params, "threadId"); + const turnId = readNonEmptyString(params, "turnId"); + + if (itemId === null || processId === null || threadId === null || turnId === null) { + return; + } + const publicItemId = this.#tools.publicToolCallId(itemId); + + if (publicItemId === null) { + return; + } + + await this.#push(context, "driver.openai.terminal.interaction", [ + { + delivery: "best_effort", + kind: "shell.command.updated", payload: { - startedAt: new Date().toISOString(), + ...toBoundedOpenAiTelemetry({ processId }, ["processId"]), + itemId: publicItemId, + status: "running", + threadId: toRuntimePublicId(threadId, "openai-thread"), + turnId: this.publicTurnId(turnId), }, }, ]); @@ -368,24 +986,86 @@ export class OpenAiAppServerEventBridge { } } - #onWarning(context: AgentDriverContext, params: JsonObject): void { + async #onWarning( + context: AgentDriverContext, + params: JsonObject, + method: "guardianWarning" | "warning", + ): Promise { + const message = readString(params, "message") ?? "OpenAi app-server warning."; + const messageUtf8Bytes = Buffer.byteLength(message, "utf8"); + context.logger.warn("driver.openai.warning", { - message: readString(params, "message") ?? "OpenAi app-server warning.", + ...(messageUtf8Bytes <= MAX_OPENAI_TELEMETRY_FIELD_BYTES ? { message } : {}), + messageUtf8Bytes, threadIdPresent: readString(params, "threadId") !== null, }); + + await this.#push(context, `driver.openai.${method}`, toOpenAiUserFacingEvents(method, message)); + } + + async #onWorldWritableWarning(context: AgentDriverContext, params: JsonObject): Promise { + const rawSamplePaths = Array.isArray(params["samplePaths"]) + ? params["samplePaths"].filter((path): path is string => typeof path === "string") + : []; + const samplePaths: string[] = []; + let omittedSampleCount = 0; + + for (const path of rawSamplePaths) { + if ( + samplePaths.length < MAX_WORLD_WRITABLE_SAMPLE_PATHS && + Buffer.byteLength(path, "utf8") <= MAX_OPENAI_TELEMETRY_FIELD_BYTES + ) { + samplePaths.push(path); + } else { + omittedSampleCount += 1; + } + } + const rawExtraCount = params["extraCount"]; + const providerExtraCount = + typeof rawExtraCount === "number" && Number.isSafeInteger(rawExtraCount) && rawExtraCount >= 0 + ? rawExtraCount + : 0; + const extraCount = Math.min(Number.MAX_SAFE_INTEGER, providerExtraCount + omittedSampleCount); + const failedScan = params["failedScan"] === true; + const content = [ + "Windows sandbox protection is incomplete because world-writable directories were found.", + samplePaths.length === 0 ? null : `Affected paths:\n${samplePaths.join("\n")}`, + extraCount === 0 ? null : `${String(extraCount)} additional affected paths were omitted.`, + failedScan ? "The world-writable directory scan did not complete." : null, + ] + .filter((part): part is string => part !== null) + .join("\n\n"); + + context.logger.warn("driver.openai.windows.world_writable", { + extraCount, + failedScan, + samplePaths, + }); + await this.#push(context, "driver.openai.windows.world_writable", [ + { + delivery: "lossless", + kind: "message.added", + payload: { + content, + extraCount, + failedScan, + level: "warning", + messageId: createDriverId(), + role: "agent", + samplePaths, + subtype: "windows_world_writable_warning", + }, + }, + ]); } #onRuntimeError(context: AgentDriverContext, params: JsonObject): void { const error = readRecord(params, "error"); - const message = - readString(error, "message") ?? readString(params, "message") ?? "OpenAi app-server error."; - const additionalDetails = readString(error, "additionalDetails"); const turnId = readString(params, "turnId"); const willRetry = params["willRetry"] === true; context.logger.warn("driver.openai.error.awaiting_turn_completion", { - additionalDetails, - message, + ...toBoundedOpenAiTelemetry(error ?? params, ["additionalDetails", "message"]), threadIdPresent: readString(params, "threadId") !== null, turnIdPresent: turnId !== null, willRetry, @@ -406,7 +1086,13 @@ export class OpenAiAppServerEventBridge { throw new Error("OpenAi turn/completed requires a terminal turn status."); } - const runId = this.#turns.activeRunId(turnId) ?? undefined; + const pendingTurn = this.#turns.pendingTurnContext(turnId); + if (pendingTurn !== null && !this.#turns.hasTurnStarted(turnId)) { + throw new Error("OpenAi turn/completed arrived before its turn/started notification."); + } + const runId = this.#turns.activeRunId(turnId) ?? pendingTurn?.runId; + const cancellationSignal = + this.#turns.cancellationSignal(turnId) ?? pendingTurn?.cancellationSignal ?? null; await this.publishRunStarted(context, { runId, turnId }); await this.#itemEvents.onTurnItems(context, params, turnId); @@ -415,25 +1101,36 @@ export class OpenAiAppServerEventBridge { params, turnId, ); + const authoritativeFinalSnapshot = + authoritativeFinalMessage !== null && authoritativeFinalMessage.text.trim().length > 0 + ? authoritativeFinalMessage + : null; if (!this.#turns.beginSettlement(turnId)) { return; } + const publicTurnId = this.publicTurnId(turnId); - const error = turn ? readRecord(turn, "error") : null; try { if (status === "interrupted") { await this.#options.beforeInterruptedTurn?.(context, turnId); - await this.#push(context, "driver.openai.turn.interrupted", [ - ...this.#itemEvents.finishOpen(), + await this.#options.pushTerminal( + context, + "driver.openai.turn.interrupted", + turnClosureEvents({ + eventName: "turn.interrupted", + events: this.#itemEvents.terminalEvents({ kind: "cancelled" }), + publicTurnId, + runId, + }), { - ...turnEventFields({ eventName: "turn.interrupted", runId, turnId }), + ...turnEventFields({ eventName: "turn.interrupted", publicTurnId, runId }), kind: "run.cancelled", payload: { requestedBy: "provider", stopReason: "cancelled", }, }, - ]); + ); this.#finishSettlement(turnId, { error: new DriverTurnCancelledError("OpenAI turn was interrupted."), kind: "failed", @@ -442,30 +1139,31 @@ export class OpenAiAppServerEventBridge { } if (status === "failed") { - const message = toOpenAiErrorMessage( - readString(error, "message") ?? "OpenAi turn failed.", - readString(error, "additionalDetails"), - ); - await this.#push(context, "driver.openai.turn.failed", [ - ...this.#itemEvents.finishOpen(), + const error = toOpenAiProtocolError(readRecord(turn, "error")); + await this.#options.pushTerminal( + context, + "driver.openai.turn.failed", + turnClosureEvents({ + eventName: "turn.failed", + events: this.#itemEvents.terminalEvents({ error, kind: "failed" }), + publicTurnId, + runId, + }), { ...turnEventFields({ eventName: "turn.failed", + publicTurnId, runId, - turnId, }), kind: "run.failed", payload: { - error: { - code: "openai.turn_failed", - message, - }, - recoverable: false, + error, + recoverable: error.retryable, }, }, - ]); + ); this.#finishSettlement(turnId, { - error: new Error(message), + error: new Error(error.message), kind: "failed", }); return; @@ -485,29 +1183,46 @@ export class OpenAiAppServerEventBridge { }); } - await this.#push(context, "driver.openai.turn.completed", [ - ...this.#itemEvents.finishOpen(), + await this.#options.pushTerminal( + context, + "driver.openai.turn.completed", + turnClosureEvents({ + eventName: "turn.completed", + events: this.#itemEvents.terminalEvents({ kind: "completed" }), + publicTurnId, + runId, + }), { ...turnEventFields({ eventName: "turn.completed", + publicTurnId, runId, - turnId, }), kind: "run.completed", payload: { - ...(authoritativeFinalMessage === null + ...(authoritativeFinalSnapshot === null ? {} - : { - finalMessageId: authoritativeFinalMessage.id, - finalMessageText: authoritativeFinalMessage.text, - }), + : { finalMessageId: authoritativeFinalSnapshot.id }), stopReason: "end_turn", }, }, - ]); + cancellationSignal ?? undefined, + ); this.#finishSettlement(turnId, { kind: "completed" }); } catch (pushError) { this.#turns.cancelSettlement(turnId); + if ( + status === "completed" && + cancellationSignal?.aborted === true && + (pushError === cancellationSignal.reason || + (pushError instanceof DriverCompletedTerminalSupersededError && + pushError.cause === cancellationSignal.reason)) + ) { + if (pushError instanceof DriverCompletedTerminalSupersededError) { + this.#turns.markCompletionClosuresCommitted(turnId); + } + return; + } throw pushError; } } @@ -527,12 +1242,15 @@ export class OpenAiAppServerEventBridge { await this.#push(context, "driver.openai.turn.diff.updated", [ { + delivery: "best_effort", kind: "diagnostic.reported", payload: { - diff, + details: { + utf8Bytes: Buffer.byteLength(diff, "utf8"), + }, message: "OpenAI turn diff updated.", severity: "info", - turnId, + turnId: this.publicTurnId(turnId), }, runId, visibility: "owner_debug", @@ -548,26 +1266,33 @@ export class OpenAiAppServerEventBridge { return; } - const runId = this.#turns.activeRunId(turnId) ?? undefined; + const runId = this.#turns.activeRunId(turnId) ?? this.#turns.pendingTurnContext(turnId)?.runId; await this.publishRunStarted(context, { runId, turnId }); } async #onUsage(context: AgentDriverContext, params: JsonObject): Promise { const turnId = readNonEmptyString(params, "turnId"); - const runId = turnId === null ? null : this.#turns.activeRunId(turnId); - if (runId === null) { + if (turnId === null) { + return; + } + const runId = this.#turns.activeRunId(turnId); + const update = this.#usage.prepareUpdate(runId === null ? null : turnId, params); + + if (runId === null || update.usage === null) { + update.commit(); return; } await this.#push(context, "driver.openai.usage.updated", [ { kind: "usage.updated", - payload: toOpenAiSessionUsageSummary(params), + payload: update.usage, runId, }, ]); + update.commit(); } async #push( @@ -588,5 +1313,6 @@ export class OpenAiAppServerEventBridge { ): void { this.#turns.finishSettlement(turnId, terminalTurn); this.#itemEvents.reset(); + this.#usage.release(turnId); } } diff --git a/src/runtimes/openai/app-server-event-mapping.ts b/src/runtimes/openai/app-server-event-mapping.ts index c011b90..00f7d83 100644 --- a/src/runtimes/openai/app-server-event-mapping.ts +++ b/src/runtimes/openai/app-server-event-mapping.ts @@ -1,13 +1,52 @@ -import { readRecord } from "./app-server-json"; +import type { ProtocolError } from "../../contract"; +import { isRecord, readString } from "./app-server-json"; import type { JsonObject } from "./app-server-json"; +const retryableCodexErrors = new Set([ + "httpConnectionFailed", + "responseStreamConnectionFailed", + "internalServerError", + "rateLimitExceeded", + "responseStreamDisconnected", +]); +const MAX_OPENAI_ERROR_TEXT_BYTES = 16 * 1_024; + +type OpenAiMisalignmentDetails = { + readonly misalignmentDetailedExplanation?: string; + readonly misalignmentDetailedExplanationUtf8Bytes?: number; + readonly misalignmentErrorType?: string; + readonly misalignmentErrorTypeUtf8Bytes?: number; + readonly misalignmentSteerMessage?: string; + readonly misalignmentSteerMessageUtf8Bytes?: number; +}; + +function boundOpenAiErrorText( + text: string, + label: string, +): { + readonly text: string; + readonly utf8Bytes: number | null; +} { + const utf8Bytes = Buffer.byteLength(text, "utf8"); + + return utf8Bytes <= MAX_OPENAI_ERROR_TEXT_BYTES + ? { text, utf8Bytes: null } + : { + text: `${label} was omitted because it contained ${String(utf8Bytes)} UTF-8 bytes.`, + utf8Bytes, + }; +} + function readNonNegativeNumber(value: JsonObject | null, key: string): number | null { const entry = value?.[key]; - return typeof entry === "number" && Number.isFinite(entry) && entry >= 0 ? entry : null; + return typeof entry === "number" && Number.isSafeInteger(entry) && entry >= 0 ? entry : null; } -export function toOpenAiErrorMessage(message: string, additionalDetails: string | null): string { +function toOpenAiErrorMessage( + message: string, + additionalDetails: string | null | undefined, +): string { const details = additionalDetails?.trim(); if (!details || details === message) { @@ -17,29 +56,132 @@ export function toOpenAiErrorMessage(message: string, additionalDetails: string return `${message}\n${details}`; } -export function toOpenAiSessionUsageSummary(params: JsonObject) { - const tokenUsage = readRecord(params, "tokenUsage"); - const usage = - readRecord(tokenUsage, "last") ?? - readRecord(tokenUsage, "total") ?? - readRecord(params, "usage") ?? - params; +function toOpenAiMisalignmentDetails(error: JsonObject): OpenAiMisalignmentDetails | null { + const misalignment = isRecord(error["misalignment"]) ? error["misalignment"] : null; + if (misalignment === null) { + return null; + } + + const errorType = readString(misalignment, "errorType"); + const detailedExplanation = readString(misalignment, "detailedExplanation"); + const steer = isRecord(misalignment["steer"]) ? misalignment["steer"] : null; + const steerMessage = readString(steer, "message"); + const boundedErrorType = + errorType === null ? null : boundOpenAiErrorText(errorType, "OpenAI misalignment error type"); + const boundedExplanation = + detailedExplanation === null + ? null + : boundOpenAiErrorText(detailedExplanation, "OpenAI misalignment explanation"); + const boundedSteerMessage = + steerMessage === null + ? null + : boundOpenAiErrorText(steerMessage, "OpenAI misalignment steering message"); + const details: OpenAiMisalignmentDetails = { + ...(boundedErrorType?.utf8Bytes === null + ? { misalignmentErrorType: boundedErrorType.text } + : {}), + ...(boundedErrorType === null || boundedErrorType.utf8Bytes === null + ? {} + : { misalignmentErrorTypeUtf8Bytes: boundedErrorType.utf8Bytes }), + ...(boundedExplanation?.utf8Bytes === null + ? { misalignmentDetailedExplanation: boundedExplanation.text } + : {}), + ...(boundedExplanation === null || boundedExplanation.utf8Bytes === null + ? {} + : { misalignmentDetailedExplanationUtf8Bytes: boundedExplanation.utf8Bytes }), + ...(boundedSteerMessage?.utf8Bytes === null + ? { misalignmentSteerMessage: boundedSteerMessage.text } + : {}), + ...(boundedSteerMessage === null || boundedSteerMessage.utf8Bytes === null + ? {} + : { misalignmentSteerMessageUtf8Bytes: boundedSteerMessage.utf8Bytes }), + }; + + return Object.keys(details).length === 0 ? null : details; +} + +function classifyOpenAiError(info: unknown): { + codexErrorInfo?: string; + httpStatusCode?: number; + retryable: boolean; + turnKind?: string; +} { + if (info == null) { + return { retryable: false }; + } + + const [codexErrorInfo, metadata] = + typeof info === "string" + ? [info, null] + : (Object.entries(isRecord(info) ? info : {})[0] ?? ["unknown", null]); + const metadataRecord = isRecord(metadata) ? metadata : null; + const httpStatusCode = readNonNegativeNumber(metadataRecord, "httpStatusCode"); + const turnKind = metadataRecord?.["turnKind"]; + + return { + codexErrorInfo, + ...(httpStatusCode === null ? {} : { httpStatusCode }), + retryable: retryableCodexErrors.has(codexErrorInfo), + ...(typeof turnKind === "string" ? { turnKind } : {}), + }; +} + +export function toOpenAiProtocolError(error: JsonObject | null): ProtocolError { + if (error === null) { + return { + code: "openai.turn_failed", + message: "OpenAI turn failed.", + retryable: false, + }; + } + + const rawMessage = readString(error, "message") ?? "OpenAI turn failed."; + const rawAdditionalDetails = readString(error, "additionalDetails"); + const message = boundOpenAiErrorText(rawMessage, "OpenAI provider error message"); + const additionalDetails = + rawAdditionalDetails === null + ? null + : boundOpenAiErrorText(rawAdditionalDetails, "OpenAI provider error details"); + const { retryable, ...classification } = classifyOpenAiError(error["codexErrorInfo"]); + const misalignment = toOpenAiMisalignmentDetails(error); + const details = { + ...(additionalDetails === null ? {} : { additionalDetails: additionalDetails.text }), + ...(additionalDetails?.utf8Bytes === null || additionalDetails === null + ? {} + : { additionalDetailsUtf8Bytes: additionalDetails.utf8Bytes }), + ...classification, + ...misalignment, + ...(message.utf8Bytes === null ? {} : { messageUtf8Bytes: message.utf8Bytes }), + }; + + return { + code: "openai.turn_failed", + ...(Object.keys(details).length === 0 ? {} : { details }), + message: toOpenAiErrorMessage(message.text, additionalDetails?.text), + retryable, + }; +} + +export function toOpenAiSessionUsageSummary(input: { + contextWindow: number | null; + usage: JsonObject | null; + used: number | null; +}) { + const { usage } = input; return { cachedReadTokens: readNonNegativeNumber(usage, "cachedInputTokens"), - cachedWriteTokens: null, + cachedWriteTokens: readNonNegativeNumber(usage, "cacheWriteInputTokens"), costAmount: null, costCurrency: null, inputTokens: readNonNegativeNumber(usage, "inputTokens"), outputTokens: readNonNegativeNumber(usage, "outputTokens"), - size: null, + size: input.contextWindow, source: "session_update" as const, - thoughtTokens: - readNonNegativeNumber(usage, "reasoningOutputTokens") ?? - readNonNegativeNumber(usage, "reasoningTokens"), + thoughtTokens: readNonNegativeNumber(usage, "reasoningOutputTokens"), totalTokens: readNonNegativeNumber(usage, "totalTokens"), usageContract: "openai_runtime_total_with_cached_breakdown" as const, - used: null, + used: input.used, }; } diff --git a/src/runtimes/openai/app-server-event-state.ts b/src/runtimes/openai/app-server-event-state.ts index b7cac9a..74cf914 100644 --- a/src/runtimes/openai/app-server-event-state.ts +++ b/src/runtimes/openai/app-server-event-state.ts @@ -1,7 +1,12 @@ import type { DriverEventInput } from "../../protocol/events"; import type { MessageId } from "../../protocol/id"; import type { AgentDriverContext } from "../../core/agent-driver-backend"; -import { RuntimeAssistantMessageIdIndex } from "../runtime-turn-transcript"; +import type { ProtocolError } from "../../contract"; +import { createRuntimeSourceEventId, toRuntimePublicId } from "../runtime-public-id"; +import { createRuntimeAssistantMessageId } from "../runtime-turn-transcript"; +import { toOpenAiSessionUsageSummary } from "./app-server-event-mapping"; +import { readRecord } from "./app-server-json"; +import type { JsonObject } from "./app-server-json"; export type OpenAiEventPush = ( context: AgentDriverContext, @@ -9,12 +14,123 @@ export type OpenAiEventPush = ( events: DriverEventInput[], ) => Promise; +export type OpenAiTerminalOutcome = + | { kind: "cancelled" } + | { kind: "completed" } + | { error: ProtocolError; kind: "failed" }; + +export type OpenAiMessagePhase = "commentary" | "final" | null; + +const usageKeys = [ + "cacheWriteInputTokens", + "cachedInputTokens", + "inputTokens", + "outputTokens", + "reasoningOutputTokens", + "totalTokens", +] as const; +export const MAX_OPENAI_DURABLE_EVENT_BYTES = 1_020 * 1_024; + +export function assertOpenAiDurableEventFits(event: DriverEventInput, subject: string): void { + const bytes = Buffer.byteLength(JSON.stringify(event), "utf8"); + + if (bytes > MAX_OPENAI_DURABLE_EVENT_BYTES) { + throw new RangeError( + `OpenAI ${subject} exceeds durable event capacity (${String(bytes)} UTF-8 bytes).`, + ); + } +} + +type OpenAiTokenUsage = Partial>; + +function readUsage(value: JsonObject | null): OpenAiTokenUsage { + return Object.fromEntries( + usageKeys.flatMap((key) => { + const entry = value?.[key]; + return typeof entry === "number" && Number.isSafeInteger(entry) && entry >= 0 + ? [[key, entry]] + : []; + }), + ); +} + +function subtractUsage(total: OpenAiTokenUsage, baseline: OpenAiTokenUsage): OpenAiTokenUsage { + return Object.fromEntries( + usageKeys.flatMap((key) => { + const value = total[key]; + return value === undefined ? [] : [[key, Math.max(0, value - (baseline[key] ?? 0))]]; + }), + ); +} + +function monotonicUsage(previous: OpenAiTokenUsage, next: OpenAiTokenUsage): OpenAiTokenUsage { + return Object.fromEntries( + usageKeys.flatMap((key) => { + const value = next[key] ?? previous[key]; + return value === undefined ? [] : [[key, Math.max(value, previous[key] ?? 0)]]; + }), + ); +} + +export class OpenAiSessionUsageState { + #lastTotal: OpenAiTokenUsage | null = null; + readonly #turns = new Map(); + + prepareUpdate(turnId: string | null, params: JsonObject) { + const tokenUsage = readRecord(params, "tokenUsage"); + const total = readUsage(readRecord(tokenUsage, "total")); + const last = readUsage(readRecord(tokenUsage, "last")); + const previous = turnId === null ? undefined : this.#turns.get(turnId); + const baseline = previous?.baseline ?? this.#lastTotal ?? subtractUsage(total, last); + const current = monotonicUsage(previous?.current ?? {}, subtractUsage(total, baseline)); + + if (turnId === null) { + return { + commit: () => { + this.#lastTotal = monotonicUsage(this.#lastTotal ?? {}, total); + }, + usage: null, + }; + } + const changed = usageKeys.some((key) => (current[key] ?? 0) > (previous?.current[key] ?? 0)); + + return { + commit: () => { + this.#lastTotal = monotonicUsage(this.#lastTotal ?? {}, total); + this.#turns.set(turnId, { baseline, current }); + }, + usage: changed + ? toOpenAiSessionUsageSummary({ + contextWindow: + typeof tokenUsage?.["modelContextWindow"] === "number" && + Number.isSafeInteger(tokenUsage["modelContextWindow"]) && + tokenUsage["modelContextWindow"] >= 0 + ? tokenUsage["modelContextWindow"] + : null, + usage: current, + used: last["totalTokens"] ?? null, + }) + : null, + }; + } + + release(turnId: string): void { + this.#turns.delete(turnId); + } + + reset(): void { + this.#turns.clear(); + this.#lastTotal = null; + } +} + export class OpenAiMessageState { readonly #completedSnapshots = new Map< string, { itemId: string; messageId: MessageId; + phase: OpenAiMessagePhase; sequence: number; text: string; turnId: string; @@ -22,17 +138,16 @@ export class OpenAiMessageState { >(); readonly #ended = new Set(); readonly #itemSequences = new Map(); - readonly #itemByMessageId = new Map(); readonly #itemMessageIds = new Map(); readonly #reasoningEnded = new Set(); + readonly #reasoningParts = new Map>(); readonly #reasoningStarted = new Set(); + readonly #reasoningTextById = new Map(); + readonly #starting = new Map>(); readonly #started = new Set(); readonly #textById = new Map(); - readonly #turnByMessageId = new Map(); - readonly #turnMessages = new RuntimeAssistantMessageIdIndex(); - readonly #turnMessageIds = new Map(); - readonly #turnMessageSequences = new Map(); readonly #turnNextItemSequences = new Map(); + readonly #turnParentMessageIds = new Map(); appendText(messageId: MessageId, delta: string): void { if (delta.length === 0 || this.#ended.has(messageId)) { @@ -50,47 +165,89 @@ export class OpenAiMessageState { this.#textById.set(messageId, text); } - ensureReasoning(messageId: string, events: DriverEventInput[]): void { + ensureReasoning(messageId: string, events: DriverEventInput[], commit = true): void { if (this.#reasoningEnded.has(messageId) || this.#reasoningStarted.has(messageId)) { return; } - this.#reasoningStarted.add(messageId); + if (commit) { + this.#reasoningStarted.add(messageId); + } events.push({ kind: "thought.started", payload: { channel: "summary", thoughtId: messageId, }, + sourceEventId: `openai.thought.started:${messageId}`, }); } - finishOpen(): DriverEventInput[] { + appendReasoningText(messageId: string, delta: string): void { + if (delta.length === 0 || this.#reasoningEnded.has(messageId)) { + return; + } + + this.#reasoningTextById.set( + messageId, + `${this.#reasoningTextById.get(messageId) ?? ""}${delta}`, + ); + } + + beginReasoningPart(messageId: string, summaryIndex: number, commit = true): boolean { + if (this.#reasoningEnded.has(messageId)) { + return false; + } + + const parts = this.#reasoningParts.get(messageId) ?? new Set(); + + if (parts.has(summaryIndex)) { + return false; + } + + if (commit) { + this.#reasoningParts.set(messageId, parts); + parts.add(summaryIndex); + } + return true; + } + + currentReasoningText(messageId: string): string { + return this.#reasoningTextById.get(messageId) ?? ""; + } + + terminalEvents(outcome: OpenAiTerminalOutcome): DriverEventInput[] { const events: DriverEventInput[] = []; for (const messageId of this.#started) { - this.markEnded(messageId); + events.push( + outcome.kind === "completed" + ? { + kind: "message.completed", + payload: { messageId, role: "agent" }, + } + : outcome.kind === "cancelled" + ? { + kind: "message.cancelled", + payload: { messageId, role: "agent" }, + } + : { + kind: "message.failed", + payload: { error: outcome.error, messageId, role: "agent" }, + }, + ); + } + + for (const thoughtId of this.#reasoningStarted) { events.push({ - kind: "message.completed", + kind: outcome.kind === "completed" ? "thought.completed" : "thought.cancelled", payload: { - messageId, - role: "agent", + channel: "summary", + thoughtId, }, }); } - for (const thoughtId of this.#reasoningStarted) { - if (this.markReasoningEnded(thoughtId)) { - events.push({ - kind: "thought.completed", - payload: { - channel: "summary", - thoughtId, - }, - }); - } - } - return events; } @@ -99,19 +256,20 @@ export class OpenAiMessageState { turnId: string, push: OpenAiEventPush, ): Promise { - const existing = this.#turnMessageIds.get(turnId); + const existing = this.#turnParentMessageIds.get(turnId); if (existing !== undefined) { await this.ensureStarted(context, existing, push); return existing; } - const nextSequence = (this.#turnMessageSequences.get(turnId) ?? 0) + 1; - this.#turnMessageSequences.set(turnId, nextSequence); - const generated = this.#turnMessages.getOrCreate(`${turnId}:${nextSequence}`); - this.#turnMessageIds.set(turnId, generated); - this.#turnByMessageId.set(generated, turnId); + const generated = createRuntimeAssistantMessageId( + context.payload.execution.run.sessionId, + "openai-message", + `turn:${JSON.stringify(turnId)}`, + ); await this.ensureStarted(context, generated, push); + this.#turnParentMessageIds.set(turnId, generated); return generated; } @@ -120,62 +278,83 @@ export class OpenAiMessageState { input: { itemId: string; turnId: string }, push: OpenAiEventPush, ): Promise { - const itemKey = `${input.turnId}:${input.itemId}`; - this.#observeItem(itemKey, input.turnId); + const itemKey = JSON.stringify([input.turnId, input.itemId]); const existing = this.#itemMessageIds.get(itemKey); if (existing !== undefined) { - if (!this.#ended.has(existing)) { - this.#turnMessageIds.set(input.turnId, existing); - } await this.ensureStarted(context, existing, push); return existing; } - const activeMessageId = this.#turnMessageIds.get(input.turnId); - const messageId = - activeMessageId !== undefined && - !this.#ended.has(activeMessageId) && - !this.#itemByMessageId.has(activeMessageId) - ? activeMessageId - : this.#turnMessages.getOrCreate(`item:${itemKey}`); + const messageId = createRuntimeAssistantMessageId( + context.payload.execution.run.sessionId, + "openai-message", + `item:${itemKey}`, + ); - this.#itemMessageIds.set(itemKey, messageId); - this.#itemByMessageId.set(messageId, itemKey); - this.#turnMessageIds.set(input.turnId, messageId); - this.#turnByMessageId.set(messageId, input.turnId); await this.ensureStarted(context, messageId, push); + this.#observeItem(itemKey, input.turnId); + this.#itemMessageIds.set(itemKey, messageId); return messageId; } recordSnapshot(input: { itemId: string; messageId: MessageId; + phase: OpenAiMessagePhase; text: string; turnId: string; - }): void { - const itemKey = `${input.turnId}:${input.itemId}`; + }): boolean { + const itemKey = JSON.stringify([input.turnId, input.itemId]); const sequence = this.#observeItem(itemKey, input.turnId); + if (!this.needsSnapshot(input)) { + return false; + } this.#completedSnapshots.set(itemKey, { ...input, sequence }); + return true; + } + + needsSnapshot(input: { + itemId: string; + messageId: MessageId; + phase: OpenAiMessagePhase; + text: string; + turnId: string; + }): boolean { + const previous = this.#completedSnapshots.get(JSON.stringify([input.turnId, input.itemId])); + return ( + previous?.messageId !== input.messageId || + previous.phase !== input.phase || + previous.text !== input.text + ); } finalSnapshot(turnId: string): { id: MessageId; text: string } | null { + const turnSnapshots = [...this.#completedSnapshots.values()].filter( + (snapshot) => snapshot.turnId === turnId, + ); + const hasExplicitPhase = turnSnapshots.some((snapshot) => snapshot.phase !== null); let finalSnapshot: | { itemId: string; messageId: MessageId; + phase: OpenAiMessagePhase; sequence: number; text: string; turnId: string; } | undefined; - for (const snapshot of this.#completedSnapshots.values()) { + for (const snapshot of turnSnapshots) { if ( - snapshot.turnId === turnId && - (finalSnapshot === undefined || snapshot.sequence > finalSnapshot.sequence) + (hasExplicitPhase && snapshot.phase !== "final") || + (!hasExplicitPhase && snapshot.phase !== null) ) { + continue; + } + + if (finalSnapshot === undefined || snapshot.sequence > finalSnapshot.sequence) { finalSnapshot = snapshot; } } @@ -194,16 +373,35 @@ export class OpenAiMessageState { return; } - this.#started.add(messageId); - await push(context, "driver.openai.message.started", [ - { - kind: "message.started", - payload: { - messageId, - role: "agent", + const existing = this.#starting.get(messageId); + + if (existing !== undefined) { + await existing; + return; + } + + const starting = (async () => { + await push(context, "driver.openai.message.started", [ + { + kind: "message.started", + payload: { + messageId, + role: "agent", + }, + sourceEventId: `openai.message.started:${messageId}`, }, - }, - ]); + ]); + this.#started.add(messageId); + })(); + this.#starting.set(messageId, starting); + + try { + await starting; + } finally { + if (this.#starting.get(messageId) === starting) { + this.#starting.delete(messageId); + } + } } markEnded(messageId: MessageId): boolean { @@ -213,16 +411,6 @@ export class OpenAiMessageState { this.#ended.add(messageId); this.#started.delete(messageId); - const turnId = this.#turnByMessageId.get(messageId); - - if (turnId !== undefined) { - this.#turnByMessageId.delete(messageId); - - if (this.#turnMessageIds.get(turnId) === messageId) { - this.#turnMessageIds.delete(turnId); - } - } - return true; } @@ -234,6 +422,18 @@ export class OpenAiMessageState { return this.#reasoningEnded.has(messageId); } + reasoningId(context: AgentDriverContext, itemId: string): MessageId { + return createRuntimeAssistantMessageId( + context.payload.execution.run.sessionId, + "openai-reasoning", + itemId, + ); + } + + isReasoningStarted(messageId: string): boolean { + return this.#reasoningStarted.has(messageId); + } + markReasoningEnded(messageId: string): boolean { if (this.#reasoningEnded.has(messageId) || !this.#reasoningStarted.has(messageId)) { return false; @@ -245,24 +445,23 @@ export class OpenAiMessageState { } messageForTurn(turnId: string): MessageId | null { - return this.#turnMessageIds.get(turnId) ?? null; + return this.#turnParentMessageIds.get(turnId) ?? null; } reset(): void { this.#completedSnapshots.clear(); this.#ended.clear(); this.#itemSequences.clear(); - this.#itemByMessageId.clear(); this.#itemMessageIds.clear(); this.#reasoningEnded.clear(); + this.#reasoningParts.clear(); this.#reasoningStarted.clear(); + this.#reasoningTextById.clear(); + this.#starting.clear(); this.#started.clear(); this.#textById.clear(); - this.#turnByMessageId.clear(); - this.#turnMessages.reset(); - this.#turnMessageIds.clear(); - this.#turnMessageSequences.clear(); this.#turnNextItemSequences.clear(); + this.#turnParentMessageIds.clear(); } #observeItem(itemKey: string, turnId: string): number { @@ -282,6 +481,10 @@ export class OpenAiMessageState { export class OpenAiItemState { readonly #completed = new Set(); + isCompleted(itemId: string): boolean { + return this.#completed.has(itemId); + } + markCompleted(itemId: string): boolean { if (this.#completed.has(itemId)) { return false; @@ -291,16 +494,28 @@ export class OpenAiItemState { return true; } + publicId(nativeId: string, namespace: "item" | "turn" = "item"): string { + return toRuntimePublicId(nativeId, namespace === "item" ? "openai-item" : "openai-turn"); + } + reset(): void { this.#completed.clear(); } } export class OpenAiToolState { - readonly #parentMessages = new Map(); + readonly #parentMessages = new Map< + string, + { parentMessageId: MessageId; publicToolCallId: string } + >(); + readonly #starting = new Map>(); parentMessage(toolCallId: string): MessageId | null { - return this.#parentMessages.get(toolCallId) ?? null; + return this.#parentMessages.get(toolCallId)?.parentMessageId ?? null; + } + + publicToolCallId(toolCallId: string): string | null { + return this.#parentMessages.get(toolCallId)?.publicToolCallId ?? null; } async ensureStarted( @@ -308,60 +523,102 @@ export class OpenAiToolState { push: OpenAiEventPush, input: { parentMessageId: MessageId; + publicToolCallId: string; reason: string; + sourceScope: string; toolCallId: string; toolCallName: string; }, ): Promise { - const started = this.#parentMessages.has(input.toolCallId); - this.#parentMessages.set(input.toolCallId, input.parentMessageId); + if (this.#parentMessages.has(input.toolCallId)) { + return; + } + + const existing = this.#starting.get(input.toolCallId); - if (started) { + if (existing !== undefined) { + await existing; return; } - await push(context, input.reason, [ - { - kind: "item.started", - payload: { - itemId: input.toolCallId, - itemType: "tool_call", - parentMessageId: input.parentMessageId, - title: input.toolCallName, + const starting = (async () => { + const events: DriverEventInput[] = [ + { + kind: "item.started", + payload: { + itemId: input.publicToolCallId, + itemType: "tool_call", + parentMessageId: input.parentMessageId, + title: input.toolCallName, + }, + sourceEventId: createRuntimeSourceEventId( + "openai.item.started", + input.sourceScope, + input.publicToolCallId, + ), }, - }, - { - kind: "tool.call.updated", - payload: { - kind: "tool", - parentMessageId: input.parentMessageId, - status: "running", - title: input.toolCallName, - toolCallId: input.toolCallId, + { + kind: "tool.call.updated", + payload: { + kind: "tool", + parentMessageId: input.parentMessageId, + status: "running", + title: input.toolCallName, + toolCallId: input.publicToolCallId, + }, + sourceEventId: createRuntimeSourceEventId( + "openai.tool.started", + input.sourceScope, + input.publicToolCallId, + ), }, - }, - ]); + ]; + for (const event of events) { + assertOpenAiDurableEventFits(event, "tool start"); + } + await push(context, input.reason, events); + this.#parentMessages.set(input.toolCallId, { + parentMessageId: input.parentMessageId, + publicToolCallId: input.publicToolCallId, + }); + })(); + this.#starting.set(input.toolCallId, starting); + + try { + await starting; + } finally { + if (this.#starting.get(input.toolCallId) === starting) { + this.#starting.delete(input.toolCallId); + } + } } - failOpen(): DriverEventInput[] { - const events = [...this.#parentMessages.keys()].flatMap((toolCallId) => [ - { - kind: "tool.call.updated", - payload: { - status: "failed", - toolCallId, + terminalEvents(outcome: OpenAiTerminalOutcome): DriverEventInput[] { + const status = + outcome.kind === "completed" + ? "completed" + : outcome.kind === "cancelled" + ? "cancelled" + : "failed"; + const events = [...this.#parentMessages.values()].flatMap( + ({ publicToolCallId }) => [ + { + kind: "tool.call.updated", + payload: { + status, + toolCallId: publicToolCallId, + }, }, - }, - { - kind: "item.completed", - payload: { - itemId: toolCallId, - itemType: "tool_call", - status: "failed", + { + kind: "item.completed", + payload: { + itemId: publicToolCallId, + itemType: "tool_call", + status, + }, }, - }, - ]); - this.#parentMessages.clear(); + ], + ); return events; } @@ -371,6 +628,7 @@ export class OpenAiToolState { reset(): void { this.#parentMessages.clear(); + this.#starting.clear(); } } @@ -385,11 +643,29 @@ export class OpenAiPlanState { }); } - createUpdatedEvent(): DriverEventInput { + createDeltaEvent(itemId: string, delta: string): DriverEventInput { + const plans = new Map(this.#plans); + const current = plans.get(itemId); + plans.set(itemId, { + content: `${current?.content ?? ""}${delta}`, + status: "in_progress", + }); + return this.#createEvent(plans); + } + + createCompletedEvent(itemId: string, content: string): DriverEventInput { + const plans = new Map(this.#plans); + plans.set(itemId, { content, status: "completed" }); + return this.#createEvent(plans); + } + + #createEvent( + plans: ReadonlyMap, + ): DriverEventInput { return { kind: "plan.updated", payload: { - entries: [...this.#plans.values()] + entries: [...plans.values()] .filter((entry) => entry.content.trim().length > 0) .map((entry) => ({ content: entry.content.trim(), diff --git a/src/runtimes/openai/app-server-item-events.ts b/src/runtimes/openai/app-server-item-events.ts index b4bf96a..95ed2d1 100644 --- a/src/runtimes/openai/app-server-item-events.ts +++ b/src/runtimes/openai/app-server-item-events.ts @@ -1,32 +1,361 @@ import type { AgentDriverContext } from "../../core/agent-driver-backend"; import type { DriverEventInput } from "../../protocol/events"; +import { chunkJsonText } from "../provider-json"; +import { createRuntimeSourceEventId, toRuntimePublicId } from "../runtime-public-id"; import { toOpenAiPlanStatus } from "./app-server-event-mapping"; +import { + assertOpenAiDurableEventFits, + MAX_OPENAI_DURABLE_EVENT_BYTES, +} from "./app-server-event-state"; import type { OpenAiEventPush, OpenAiItemState, OpenAiMessageState, OpenAiPlanState, + OpenAiTerminalOutcome, OpenAiToolState, } from "./app-server-event-state"; import { isRecord, readArray, readNonEmptyString, readRecord, readString } from "./app-server-json"; import type { JsonObject } from "./app-server-json"; import { + OpenAiAgentTaskState, + openAiAgentTasksClosedEvent, + toOpenAiAgentTaskId, + type OpenAiSubAgentActivity, +} from "./app-server-agent-task-events"; +import { + toOpenAiCollaborationOutput, toOpenAiFileChangeEvents, + toOpenAiMessagePhase, toOpenAiToolName, + toOpenAiToolRawInput, toOpenAiToolResultText, + toOpenAiToolStructuredOutput, } from "./event-translator"; import { filterOpenAiPrivateCitations, OpenAiPrivateCitationStreamFilter, } from "./private-citation-filter"; +const MAX_OPENAI_MESSAGE_EVENT_TEXT_BYTES = 512 * 1_024; + +export function chunkOpenAiText( + text: string, + firstChunkBytes = MAX_OPENAI_MESSAGE_EVENT_TEXT_BYTES, + remainingChunkBytes = MAX_OPENAI_MESSAGE_EVENT_TEXT_BYTES, +): string[] { + return chunkJsonText(text, firstChunkBytes, remainingChunkBytes); +} + +function toOpenAiMessageSnapshotEvents(input: { + readonly item: JsonObject; + readonly itemId: string; + readonly messageId: string; + readonly phase: ReturnType; + readonly sourcePrefix: string; + readonly text: string; +}): DriverEventInput[] { + const createAddedEvent = (content: string): DriverEventInput => ({ + delivery: "lossless", + kind: "message.added", + payload: { + content, + ...(input.item["memoryCitation"] === null || input.item["memoryCitation"] === undefined + ? {} + : { memoryCitation: input.item["memoryCitation"] }), + messageId: input.messageId, + ...(input.phase === null ? {} : { phase: input.phase }), + role: "agent", + }, + sourceEventId: `${input.sourcePrefix}:0`, + }); + const createDeltaEvent = (contentDelta: string, index: number): DriverEventInput => ({ + delivery: "lossless", + kind: "message.delta", + payload: { + contentDelta, + messageId: input.messageId, + role: "agent", + }, + sourceEventId: `${input.sourcePrefix}:${String(index)}`, + }); + const emptyAddedEvent = createAddedEvent(""); + const metadataBytes = Buffer.byteLength(JSON.stringify(emptyAddedEvent), "utf8"); + assertOpenAiDurableEventFits(emptyAddedEvent, `message snapshot ${input.itemId}`); + const emptyDeltaEvent = createDeltaEvent("", input.text.length); + const deltaMetadataBytes = Buffer.byteLength(JSON.stringify(emptyDeltaEvent), "utf8"); + assertOpenAiDurableEventFits(emptyDeltaEvent, `message snapshot ${input.itemId}`); + const chunks = chunkOpenAiText( + input.text, + Math.min(MAX_OPENAI_MESSAGE_EVENT_TEXT_BYTES, MAX_OPENAI_DURABLE_EVENT_BYTES - metadataBytes), + Math.min( + MAX_OPENAI_MESSAGE_EVENT_TEXT_BYTES, + MAX_OPENAI_DURABLE_EVENT_BYTES - deltaMetadataBytes, + ), + ); + + const events: DriverEventInput[] = [ + createAddedEvent(chunks[0]!), + ...chunks.slice(1).map((contentDelta, index) => createDeltaEvent(contentDelta, index + 1)), + ]; + assertOpenAiDurableEventFits(events[0]!, `message snapshot ${input.itemId}`); + return events; +} + +interface OpenAiItemCompletionCommit { + readonly commit: () => void; + readonly events: DriverEventInput[]; +} + +function readOpenAiSubAgentActivity(item: JsonObject): OpenAiSubAgentActivity | null { + if (readString(item, "type") !== "subAgentActivity") { + return null; + } + + const kind = readString(item, "kind"); + const agentId = readNonEmptyString(item, "agentThreadId"); + const agentPath = readNonEmptyString(item, "agentPath"); + + if ( + agentId === null || + agentPath === null || + (kind !== "started" && kind !== "interacted" && kind !== "interrupted" && kind !== "completed") + ) { + throw new Error("OpenAI sub-agent activity is malformed."); + } + + return { agentId, agentPath, kind }; +} + +function withOpenAiEventIds( + events: readonly DriverEventInput[], + sourcePrefix: string, +): DriverEventInput[] { + let autoIndex = 0; + return events.map((event) => + typeof event.sourceEventId !== "string" || event.sourceEventId.length === 0 + ? { + ...event, + sourceEventId: createRuntimeSourceEventId( + "openai.derived", + sourcePrefix, + autoIndex++, + JSON.stringify(event), + ), + } + : event, + ); +} + +function toOpenAiItemLifecycleEvents( + item: JsonObject, + itemId: string, + phase: "completed" | "started", +): DriverEventInput[] { + const itemType = readString(item, "type"); + + switch (itemType) { + // These are translated by their dedicated message, plan, reasoning, or tool paths. + case "agentMessage": + case "plan": + case "reasoning": + case "commandExecution": + case "fileChange": + case "mcpToolCall": + case "dynamicToolCall": + case "collabAgentToolCall": + case "webSearch": + case "imageView": + case "sleep": + return []; + // User messages and standalone function outputs are input echoes, while hook/* + // notifications own hook lifecycle. Their ThreadItem copies must not publish duplicates. + case "userMessage": + case "hookPrompt": + case "functionCallOutput": + return []; + case "subAgentActivity": { + // App-server emits a started/completed pair for one display activity. Publish it once. + // Completion is authoritative and is also the only phase present in turn snapshots. + if (phase === "started") { + return []; + } + + const { agentId, agentPath, kind: activityKind } = readOpenAiSubAgentActivity(item)!; + + return [ + { + delivery: "lossless", + kind: "agent.task.updated", + payload: { + ...(activityKind === "started" + ? { active: true, status: "running" } + : activityKind === "interacted" + ? {} + : { + active: false, + status: activityKind === "interrupted" ? "cancelled" : "completed", + }), + activityKind, + agentId: toOpenAiAgentTaskId(agentId), + agentPath, + taskId: toOpenAiAgentTaskId(agentId), + title: `Sub-agent ${activityKind}`, + }, + }, + ]; + } + case "imageGeneration": { + if (phase === "started") { + return []; + } + + const status = readString(item, "status"); + const revisedPrompt = readString(item, "revisedPrompt"); + const transparentBackground = item["transparentBackground"]; + const imageMetadata = { + imageId: itemId, + ...(revisedPrompt === null ? {} : { revisedPrompt }), + ...(typeof transparentBackground === "boolean" ? { transparentBackground } : {}), + }; + + if (status === "failed") { + return [ + { + kind: "diagnostic.reported", + payload: { + code: "openai.image_generation.failed", + details: { + ...imageMetadata, + }, + message: "OpenAI image generation failed.", + severity: "error", + source: "openai", + }, + visibility: "owner_debug", + }, + ]; + } + + if (status !== "completed" || readNonEmptyString(item, "result") === null) { + throw new Error("OpenAI completed image generation did not contain a PNG result."); + } + + throw new Error( + "OpenAI image generation completed without a supported durable image transport.", + ); + } + case "enteredReviewMode": + case "exitedReviewMode": + return phase === "completed" + ? [ + { + kind: "review.updated", + payload: { + mode: itemType === "enteredReviewMode" ? "entered" : "exited", + review: readString(item, "review") ?? "", + reviewId: itemId, + status: "completed", + }, + }, + ] + : []; + case "contextCompaction": + return phase === "completed" + ? [ + { + kind: "context.compacted", + payload: { itemId, status: "completed" }, + }, + ] + : []; + default: + return [ + { + kind: "diagnostic.reported", + payload: { + code: "openai.item.unknown", + details: { itemId, itemType, phase }, + message: `OpenAI ${String(itemType)} item is unknown to this protocol snapshot.`, + severity: "error", + source: "openai", + }, + visibility: "owner_debug", + }, + ]; + } +} + +function toOpenAiToolStatus( + item: JsonObject, + phase: "completed" | "started", +): "cancelled" | "completed" | "failed" | null { + const itemType = readString(item, "type"); + const nativeStatus = readString(item, "status"); + + if (phase === "started") { + if (itemType === "collabAgentToolCall" && nativeStatus !== "inProgress") { + throw new Error( + `OpenAI ${itemType} started with non-running status ${String(nativeStatus)}.`, + ); + } + if (itemType === "imageGeneration" && nativeStatus !== "in_progress") { + throw new Error( + `OpenAI ${itemType} started with non-running status ${String(nativeStatus)}.`, + ); + } + return null; + } + + switch (itemType) { + case "commandExecution": + case "fileChange": + if (nativeStatus === "completed") { + return "completed"; + } + if (nativeStatus === "failed" || nativeStatus === "declined") { + return "failed"; + } + break; + case "mcpToolCall": + case "dynamicToolCall": + case "imageGeneration": + if (nativeStatus === "completed") { + return "completed"; + } + if (nativeStatus === "failed") { + return "failed"; + } + break; + case "collabAgentToolCall": + if (nativeStatus === "completed") { + return "completed"; + } + if (nativeStatus === "failed") { + return "failed"; + } + if (nativeStatus === "interrupted") { + return "cancelled"; + } + break; + default: + return null; + } + + throw new Error( + `OpenAI ${String(itemType)} completed with non-terminal status ${String(nativeStatus)}.`, + ); +} + export class OpenAiAppServerItemEventBridge { + readonly #agentTasks = new OpenAiAgentTaskState(); readonly #citationDiagnosticsEmitted = new Set(); readonly #citationFilters = new Map(); readonly #items: OpenAiItemState; readonly #messages: OpenAiMessageState; readonly #plans: OpenAiPlanState; readonly #push: OpenAiEventPush; + readonly #pushSession: OpenAiEventPush; readonly #tools: OpenAiToolState; constructor(input: { @@ -34,16 +363,19 @@ export class OpenAiAppServerItemEventBridge { messages: OpenAiMessageState; plans: OpenAiPlanState; push: OpenAiEventPush; + pushSession: OpenAiEventPush; tools: OpenAiToolState; }) { this.#items = input.items; this.#messages = input.messages; this.#plans = input.plans; this.#push = input.push; + this.#pushSession = input.pushSession; this.#tools = input.tools; } reset(): void { + this.#agentTasks.reset(); this.#citationDiagnosticsEmitted.clear(); this.#citationFilters.clear(); this.#items.reset(); @@ -52,8 +384,12 @@ export class OpenAiAppServerItemEventBridge { this.#tools.reset(); } - finishOpen(): DriverEventInput[] { - return [...this.#messages.finishOpen(), ...this.#tools.failOpen()]; + terminalEvents(outcome: OpenAiTerminalOutcome): [DriverEventInput, ...DriverEventInput[]] { + return [ + openAiAgentTasksClosedEvent(), + ...this.#messages.terminalEvents(outcome), + ...this.#tools.terminalEvents(outcome), + ]; } async onMessageDelta(context: AgentDriverContext, params: JsonObject): Promise { @@ -109,35 +445,26 @@ export class OpenAiAppServerItemEventBridge { id: itemId, type: "fileChange", }; + const publicItemId = this.#items.publicId(itemId); const parentMessageId = this.#tools.parentMessage(itemId) ?? (await this.#messages.ensureTurnMessage(context, turnId, this.#push)); await this.#tools.ensureStarted(context, this.#push, { parentMessageId, + publicToolCallId: publicItemId, reason: "driver.openai.file_change.patch_updated.synthetic_start", + sourceScope: this.#items.publicId(turnId, "turn"), toolCallId: itemId, toolCallName: "File change", }); - const resultText = toOpenAiToolResultText(item); - const events: DriverEventInput[] = []; + const events = toOpenAiFileChangeEvents(item); - if (resultText !== null && resultText.length > 0) { - events.push({ - kind: "tool.call.updated", - payload: { - content: resultText, - messageId: parentMessageId, - rawOutput: resultText, - status: "running", - toolCallId: itemId, - }, - }); + for (const event of events) { + assertOpenAiDurableEventFits(event, `file change ${publicItemId}`); } - events.push(...toOpenAiFileChangeEvents(item)); - if (events.length > 0) { await this.#push(context, "driver.openai.file_change.patch_updated", events); } @@ -152,21 +479,70 @@ export class OpenAiAppServerItemEventBridge { return; } - if (!this.#items.markCompleted(itemId)) { + if (this.#items.isCompleted(itemId)) { return; } - const events: DriverEventInput[] = []; - await this.#appendMessageEnd(context, events, item, itemId, turnId); - this.#appendPlanEnd(events, item, itemId); - this.#appendReasoningEnd(events, item, itemId); - await this.#appendToolEnd(context, events, item, itemId, turnId); + // Validate and prepare the full completion before committing replay state. + const toolStatus = toOpenAiToolStatus(item, "completed"); + const activity = readOpenAiSubAgentActivity(item); + const agentTaskUpdate = activity === null ? null : this.#agentTasks.prepare(activity); + const publicItemId = this.#items.publicId(itemId); + const publicTurnId = this.#items.publicId(turnId, "turn"); + const lifecycleEvents = toOpenAiItemLifecycleEvents(item, publicItemId, "completed"); + const completions = await Promise.all([ + this.#prepareMessageEnd(context, item, itemId, publicItemId, publicTurnId, turnId), + this.#preparePlanEnd(item, itemId), + this.#prepareReasoningEnd(context, item, itemId), + this.#prepareToolEnd(context, item, itemId, publicItemId, publicTurnId, toolStatus, turnId), + ]); + const events = completions.flatMap((completion) => completion.events); events.push(...toOpenAiFileChangeEvents(item)); + events.push(...lifecycleEvents); + events.push(...(agentTaskUpdate?.events ?? [])); if (events.length > 0) { - await this.#push(context, "driver.openai.item.completed", events); + const durableEvents = withOpenAiEventIds( + events, + createRuntimeSourceEventId("openai.item.completed", publicTurnId, publicItemId), + ); + for (const event of durableEvents) { + assertOpenAiDurableEventFits(event, `item completion ${publicItemId}`); + } + await this.#push(context, "driver.openai.item.completed", durableEvents); } + + for (const completion of completions) { + completion.commit(); + } + agentTaskUpdate?.commit(); + this.#items.markCompleted(itemId); + } + + async onPostTerminalSubAgentActivity( + context: AgentDriverContext, + params: JsonObject, + ): Promise { + const item = readRecord(params, "item"); + const itemId = item === null ? null : readNonEmptyString(item, "id"); + const turnId = readNonEmptyString(params, "turnId"); + + if (item === null || itemId === null || turnId === null || this.#items.isCompleted(itemId)) { + return; + } + + const publicItemId = this.#items.publicId(itemId); + const publicTurnId = this.#items.publicId(turnId, "turn"); + const events = withOpenAiEventIds( + toOpenAiItemLifecycleEvents(item, publicItemId, "completed"), + createRuntimeSourceEventId("openai.item.completed", publicTurnId, publicItemId), + ); + for (const event of events) { + assertOpenAiDurableEventFits(event, `post-terminal sub-agent activity ${publicItemId}`); + } + await this.#pushSession(context, "driver.openai.sub_agent.post_terminal", events); + this.#items.markCompleted(itemId); } async onItemStarted(context: AgentDriverContext, params: JsonObject): Promise { @@ -180,13 +556,34 @@ export class OpenAiAppServerItemEventBridge { const toolName = toOpenAiToolName(item); const itemId = readNonEmptyString(item, "id"); - if (toolName === null || itemId === null) { + if (itemId === null) { + return; + } + const publicItemId = this.#items.publicId(itemId); + const publicTurnId = this.#items.publicId(turnId, "turn"); + + if (toolName === null) { + const events = withOpenAiEventIds( + toOpenAiItemLifecycleEvents(item, publicItemId, "started"), + createRuntimeSourceEventId("openai.item.started", publicTurnId, publicItemId), + ); + + if (events.length > 0) { + for (const event of events) { + assertOpenAiDurableEventFits(event, `item start ${publicItemId}`); + } + await this.#push(context, "driver.openai.item.started", events); + } return; } + toOpenAiToolStatus(item, "started"); + await this.#tools.ensureStarted(context, this.#push, { parentMessageId: await this.#messages.ensureTurnMessage(context, turnId, this.#push), + publicToolCallId: publicItemId, reason: "driver.openai.item.started", + sourceScope: publicTurnId, toolCallId: itemId, toolCallName: toolName, }); @@ -194,14 +591,18 @@ export class OpenAiAppServerItemEventBridge { async onPlanDelta(context: AgentDriverContext, params: JsonObject): Promise { const itemId = readNonEmptyString(params, "itemId"); + const turnId = readNonEmptyString(params, "turnId"); const delta = readString(params, "delta"); - if (itemId === null || delta === null || delta.length === 0) { + if (itemId === null || turnId === null || delta === null || delta.length === 0) { return; } + const publicItemId = this.#items.publicId(itemId); + const event = this.#plans.createDeltaEvent(itemId, delta); + assertOpenAiDurableEventFits(event, `plan update ${publicItemId}`); + await this.#push(context, "driver.openai.plan.delta", [event]); this.#plans.appendDelta(itemId, delta); - await this.#push(context, "driver.openai.plan.delta", [this.#plans.createUpdatedEvent()]); } async onReasoningDelta(context: AgentDriverContext, params: JsonObject): Promise { @@ -212,35 +613,56 @@ export class OpenAiAppServerItemEventBridge { return; } - const messageId = `reasoning:${itemId}`; + const messageId = this.#messages.reasoningId(context, itemId); const events: DriverEventInput[] = []; if (this.#messages.isReasoningEnded(messageId)) { return; } - this.#messages.ensureReasoning(messageId, events); - events.push({ - delivery: "best_effort", - kind: "thought.delta", - payload: { - channel: "summary", - contentDelta: delta, - thoughtId: messageId, - }, - }); + this.#messages.ensureReasoning(messageId, events, false); + events.push( + ...chunkOpenAiText(delta).map((contentDelta): DriverEventInput => ({ + delivery: "lossless", + kind: "thought.delta", + payload: { + channel: "summary", + contentDelta, + thoughtId: messageId, + }, + })), + ); await this.#push(context, "driver.openai.reasoning.summary", events); + this.#messages.ensureReasoning(messageId, [], true); + this.#messages.appendReasoningText(messageId, delta); } async onReasoningPart(context: AgentDriverContext, params: JsonObject): Promise { const summaryIndex = params["summaryIndex"]; - if (typeof summaryIndex !== "number" || !Number.isInteger(summaryIndex) || summaryIndex < 1) { + const itemId = readNonEmptyString(params, "itemId"); + + if ( + itemId === null || + typeof summaryIndex !== "number" || + !Number.isInteger(summaryIndex) || + summaryIndex < 1 || + !this.#messages.beginReasoningPart( + this.#messages.reasoningId(context, itemId), + summaryIndex, + false, + ) + ) { return; } await this.onReasoningDelta(context, { ...params, delta: "\n\n" }); + this.#messages.beginReasoningPart( + this.#messages.reasoningId(context, itemId), + summaryIndex, + true, + ); } async onToolOutput(context: AgentDriverContext, params: JsonObject): Promise { @@ -252,8 +674,9 @@ export class OpenAiAppServerItemEventBridge { } const parentMessageId = this.#tools.parentMessage(itemId); + const publicToolCallId = this.#tools.publicToolCallId(itemId); - if (parentMessageId === null) { + if (parentMessageId === null || publicToolCallId === null) { return; } @@ -262,11 +685,10 @@ export class OpenAiAppServerItemEventBridge { delivery: "best_effort", kind: "tool.call.updated", payload: { - content: delta, messageId: parentMessageId, - rawOutput: delta, + rawOutputDelta: delta, status: "running", - toolCallId: itemId, + toolCallId: publicToolCallId, }, }, ]); @@ -306,9 +728,16 @@ export class OpenAiAppServerItemEventBridge { const items = readArray(turn, "items"); const itemsView = readString(turn, "itemsView"); - const finalAssistantItem = items.findLast( - (item) => isRecord(item) && readString(item, "type") === "agentMessage", + const assistantItems = items.filter( + (item): item is JsonObject => + isRecord(item) && + readString(item, "type") === "agentMessage" && + readString(item, "delivery") !== "async", ); + const hasExplicitPhase = assistantItems.some((item) => toOpenAiMessagePhase(item) !== null); + const finalAssistantItem = hasExplicitPhase + ? assistantItems.findLast((item) => toOpenAiMessagePhase(item) === "final") + : assistantItems.at(-1); if (!isRecord(finalAssistantItem)) { // Terminal notifications commonly use `itemsView: "notLoaded"` with an @@ -344,13 +773,63 @@ export class OpenAiAppServerItemEventBridge { this.#push, ); const filteredText = filterOpenAiPrivateCitations(text); + const phase = toOpenAiMessagePhase(finalAssistantItem); + const publicItemId = this.#items.publicId(itemId); + const publicTurnId = this.#items.publicId(turnId, "turn"); await this.#reportCitations(context, messageId, filteredText.privateCitationCount); - this.#messages.setText(messageId, filteredText.text); + const snapshot = { + itemId, + messageId, + phase, + text: filteredText.text, + turnId, + }; + + if (this.#messages.needsSnapshot(snapshot)) { + const sourcePrefix = createRuntimeSourceEventId( + "openai.turn.final_message", + publicTurnId, + publicItemId, + ); + const snapshotEvents = toOpenAiMessageSnapshotEvents({ + item: finalAssistantItem, + itemId, + messageId, + phase, + sourcePrefix, + text: filteredText.text, + }); + await this.#push( + context, + "driver.openai.turn.final_message", + withOpenAiEventIds( + [ + ...snapshotEvents, + ...(this.#messages.isEnded(messageId) + ? [ + { + kind: "message.completed" as const, + payload: { messageId, role: "agent" }, + sourceEventId: `${sourcePrefix}:completed`, + }, + ] + : []), + ], + sourcePrefix, + ), + ); + this.#messages.setText(messageId, filteredText.text); + this.#messages.recordSnapshot(snapshot); + } this.#releaseCitationState(messageId); return { id: messageId, text: filteredText.text }; } async onTurnPlan(context: AgentDriverContext, params: JsonObject): Promise { + const turnId = readNonEmptyString(params, "turnId"); + if (turnId === null) { + return; + } const plan = readArray(params, "plan").flatMap((entry) => { if (!isRecord(entry)) { return []; @@ -371,42 +850,44 @@ export class OpenAiAppServerItemEventBridge { ]; }); - await this.#push(context, "driver.openai.turn.plan.updated", [ - { - kind: "plan.updated", - payload: { - entries: plan, - source: "driver", - }, + const event: DriverEventInput = { + kind: "plan.updated", + payload: { + entries: plan, + source: "driver", }, - ]); + }; + assertOpenAiDurableEventFits(event, "turn plan update"); + await this.#push(context, "driver.openai.turn.plan.updated", [event]); } - async #appendMessageEnd( + async #prepareMessageEnd( context: AgentDriverContext, - events: DriverEventInput[], item: JsonObject, itemId: string, + publicItemId: string, + publicTurnId: string, turnId: string, - ): Promise { + ): Promise { if (readString(item, "type") !== "agentMessage") { - return; + return { commit: () => {}, events: [] }; } + const events: DriverEventInput[] = []; const finalText = readString(item, "text"); + const isAsync = readString(item, "delivery") === "async"; const messageId = await this.#messages.ensureItemMessage( context, { itemId, turnId }, this.#push, ); const filteredFinalText = finalText === null ? null : filterOpenAiPrivateCitations(finalText); - const currentText = this.#messages.currentText(messageId); + let trailingText = ""; if (filteredFinalText === null) { - const trailingText = this.#citationFilters.get(messageId)?.finish().text ?? ""; + trailingText = this.#citationFilters.get(messageId)?.previewFinish().text ?? ""; if (trailingText.length > 0) { - this.#messages.appendText(messageId, trailingText); events.push({ delivery: "best_effort", kind: "message.delta", @@ -419,68 +900,29 @@ export class OpenAiAppServerItemEventBridge { } } - if (filteredFinalText !== null) { - this.#appendCitationDiag(events, messageId, filteredFinalText.privateCitationCount); - } - - if (filteredFinalText !== null && filteredFinalText.text.length > currentText.length) { - if (filteredFinalText.text.startsWith(currentText)) { - const delta = filteredFinalText.text.slice(currentText.length); - this.#messages.appendText(messageId, delta); - events.push({ - delivery: "best_effort", - kind: "message.delta", - payload: { - contentDelta: delta, - messageId, - role: "agent", - }, - }); - } else if (currentText.length === 0) { - this.#messages.appendText(messageId, filteredFinalText.text); - events.push({ - delivery: "best_effort", - kind: "message.delta", - payload: { - contentDelta: filteredFinalText.text, - messageId, - role: "agent", - }, - }); - } else { - context.logger.warn("driver.openai.agent.final_text.mismatch", { - currentLength: currentText.length, - finalLength: filteredFinalText.text.length, - itemId, - }); - } - } + const commitCitationDiagnostic = + filteredFinalText !== null + ? this.#appendCitationDiag(events, messageId, filteredFinalText.privateCitationCount) + : false; if (filteredFinalText !== null) { - // item/completed is the provider's authoritative snapshot. Streaming - // deltas may be missing or replayed, so canonical completion must not - // inherit a corrupted accumulator even when live deltas cannot be undone. - this.#messages.setText(messageId, filteredFinalText.text); - this.#messages.recordSnapshot({ - itemId, - messageId, - text: filteredFinalText.text, - turnId, - }); - events.push({ - delivery: "lossless", - kind: "message.added", - payload: { - content: filteredFinalText.text, + events.push( + ...toOpenAiMessageSnapshotEvents({ + item, + itemId, messageId, - role: "agent", - }, - }); + phase: toOpenAiMessagePhase(item), + sourcePrefix: createRuntimeSourceEventId( + "openai.item.completed", + publicTurnId, + publicItemId, + ), + text: filteredFinalText.text, + }), + ); } - this.#citationFilters.delete(messageId); - - if (this.#messages.markEnded(messageId)) { + if (!this.#messages.isEnded(messageId)) { events.push({ kind: "message.completed", payload: { @@ -489,18 +931,46 @@ export class OpenAiAppServerItemEventBridge { }, }); } + + return { + commit: () => { + if (commitCitationDiagnostic) { + this.#citationDiagnosticsEmitted.add(messageId); + } + if (filteredFinalText === null) { + this.#citationFilters.get(messageId)?.finish(); + this.#messages.appendText(messageId, trailingText); + } else { + // item/completed is the provider's authoritative snapshot. Streaming + // deltas may be missing or replayed, so canonical completion must not + // inherit a corrupted accumulator even when live deltas cannot be undone. + this.#messages.setText(messageId, filteredFinalText.text); + if (!isAsync) { + this.#messages.recordSnapshot({ + itemId, + messageId, + phase: toOpenAiMessagePhase(item), + text: filteredFinalText.text, + turnId, + }); + } + } + this.#citationFilters.delete(messageId); + this.#messages.markEnded(messageId); + }, + events, + }; } #appendCitationDiag( events: DriverEventInput[], messageId: string, privateCitationCount: number, - ): void { + ): boolean { if (privateCitationCount === 0 || this.#citationDiagnosticsEmitted.has(messageId)) { - return; + return false; } - this.#citationDiagnosticsEmitted.add(messageId); events.push({ kind: "diagnostic.reported", payload: { @@ -514,6 +984,7 @@ export class OpenAiAppServerItemEventBridge { }, visibility: "owner_debug", }); + return true; } #filterCitationDelta(messageId: string, delta: string): string { @@ -533,11 +1004,14 @@ export class OpenAiAppServerItemEventBridge { privateCitationCount: number, ): Promise { const events: DriverEventInput[] = []; - this.#appendCitationDiag(events, messageId, privateCitationCount); + const commit = this.#appendCitationDiag(events, messageId, privateCitationCount); if (events.length > 0) { await this.#push(context, "driver.openai.private_citation_markup_removed", events); } + if (commit) { + this.#citationDiagnosticsEmitted.add(messageId); + } } #releaseCitationState(messageId: string): void { @@ -545,62 +1019,109 @@ export class OpenAiAppServerItemEventBridge { this.#citationFilters.delete(messageId); } - #appendPlanEnd(events: DriverEventInput[], item: JsonObject, itemId: string): void { + #preparePlanEnd(item: JsonObject, itemId: string): OpenAiItemCompletionCommit { if (readString(item, "type") !== "plan") { - return; + return { commit: () => {}, events: [] }; } const planText = readString(item, "text"); if (planText === null || planText.trim().length === 0) { - return; + return { commit: () => {}, events: [] }; } - this.#plans.setCompleted(itemId, planText); - events.push(this.#plans.createUpdatedEvent()); + return { + commit: () => this.#plans.setCompleted(itemId, planText), + events: [this.#plans.createCompletedEvent(itemId, planText)], + }; } - #appendReasoningEnd(events: DriverEventInput[], item: JsonObject, itemId: string): void { + #prepareReasoningEnd( + context: AgentDriverContext, + item: JsonObject, + itemId: string, + ): OpenAiItemCompletionCommit { if (readString(item, "type") !== "reasoning") { - return; + return { commit: () => {}, events: [] }; } + const events: DriverEventInput[] = []; const summary = Array.isArray(item["summary"]) ? item["summary"].filter((entry): entry is string => typeof entry === "string") : []; - const messageId = `reasoning:${itemId}`; - - if (summary.length > 0) { - this.#messages.ensureReasoning(messageId, events); - events.push({ - delivery: "best_effort", - kind: "thought.delta", - payload: { - channel: "summary", - contentDelta: summary.join("\n\n"), - thoughtId: messageId, - }, + const messageId = this.#messages.reasoningId(context, itemId); + const summaryText = summary.join("\n\n"); + const currentText = this.#messages.currentReasoningText(messageId); + // A terminal turn snapshot can be the first frame seen by a fresh Driver. + // Active-run Driver loss is terminal, so only a live instance can have a durable prefix to skip. + const missingText = summaryText.startsWith(currentText) + ? summaryText.slice(currentText.length) + : currentText.length === 0 + ? summaryText + : ""; + + if ( + summaryText.length > 0 && + currentText.length > 0 && + summaryText !== currentText && + missingText.length === 0 + ) { + context.logger.warn("driver.openai.reasoning.final_text.mismatch", { + currentLength: currentText.length, + finalLength: summaryText.length, + itemId, }); } - if (this.#messages.markReasoningEnded(messageId)) { + if (missingText.length > 0) { + this.#messages.ensureReasoning(messageId, events, false); + events.push( + ...chunkOpenAiText(missingText).map((contentDelta): DriverEventInput => ({ + delivery: "lossless", + kind: "thought.delta", + payload: { + channel: "summary", + contentDelta, + thoughtId: messageId, + }, + })), + ); + } + + const shouldStart = missingText.length > 0 && !this.#messages.isReasoningStarted(messageId); + if (this.#messages.isReasoningStarted(messageId) || shouldStart) { events.push({ kind: "thought.completed", payload: { channel: "summary", thoughtId: messageId, }, + sourceEventId: `openai.reasoning.completed:${messageId}:terminal`, }); } + + return { + commit: () => { + if (shouldStart) { + this.#messages.ensureReasoning(messageId, [], true); + } + this.#messages.appendReasoningText(messageId, missingText); + this.#messages.markReasoningEnded(messageId); + }, + events, + }; } - async #appendToolEnd( + async #prepareToolEnd( context: AgentDriverContext, - events: DriverEventInput[], item: JsonObject, itemId: string, + publicItemId: string, + publicTurnId: string, + status: "cancelled" | "completed" | "failed" | null, turnId: string, - ): Promise { + ): Promise { + const events: DriverEventInput[] = []; const toolName = toOpenAiToolName(item); const parentMessageId = this.#tools.parentMessage(itemId) ?? @@ -610,44 +1131,76 @@ export class OpenAiAppServerItemEventBridge { : await this.#messages.ensureTurnMessage(context, turnId, this.#push)); if (parentMessageId === null || toolName === null) { - return; + return { commit: () => {}, events }; } await this.#tools.ensureStarted(context, this.#push, { parentMessageId, + publicToolCallId: publicItemId, reason: "driver.openai.item.completed.synthetic_start", + sourceScope: publicTurnId, toolCallId: itemId, toolCallName: toolName, }); - const nativeStatus = readString(item, "status"); - const status = - nativeStatus === "failed" || nativeStatus === "declined" ? "failed" : "completed"; + const itemType = readString(item, "type"); + const terminalStatus = status ?? "completed"; const toolResult = toOpenAiToolResultText(item); + const rawInput = toOpenAiToolRawInput(item); + const collaborationOutput = toOpenAiCollaborationOutput(item); + const receiverThreadIds = readArray(item, "receiverThreadIds").filter( + (entry): entry is string => typeof entry === "string", + ); + const structuredOutput = + toOpenAiToolStructuredOutput(item) ?? + collaborationOutput ?? + (itemType === "webSearch" + ? { + action: item["action"] ?? null, + query: readString(item, "query"), + results: item["results"] ?? null, + } + : null); events.push({ kind: "tool.call.updated", payload: { - ...(toolResult === null || toolResult.length === 0 + ...(itemType === "collabAgentToolCall" && receiverThreadIds.length === 1 + ? { agentId: toRuntimePublicId(receiverThreadIds[0]!, "openai-thread") } + : {}), + ...(toolResult === null || + toolResult.length === 0 || + ((itemType === "dynamicToolCall" || itemType === "collabAgentToolCall") && + structuredOutput !== null) ? {} : { - content: toolResult, messageId: parentMessageId, rawOutput: toolResult, }), - status, - toolCallId: itemId, + ...(structuredOutput === null ? {} : { structuredOutput }), + ...(rawInput === null ? {} : { rawInput }), + status: terminalStatus, + toolCallId: publicItemId, }, + sourceEventId: `${createRuntimeSourceEventId( + "openai.item.completed", + publicTurnId, + publicItemId, + )}:0`, }); + assertOpenAiDurableEventFits(events[0]!, `tool completion ${publicItemId}`); events.push({ kind: "item.completed", payload: { - itemId, + itemId: publicItemId, itemType: "tool_call", - status, + status: terminalStatus, }, }); - this.#tools.markEnded(itemId); + return { + commit: () => this.#tools.markEnded(itemId), + events, + }; } } diff --git a/src/runtimes/openai/app-server-json.ts b/src/runtimes/openai/app-server-json.ts index 28181cb..723295a 100644 --- a/src/runtimes/openai/app-server-json.ts +++ b/src/runtimes/openai/app-server-json.ts @@ -1,3 +1,6 @@ +import { isRecord } from "../provider-json"; +import type { JsonObject } from "../provider-json"; + export { isRecord, readArray, @@ -9,6 +12,17 @@ export { export type { JsonObject } from "../provider-json"; export type JsonRpcId = number | string; +export function toGrantedPermissionProfile(value: unknown): JsonObject { + if (!isRecord(value)) { + return {}; + } + + return { + ...(isRecord(value["fileSystem"]) ? { fileSystem: value["fileSystem"] } : {}), + ...(isRecord(value["network"]) ? { network: value["network"] } : {}), + }; +} + export function toJsonRpcId(value: unknown): JsonRpcId | null { if (typeof value === "string" || typeof value === "number") { return value; diff --git a/src/runtimes/openai/app-server-protocol-client-schemas.ts b/src/runtimes/openai/app-server-protocol-client-schemas.ts new file mode 100644 index 0000000..7b5325b --- /dev/null +++ b/src/runtimes/openai/app-server-protocol-client-schemas.ts @@ -0,0 +1,57 @@ +import { z } from "zod"; + +import initializeResponseJsonSchema from "./generated-json-schema/InitializeResponse.json" with { type: "json" }; +import threadBackgroundTerminalsCleanResponseJsonSchema from "./generated-json-schema/ThreadBackgroundTerminalsCleanResponse.json" with { type: "json" }; +import threadInjectItemsResponseJsonSchema from "./generated-json-schema/ThreadInjectItemsResponse.json" with { type: "json" }; +import threadResumeResponseJsonSchema from "./generated-json-schema/ThreadResumeResponse.json" with { type: "json" }; +import threadStartResponseJsonSchema from "./generated-json-schema/ThreadStartResponse.json" with { type: "json" }; +import turnStartResponseJsonSchema from "./generated-json-schema/TurnStartResponse.json" with { type: "json" }; +import type { ClientRequestMethod, ClientRequestResult } from "./app-server-protocol-types"; +import { validateTurnStatusError } from "./app-server-turn-validation"; + +function fromGeneratedJsonSchema(schema: unknown): z.ZodType { + return z.fromJSONSchema(schema as Parameters[0]) as z.ZodType; +} + +const requestIdSchema = z.union([z.string(), z.number().int().safe()]); + +export const jsonRpcResponseSchema = z.union([ + z.strictObject({ + id: requestIdSchema, + result: z.json(), + }), + z.strictObject({ + error: z.strictObject({ + code: z.number().int().safe(), + data: z.json().optional(), + message: z.string(), + }), + id: requestIdSchema, + }), +]); + +export const CLIENT_RESULT_SCHEMAS: { + readonly [Method in ClientRequestMethod]: z.ZodType; +} = { + initialize: fromGeneratedJsonSchema(initializeResponseJsonSchema), + "thread/backgroundTerminals/clean": fromGeneratedJsonSchema( + threadBackgroundTerminalsCleanResponseJsonSchema, + ), + "thread/inject_items": fromGeneratedJsonSchema(threadInjectItemsResponseJsonSchema), + "thread/resume": fromGeneratedJsonSchema(threadResumeResponseJsonSchema), + "thread/start": fromGeneratedJsonSchema(threadStartResponseJsonSchema), + "turn/start": fromGeneratedJsonSchema( + turnStartResponseJsonSchema, + ).superRefine((response, context) => { + const message = validateTurnStatusError(response.turn, false); + + if (message !== null) { + context.addIssue({ + code: "custom", + input: response.turn, + message, + path: ["turn", "error"], + }); + } + }), +}; diff --git a/src/runtimes/openai/app-server-protocol-server.ts b/src/runtimes/openai/app-server-protocol-server.ts new file mode 100644 index 0000000..21c10ab --- /dev/null +++ b/src/runtimes/openai/app-server-protocol-server.ts @@ -0,0 +1,142 @@ +import { z } from "zod"; + +import serverNotificationJsonSchema from "./generated-json-schema/ServerNotification.json" with { type: "json" }; +import serverRequestJsonSchema from "./generated-json-schema/ServerRequest.json" with { type: "json" }; +import { isRecord } from "./app-server-json"; +import type { JsonObject } from "./app-server-json"; +import type { + ParsedServerNotification, + ParsedServerRequest, + ServerNotificationMethod, + ServerRequestMethod, +} from "./app-server-protocol-types"; +import { validateTurnStatusError } from "./app-server-turn-validation"; + +function expectObject(value: unknown, label: string): JsonObject { + if (!isRecord(value)) { + throw new TypeError(`${label} must be an object.`); + } + + return value; +} + +function readStringArray(value: unknown, label: string): string[] { + if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) { + throw new TypeError(`${label} must be an array of strings.`); + } + + return value; +} + +function branchMethod(branch: JsonObject): string { + const properties = expectObject(branch["properties"], "Method schema properties"); + const method = expectObject(properties["method"], "Method schema discriminator"); + const variants = readStringArray(method["enum"], "Method schema discriminator enum"); + + if (variants.length !== 1) { + throw new TypeError("Method schema discriminator must contain exactly one value."); + } + + return variants[0]!; +} + +function createMethodSchemas(value: unknown): ReadonlyMap> { + const root = expectObject(value, "Root method schema"); + const branches = root["oneOf"]; + + if (!Array.isArray(branches)) { + throw new TypeError("Root method schema oneOf must be an array."); + } + + const { oneOf: _oneOf, ...shared } = root; + const sharedProperties = isRecord(shared["properties"]) ? shared["properties"] : {}; + const sharedRequired = + shared["required"] === undefined + ? [] + : readStringArray(shared["required"], "Root method schema required"); + const schemas = new Map>(); + + for (const value of branches) { + const branch = expectObject(value, "Method schema branch"); + const method = branchMethod(branch); + const properties = expectObject(branch["properties"], `${method} schema properties`); + const required = readStringArray(branch["required"], `${method} schema required`); + const schema = z.fromJSONSchema({ + ...shared, + ...branch, + properties: { ...sharedProperties, ...properties }, + required: [...new Set([...sharedRequired, ...required])], + } as Parameters[0]) as z.ZodType; + + if (schemas.has(method)) { + throw new TypeError(`Root method schema contains duplicate method ${method}.`); + } + + schemas.set(method, schema); + } + + return schemas; +} + +const serverNotificationSchemas = createMethodSchemas(serverNotificationJsonSchema); +const serverRequestSchemas = createMethodSchemas(serverRequestJsonSchema); + +export function isServerNotificationMethod(method: string): method is ServerNotificationMethod { + return serverNotificationSchemas.has(method); +} + +export function isServerRequestMethod(method: string): method is ServerRequestMethod { + return serverRequestSchemas.has(method); +} + +export function parseServerNotification(value: unknown): ParsedServerNotification | null { + const envelope = isRecord(value) ? value : null; + const method = envelope?.["method"]; + + if (typeof method !== "string" || !isServerNotificationMethod(method)) { + return null; + } + + const parsed = serverNotificationSchemas.get(method)!.parse(value); + const params = parsed["params"]; + + if (!isRecord(params)) { + throw new TypeError(`${method} params must be an object.`); + } + + if (method === "turn/completed") { + const turn = expectObject(params["turn"], "turn/completed turn"); + const message = validateTurnStatusError(turn, true); + + if (message !== null) { + throw new TypeError(`turn/completed ${message}`); + } + } + + const emittedAtMs = parsed["emittedAtMs"]; + + return { + ...(typeof emittedAtMs === "number" ? { emittedAtMs } : {}), + method, + params, + }; +} + +export function parseServerRequest(value: unknown): ParsedServerRequest | null { + const envelope = isRecord(value) ? value : null; + const method = envelope?.["method"]; + + if (typeof method !== "string" || !isServerRequestMethod(method)) { + return null; + } + + const parsed = serverRequestSchemas.get(method)!.parse(value); + const id = parsed["id"]; + const params = parsed["params"]; + + if ((typeof id !== "number" && typeof id !== "string") || !isRecord(params)) { + throw new TypeError(`${method} request envelope is invalid.`); + } + + return { id, method, params }; +} diff --git a/src/runtimes/openai/app-server-protocol-types.ts b/src/runtimes/openai/app-server-protocol-types.ts new file mode 100644 index 0000000..bd1a5b7 --- /dev/null +++ b/src/runtimes/openai/app-server-protocol-types.ts @@ -0,0 +1,70 @@ +import type { InitializeParams } from "./generated/InitializeParams"; +import type { ServerNotificationMethod, ServerRequestMethod } from "./generated/ProtocolMethods"; +import type { RequestId } from "./generated/RequestId"; +import type { ThreadBackgroundTerminalsCleanParams } from "./generated/v2/ThreadBackgroundTerminalsCleanParams"; +import type { ThreadInjectItemsParams } from "./generated/v2/ThreadInjectItemsParams"; +import type { ThreadResumeParams } from "./generated/v2/ThreadResumeParams"; +import type { ThreadStartParams } from "./generated/v2/ThreadStartParams"; +import type { TurnStartParams } from "./generated/v2/TurnStartParams"; +import type { TurnStatus } from "./generated/v2/TurnStatus"; +import type { JsonObject } from "./app-server-json"; + +export type { AskForApproval as ApprovalPolicy } from "./generated/v2/AskForApproval"; +export type { CurrentTimeReadResponse } from "./generated/v2/CurrentTimeReadResponse"; +export type { + RequestId, + ServerNotificationMethod, + ServerRequestMethod, + ThreadInjectItemsParams, + ThreadResumeParams, + ThreadStartParams, + TurnStartParams, + TurnStatus, +}; + +export type InitializeResponse = JsonObject; +export type ThreadStartResponse = JsonObject & { thread: JsonObject & { id: string } }; +export type ThreadResumeResponse = ThreadStartResponse; +export type ThreadInjectItemsResponse = JsonObject; +export type ThreadBackgroundTerminalsCleanResponse = JsonObject; +export type TurnStartResponse = JsonObject & { + turn: JsonObject & { id: string; status: TurnStatus }; +}; + +export interface ClientRequestResult { + initialize: InitializeResponse; + "thread/backgroundTerminals/clean": ThreadBackgroundTerminalsCleanResponse; + "thread/inject_items": ThreadInjectItemsResponse; + "thread/resume": ThreadResumeResponse; + "thread/start": ThreadStartResponse; + "turn/start": TurnStartResponse; +} + +export type ClientRequestMethod = keyof ClientRequestResult; + +export interface ClientRequestParams { + initialize: InitializeParams; + "thread/backgroundTerminals/clean": ThreadBackgroundTerminalsCleanParams; + "thread/inject_items": ThreadInjectItemsParams; + "thread/resume": ThreadResumeParams; + "thread/start": ThreadStartParams; + "turn/start": TurnStartParams; +} + +export interface ParsedServerNotification { + emittedAtMs?: number; + method: ServerNotificationMethod; + params: JsonObject; +} + +export interface ParsedServerRequest { + id: RequestId; + method: ServerRequestMethod; + params: JsonObject; +} + +export interface PermissionsRequestApprovalResponse { + permissions: JsonObject; + scope: "turn" | "session"; + strictAutoReview?: boolean; +} diff --git a/src/runtimes/openai/generated/app-server-protocol.ts b/src/runtimes/openai/app-server-protocol.ts similarity index 53% rename from src/runtimes/openai/generated/app-server-protocol.ts rename to src/runtimes/openai/app-server-protocol.ts index d155593..13ff77a 100644 --- a/src/runtimes/openai/generated/app-server-protocol.ts +++ b/src/runtimes/openai/app-server-protocol.ts @@ -1,3 +1,3 @@ -export * from "./app-server-protocol-client"; +export { CLIENT_RESULT_SCHEMAS } from "./app-server-protocol-client-schemas"; export * from "./app-server-protocol-server"; export * from "./app-server-protocol-types"; diff --git a/src/runtimes/openai/app-server-request-handler.ts b/src/runtimes/openai/app-server-request-handler.ts index bfdc499..780abf5 100644 --- a/src/runtimes/openai/app-server-request-handler.ts +++ b/src/runtimes/openai/app-server-request-handler.ts @@ -1,20 +1,25 @@ import { PermissionEventDeliveryError } from "../../core/driver-permission-broker"; import type { AgentDriverContext } from "../../core/agent-driver-backend"; -import { isRecord, readRecord, readString, stringifyForDisplay } from "./app-server-json"; +import { + isRecord, + readRecord, + readString, + stringifyForDisplay, + toGrantedPermissionProfile, +} from "./app-server-json"; import type { JsonObject } from "./app-server-json"; import type { - CommandExecutionRequestApprovalResponse, CurrentTimeReadResponse, - FileChangeRequestApprovalResponse, PermissionsRequestApprovalResponse, RequestId, ServerRequestMethod, -} from "./generated/app-server-protocol"; +} from "./app-server-protocol"; interface OpenAiAppServerRequestHandlerOptions { readonly context: AgentDriverContext; readonly handleError: (error: Error, method: ServerRequestMethod) => Promise; readonly isStopped: () => boolean; + readonly mapToolCallId: (toolCallId: string) => string; readonly respond: (id: RequestId, result: unknown) => void; readonly respondError: (id: RequestId, message: string) => void; } @@ -26,15 +31,53 @@ interface PendingServerRequest { function toApprovalDecision( decision: "allow_once" | "reject_once", -): CommandExecutionRequestApprovalResponse["decision"] { - return decision === "allow_once" ? "accept" : "decline"; + method: "item/commandExecution/requestApproval" | "item/fileChange/requestApproval", + params: JsonObject, +): "accept" | "cancel" | "decline" { + const explicit = params["availableDecisions"]; + + if ( + method === "item/commandExecution/requestApproval" && + explicit !== undefined && + explicit !== null && + !Array.isArray(explicit) + ) { + throw new TypeError("OpenAi approval availableDecisions must be an array or null."); + } + + const available = new Set( + (method === "item/commandExecution/requestApproval" && Array.isArray(explicit) + ? explicit + : method === "item/fileChange/requestApproval" + ? ["accept", "decline", "cancel"] + : ["accept", "cancel"] + ).filter( + (value): value is "accept" | "cancel" | "decline" => + value === "accept" || value === "cancel" || value === "decline", + ), + ); + const desired = decision === "allow_once" ? "accept" : "decline"; + + if (available.has(desired)) { + return desired; + } + + if (available.has("cancel")) { + return "cancel"; + } + + if (available.has("decline")) { + return "decline"; + } + + throw new Error(`OpenAi approval decision ${desired} was not offered by app-server.`); } function toPermissionProfileGrant(params: JsonObject): PermissionsRequestApprovalResponse { const permissions = readRecord(params, "permissions"); return { - permissions: permissions === null ? {} : { ...permissions }, + permissions: toGrantedPermissionProfile(permissions), scope: "turn", }; } @@ -47,6 +90,7 @@ export class OpenAiAppServerRequestHandler { readonly #context: AgentDriverContext; readonly #handleError: OpenAiAppServerRequestHandlerOptions["handleError"]; readonly #isStopped: () => boolean; + readonly #mapToolCallId: OpenAiAppServerRequestHandlerOptions["mapToolCallId"]; readonly #pending = new Map(); readonly #respond: OpenAiAppServerRequestHandlerOptions["respond"]; readonly #respondError: OpenAiAppServerRequestHandlerOptions["respondError"]; @@ -55,10 +99,15 @@ export class OpenAiAppServerRequestHandler { this.#context = options.context; this.#handleError = options.handleError; this.#isStopped = options.isStopped; + this.#mapToolCallId = options.mapToolCallId; this.#respond = options.respond; this.#respondError = options.respondError; } + isPending(id: RequestId): boolean { + return this.#pending.has(id); + } + dispatch(method: ServerRequestMethod, id: RequestId, params: unknown): void { if (this.#pending.has(id)) { throw new Error(`OpenAi app-server request ${String(id)} is already pending.`); @@ -121,7 +170,7 @@ export class OpenAiAppServerRequestHandler { signal: AbortSignal, ): Promise { const payload = isRecord(params) ? params : {}; - const requestId = `${method}:${String(id)}`; + const requestId = `${method}:${typeof id}:${String(id)}`; switch (method) { case "currentTime/read": { @@ -135,21 +184,21 @@ export class OpenAiAppServerRequestHandler { case "item/fileChange/requestApproval": { const decision = await this.#context.ports.permission.request( { - rawInput: stringifyForDisplay(payload["command"] ?? payload["reason"] ?? payload), + rawInput: stringifyForDisplay(payload), requestId, title: method === "item/fileChange/requestApproval" ? "Approve file changes" - : "Approve command execution", - toolCallId: readString(payload, "itemId"), + : readString(payload, "kind") === "writeStdin" + ? "Approve terminal input" + : "Approve command execution", + toolCallId: this.#publicToolCallId(payload), toolKind: method, }, signal, ); - const response: - | CommandExecutionRequestApprovalResponse - | FileChangeRequestApprovalResponse = { - decision: toApprovalDecision(decision), + const response = { + decision: toApprovalDecision(decision, method, payload), }; this.#reply(id, response); return; @@ -157,10 +206,10 @@ export class OpenAiAppServerRequestHandler { case "item/permissions/requestApproval": { const decision = await this.#context.ports.permission.request( { - rawInput: stringifyForDisplay(payload["permissions"] ?? payload), + rawInput: stringifyForDisplay(payload), requestId, title: "Approve runtime permissions", - toolCallId: readString(payload, "itemId"), + toolCallId: this.#publicToolCallId(payload), toolKind: method, }, signal, @@ -173,8 +222,10 @@ export class OpenAiAppServerRequestHandler { ); return; } + case "applyPatchApproval": case "account/chatgptAuthTokens/refresh": case "attestation/generate": + case "execCommandApproval": case "item/tool/call": case "item/tool/requestUserInput": case "mcpServer/elicitation/request": @@ -191,6 +242,11 @@ export class OpenAiAppServerRequestHandler { } } + #publicToolCallId(payload: JsonObject): string | null { + const toolCallId = readString(payload, "itemId"); + return toolCallId === null ? null : this.#mapToolCallId(toolCallId); + } + #replyError(id: RequestId, message: string): void { if (this.#pending.delete(id) && !this.#isStopped()) { this.#respondError(id, message); diff --git a/src/runtimes/openai/app-server-turn-tracker.ts b/src/runtimes/openai/app-server-turn-tracker.ts index ce1221b..7d23f6d 100644 --- a/src/runtimes/openai/app-server-turn-tracker.ts +++ b/src/runtimes/openai/app-server-turn-tracker.ts @@ -1,22 +1,53 @@ +import { createHash } from "node:crypto"; + import type { DriverTurnCancelledError } from "../../core/driver-runtime-state"; import type { RunId } from "../../protocol/id"; interface ActiveOpenAiTurn { + cancellationSignal: AbortSignal | null; + completionClosuresCommitted: boolean; + nativeTurnId: string; promise: Promise; reject(error: Error): void; resolve(): void; runId: RunId; } -type TerminalOpenAiTurn = +type OpenAiTurnTerminalOutcome = | { kind: "completed" } | { error: DriverTurnCancelledError | Error; kind: "failed" }; +type TerminalOpenAiTurn = OpenAiTurnTerminalOutcome & { runId: RunId | null }; + +export interface OpenAiTurnAdmission { + readonly token: symbol; +} + +interface PendingOpenAiTurn { + readonly admission: OpenAiTurnAdmission; + armed: boolean; + readonly boundTurnId: Promise; + readonly cancellationSignal: AbortSignal | null; + completionClosuresCommitted: boolean; + nativeTurnId: string | null; + readonly resolveBoundTurnId: (turnId: string | null) => void; + readonly runId: RunId; + selectionReleased: boolean; +} + const MAX_RETAINED_TURNS = 1_024; +const MAX_RETAINED_NATIVE_TURN_ID_BYTES = 256; + +export function retainedOpenAiTurnKey(turnId: string): string { + return Buffer.byteLength(turnId, "utf8") <= MAX_RETAINED_NATIVE_TURN_ID_BYTES + ? turnId + : `sha256:${createHash("sha256").update(turnId).digest("hex")}`; +} function rememberBounded(map: Map, turnId: string, value: T): void { - map.delete(turnId); - map.set(turnId, value); + const key = retainedOpenAiTurnKey(turnId); + map.delete(key); + map.set(key, value); if (map.size > MAX_RETAINED_TURNS) { const oldest = map.keys().next().value; @@ -29,6 +60,9 @@ function rememberBounded(map: Map, turnId: string, value: T): void export class OpenAiTurnTracker { readonly #activeTurns = new Map(); + readonly #ignoredTurnIds = new Map(); + #pendingRootTurn: PendingOpenAiTurn | null = null; + #rootAdmissionsEnforced = false; readonly #settlingTurnIds = new Set(); readonly #terminalTurns = new Map(); readonly #startedTurnIds = new Map(); @@ -38,26 +72,208 @@ export class OpenAiTurnTracker { return null; } - return this.#activeTurns.get(turnId)?.runId ?? null; + return this.#activeTurns.get(retainedOpenAiTurnKey(turnId))?.runId ?? null; + } + + admitRootTurn(runId: RunId, signal?: AbortSignal): OpenAiTurnAdmission { + if (this.#pendingRootTurn !== null) { + throw new Error("OpenAI root turn admission is already pending."); + } + + this.#rootAdmissionsEnforced = true; + const admission = { token: Symbol("openai-root-turn") }; + const boundTurnId = Promise.withResolvers(); + this.#pendingRootTurn = { + admission, + armed: false, + boundTurnId: boundTurnId.promise, + cancellationSignal: signal ?? null, + completionClosuresCommitted: false, + nativeTurnId: null, + resolveBoundTurnId: boundTurnId.resolve, + runId, + selectionReleased: false, + }; + return admission; + } + + armRootTurn(admission: OpenAiTurnAdmission): void { + const pending = this.#pendingRootTurn; + if (pending?.admission !== admission || pending.selectionReleased) { + throw new Error("OpenAI root turn admission is no longer active."); + } + pending.armed = true; + } + + bindRootTurn(admission: OpenAiTurnAdmission, turnId: string): void { + const pending = this.#pendingRootTurn; + if (pending?.admission !== admission) { + throw new Error("OpenAI root turn admission is no longer active."); + } + if (!pending.armed || pending.selectionReleased) { + throw new Error("OpenAI root turn admission is not awaiting a response."); + } + if (pending.nativeTurnId !== null && pending.nativeTurnId !== turnId) { + throw new Error("OpenAI root turn admission received a different native turn."); + } + if (pending.nativeTurnId === turnId) { + return; + } + + pending.nativeTurnId = turnId; + this.#ignoredTurnIds.delete(retainedOpenAiTurnKey(turnId)); + pending.resolveBoundTurnId(turnId); + } + + async awaitRootTurnAdmission(turnId: string): Promise { + const pending = this.#pendingRootTurn; + if (pending === null) { + return !this.#ignoredTurnIds.has(retainedOpenAiTurnKey(turnId)); + } + if (!pending.armed) { + rememberBounded(this.#ignoredTurnIds, turnId, true); + return false; + } + + const boundTurnId = await pending.boundTurnId; + if (boundTurnId === null) { + return false; + } + if (boundTurnId === turnId) { + return true; + } + rememberBounded(this.#ignoredTurnIds, turnId, true); + return false; + } + + pendingTurnContext( + turnId: string, + ): { cancellationSignal: AbortSignal | null; runId: RunId } | null { + const pending = this.#pendingRootTurn; + return pending?.nativeTurnId === turnId + ? { cancellationSignal: pending.cancellationSignal, runId: pending.runId } + : null; + } + + hasPendingRootTurn(): boolean { + return this.#pendingRootTurn !== null; + } + + admittedTurnId(admission: OpenAiTurnAdmission): string | null { + const pending = this.#pendingRootTurn; + return pending?.admission === admission ? pending.nativeTurnId : null; + } + + hasAdmittedTerminalTurn(): boolean { + const turnId = this.#pendingRootTurn?.nativeTurnId; + return turnId !== null && turnId !== undefined && this.hasTerminal(turnId); + } + + acceptsRootTurn(turnId: string): boolean { + if (!this.#rootAdmissionsEnforced) { + return true; + } + + const key = retainedOpenAiTurnKey(turnId); + return ( + this.#pendingRootTurn?.nativeTurnId === turnId || + this.#activeTurns.has(key) || + this.#settlingTurnIds.has(key) || + this.#terminalTurns.has(key) + ); + } + + claimRootTurn( + admission: OpenAiTurnAdmission, + turnId: string, + runId: RunId, + signal?: AbortSignal, + ): Promise { + const pending = this.#pendingRootTurn; + if (pending?.admission !== admission) { + throw new Error("OpenAI root turn admission is no longer active."); + } + if (pending.runId !== runId || pending.cancellationSignal !== (signal ?? null)) { + throw new Error("OpenAI root turn admission context changed before its response."); + } + if (pending.nativeTurnId !== turnId) { + throw new Error("OpenAI turn/start response was not bound to its admission."); + } + + this.#pendingRootTurn = null; + const tracked = this.#track(turnId, runId, signal); + const activeTurn = this.#activeTurns.get(retainedOpenAiTurnKey(turnId)); + if (activeTurn !== undefined) { + activeTurn.completionClosuresCommitted = pending.completionClosuresCommitted; + } + return tracked; + } + + releaseRootTurn(admission: OpenAiTurnAdmission): void { + const pending = this.#pendingRootTurn; + if (pending?.admission === admission) { + pending.resolveBoundTurnId(null); + this.#pendingRootTurn = null; + } + } + + releaseRootTurnSelection(admission: OpenAiTurnAdmission): void { + const pending = this.#pendingRootTurn; + if ( + pending?.admission === admission && + pending.nativeTurnId === null && + !pending.selectionReleased + ) { + pending.selectionReleased = true; + pending.resolveBoundTurnId(null); + } + } + + cancellationSignal(turnId: string): AbortSignal | null { + return this.#activeTurns.get(retainedOpenAiTurnKey(turnId))?.cancellationSignal ?? null; + } + + completionClosuresCommitted(turnId: string): boolean { + return ( + this.#activeTurns.get(retainedOpenAiTurnKey(turnId))?.completionClosuresCommitted ?? + (this.#pendingRootTurn?.nativeTurnId === turnId + ? this.#pendingRootTurn.completionClosuresCommitted + : false) + ); + } + + markCompletionClosuresCommitted(turnId: string): void { + const turn = this.#activeTurns.get(retainedOpenAiTurnKey(turnId)); + if (turn !== undefined) { + turn.completionClosuresCommitted = true; + return; + } + if (this.#pendingRootTurn?.nativeTurnId === turnId) { + this.#pendingRootTurn.completionClosuresCommitted = true; + } } activeTurnIds(): string[] { - return [...this.#activeTurns.keys()]; + return [...this.#activeTurns.values()].map(({ nativeTurnId }) => nativeTurnId); } clearActiveTurns(): void { + this.#pendingRootTurn?.resolveBoundTurnId(null); + this.#pendingRootTurn = null; this.#activeTurns.clear(); + this.#ignoredTurnIds.clear(); this.#settlingTurnIds.clear(); this.#startedTurnIds.clear(); this.#terminalTurns.clear(); } hasTerminal(turnId: string): boolean { - return this.#settlingTurnIds.has(turnId) || this.#terminalTurns.has(turnId); + const key = retainedOpenAiTurnKey(turnId); + return this.#settlingTurnIds.has(key) || this.#terminalTurns.has(key); } markTurnStarted(turnId: string): boolean { - if (this.#startedTurnIds.has(turnId) || this.hasTerminal(turnId)) { + if (this.#startedTurnIds.has(retainedOpenAiTurnKey(turnId)) || this.hasTerminal(turnId)) { return false; } @@ -65,13 +281,17 @@ export class OpenAiTurnTracker { return true; } + hasTurnStarted(turnId: string): boolean { + return this.#startedTurnIds.has(retainedOpenAiTurnKey(turnId)); + } + rejectTurn(turnId: string, error: Error): boolean { return this.settle(turnId, { error, kind: "failed" }); } rejectActiveTurns(error: Error): void { for (const turnId of this.activeTurnIds()) { - this.#settlingTurnIds.delete(turnId); + this.#settlingTurnIds.delete(retainedOpenAiTurnKey(turnId)); this.rejectTurn(turnId, error); } } @@ -81,16 +301,17 @@ export class OpenAiTurnTracker { return false; } - this.#settlingTurnIds.add(turnId); + this.#settlingTurnIds.add(retainedOpenAiTurnKey(turnId)); return true; } cancelSettlement(turnId: string): void { - this.#settlingTurnIds.delete(turnId); + this.#settlingTurnIds.delete(retainedOpenAiTurnKey(turnId)); } - finishSettlement(turnId: string, terminalTurn: TerminalOpenAiTurn): boolean { - if (!this.#settlingTurnIds.delete(turnId) || this.#terminalTurns.has(turnId)) { + finishSettlement(turnId: string, terminalTurn: OpenAiTurnTerminalOutcome): boolean { + const key = retainedOpenAiTurnKey(turnId); + if (!this.#settlingTurnIds.delete(key) || this.#terminalTurns.has(key)) { return false; } @@ -98,7 +319,7 @@ export class OpenAiTurnTracker { return true; } - settle(turnId: string, terminalTurn: TerminalOpenAiTurn): boolean { + settle(turnId: string, terminalTurn: OpenAiTurnTerminalOutcome): boolean { if (this.hasTerminal(turnId)) { return false; } @@ -107,10 +328,14 @@ export class OpenAiTurnTracker { return true; } - #recordTerminal(turnId: string, terminalTurn: TerminalOpenAiTurn): void { - this.#startedTurnIds.delete(turnId); - rememberBounded(this.#terminalTurns, turnId, terminalTurn); - const activeTurn = this.#activeTurns.get(turnId); + #recordTerminal(turnId: string, terminalTurn: OpenAiTurnTerminalOutcome): void { + const key = retainedOpenAiTurnKey(turnId); + this.#startedTurnIds.delete(key); + const activeTurn = this.#activeTurns.get(key); + rememberBounded(this.#terminalTurns, turnId, { + ...terminalTurn, + runId: activeTurn?.runId ?? this.pendingTurnContext(turnId)?.runId ?? null, + }); if (activeTurn === undefined) { return; @@ -122,32 +347,53 @@ export class OpenAiTurnTracker { activeTurn.reject(terminalTurn.error); } - this.#activeTurns.delete(turnId); + this.#activeTurns.delete(key); + } + + track(turnId: string, runId: RunId, signal?: AbortSignal): Promise { + try { + return this.#track(turnId, runId, signal); + } catch (error) { + return Promise.reject(error); + } } - async track(turnId: string, runId: RunId): Promise { - const terminalTurn = this.#terminalTurns.get(turnId); + #track(turnId: string, runId: RunId, signal?: AbortSignal): Promise { + const key = retainedOpenAiTurnKey(turnId); + const terminalTurn = this.#terminalTurns.get(key); if (terminalTurn?.kind === "completed") { - return; + if (terminalTurn.runId !== null && terminalTurn.runId !== runId) { + throw new Error("OpenAI terminal turn belongs to another run."); + } + return Promise.resolve(); } if (terminalTurn?.kind === "failed") { - throw terminalTurn.error; + if (terminalTurn.runId !== null && terminalTurn.runId !== runId) { + throw new Error("OpenAI terminal turn belongs to another run."); + } + return Promise.reject(terminalTurn.error); } - const activeTurn = this.#activeTurns.get(turnId); + const activeTurn = this.#activeTurns.get(key); if (activeTurn !== undefined) { if (activeTurn.runId !== runId) { - throw new Error(`Turn ${turnId} is already tracked by another run.`); + throw new Error("OpenAI turn is already tracked by another run."); + } + if (activeTurn.cancellationSignal !== (signal ?? null)) { + throw new Error("OpenAI turn cancellation signal changed while it was active."); } return activeTurn.promise; } const turn = Promise.withResolvers(); - this.#activeTurns.set(turnId, { + this.#activeTurns.set(key, { + cancellationSignal: signal ?? null, + completionClosuresCommitted: false, + nativeTurnId: turnId, promise: turn.promise, reject: turn.reject, resolve: () => { diff --git a/src/runtimes/openai/app-server-turn-validation.ts b/src/runtimes/openai/app-server-turn-validation.ts new file mode 100644 index 0000000..2ad6a73 --- /dev/null +++ b/src/runtimes/openai/app-server-turn-validation.ts @@ -0,0 +1,19 @@ +import { isRecord } from "./app-server-json"; +import type { JsonObject } from "./app-server-json"; + +export function validateTurnStatusError(turn: JsonObject, requireTerminal: boolean): string | null { + const status = turn["status"]; + + if ( + requireTerminal && + status !== "completed" && + status !== "failed" && + status !== "interrupted" + ) { + return "turn.status must be terminal."; + } + + return (status === "failed") !== isRecord(turn["error"]) + ? "turn.error must be present exactly when the turn failed." + : null; +} diff --git a/src/runtimes/openai/auth-state.ts b/src/runtimes/openai/auth-state.ts index 17c34de..2925fbb 100644 --- a/src/runtimes/openai/auth-state.ts +++ b/src/runtimes/openai/auth-state.ts @@ -1,15 +1,40 @@ -import { chmod, mkdir, readFile, stat, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { chmod, lstat, mkdtemp, rename, rm, rmdir, symlink } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { isDriverId } from "../../protocol/id"; +import type { DriverInstanceId } from "../../protocol/id"; import type { JsonObject, JsonValue } from "../../protocol/json"; -import { isJsonObject } from "../../protocol/json"; +import { + ensureAbsoluteRealDirectory, + ensureRealDirectoryAt, + hasErrorCode, + readPathStats, + writeFileAtomicallyAtPath, +} from "../atomic-file"; import { mergeProviderOptions } from "../provider-options"; -interface OpenAiApiKeyAuthStateInput { + +interface OpenAiRuntimeHomeInput { + driverGeneration: number; + driverInstanceId: DriverInstanceId; + persistentRuntimeHome: string; + signal?: AbortSignal; +} + +interface OpenAiRuntimeHomeState { + cleanupPath: string; + cleanupRoot: string | null; + readonly dev: number; + readonly ino: number; + readonly persistentRuntimeHome: string; + readonly runtimeHome: string; +} + +interface OpenAiAuthStateInput { env: NodeJS.ProcessEnv; runtimeHome: string; } -interface OpenAiApiKeyAuthStateResult { +interface OpenAiAuthStateResult { authJsonPath: string; hasApiKey: boolean; written: boolean; @@ -34,10 +59,18 @@ const OPENAI_COMPATIBLE_API_KEY_ENV_NAME = "OPENAI_COMPATIBLE_API_KEY"; const OPENAI_COMPATIBLE_BASE_URL_ENV_NAME = "OPENAI_COMPATIBLE_BASE_URL"; const OPENAI_BASE_URL_ENV_NAME = "OPENAI_BASE_URL"; const DISABLED_RUNTIME_FEATURES = ["plugins", "remote_plugin", "tool_suggest"] as const; +const OPENAI_RUNTIME_HOME_PREFIX = "/tmp/.mosoo-agent-driver-openai-"; +const OPENAI_RUNTIME_HOME_CLEANUP_PREFIX = "/tmp/.mosoo-agent-driver-openai-cleanup-"; +const PERSISTENT_OPENAI_RUNTIME_DIRECTORIES = [ + "sessions", + "archived_sessions", + "memories", + "memories_extensions", +] as const; function readOpenAiApiKey(env: NodeJS.ProcessEnv): string | null { const value = env["OPENAI_API_KEY"]?.trim(); - return value ?? null; + return value || null; } function readEnvVar(env: NodeJS.ProcessEnv, key: string): string | null { @@ -45,114 +78,179 @@ function readEnvVar(env: NodeJS.ProcessEnv, key: string): string | null { return value || null; } -function toTomlString(value: string): string { - return JSON.stringify(value); -} - -function toTomlKeySegment(value: string): string { - return /^[A-Za-z0-9_-]+$/.test(value) ? value : toTomlString(value); +function assertOpenAiRuntimeIdentity( + driverInstanceId: DriverInstanceId, + driverGeneration: number, +): void { + if ( + !isDriverId(driverInstanceId) || + !Number.isSafeInteger(driverGeneration) || + driverGeneration < 0 + ) { + throw new TypeError("OpenAI runtime identity is invalid."); + } } -function toTomlInlineValue(value: JsonValue, path: string): string { - if (value === null) { - throw new Error(`OpenAI provider option ${path} cannot be null in config.toml.`); +async function readRuntimeHomeIdentity(runtimeHome: string): Promise<{ + readonly dev: number; + readonly ino: number; +} | null> { + try { + const stats = await lstat(runtimeHome); + return stats.isDirectory() && !stats.isSymbolicLink() + ? { dev: stats.dev, ino: stats.ino } + : null; + } catch (error) { + if (hasErrorCode(error, "ENOENT")) { + return null; + } + throw error; } +} - if (typeof value === "string") { - return toTomlString(value); - } +export async function createOpenAiRuntimeHome( + input: OpenAiRuntimeHomeInput, +): Promise { + input.signal?.throwIfAborted(); + assertOpenAiRuntimeIdentity(input.driverInstanceId, input.driverGeneration); + const persistentRuntimeHome = resolve(input.persistentRuntimeHome); + await using persistentHome = await ensureAbsoluteRealDirectory( + persistentRuntimeHome, + "Persistent OpenAI runtime home", + input.signal, + ); + const persistentAuthPath = join(persistentRuntimeHome, "auth.json"); - if (typeof value === "number" || typeof value === "boolean") { - return String(value); + if ((await readPathStats(persistentAuthPath)) !== null) { + throw new Error( + `Persistent OpenAI runtime home must not contain credentials: ${persistentAuthPath}.`, + ); } - if (Array.isArray(value)) { - return `[${value.map((entry, index) => toTomlInlineValue(entry, `${path}[${index}]`)).join(", ")}]`; + for (const name of PERSISTENT_OPENAI_RUNTIME_DIRECTORIES) { + await using _directory = await ensureRealDirectoryAt( + persistentHome, + name, + `Persistent OpenAI ${name} directory`, + input.signal, + ); } - return `{ ${Object.entries(value) - .map( - ([key, entry]) => `${toTomlKeySegment(key)} = ${toTomlInlineValue(entry, `${path}.${key}`)}`, - ) - .join(", ")} }`; -} - -function isTomlTable(value: unknown): value is JsonObject { - return isJsonObject(value); -} + input.signal?.throwIfAborted(); + const runtimeHome = await mkdtemp( + `${OPENAI_RUNTIME_HOME_PREFIX}${input.driverInstanceId}-g${String(input.driverGeneration)}-`, + ); -function appendTomlObject( - lines: string[], - object: Record, - path: string[] = [], -): void { - if (path.length > 0) { - if (lines.length > 0 && lines.at(-1) !== "") { - lines.push(""); + try { + input.signal?.throwIfAborted(); + await chmod(runtimeHome, 0o700); + await Promise.all( + PERSISTENT_OPENAI_RUNTIME_DIRECTORIES.map((name) => + symlink(join(persistentRuntimeHome, name), join(runtimeHome, name)), + ), + ); + input.signal?.throwIfAborted(); + const identity = await readRuntimeHomeIdentity(runtimeHome); + + if (identity === null) { + throw new Error(`OpenAI runtime home is not a real directory: ${runtimeHome}.`); } - lines.push(`[${path.map(toTomlKeySegment).join(".")}]`); - } - - const nestedEntries: [string, JsonObject][] = []; - - for (const [key, value] of Object.entries(object)) { - if (isTomlTable(value)) { - nestedEntries.push([key, value]); - } else { - lines.push( - `${toTomlKeySegment(key)} = ${toTomlInlineValue(value, [...path, key].join("."))}`, + return { + cleanupPath: runtimeHome, + cleanupRoot: null, + ...identity, + persistentRuntimeHome, + runtimeHome, + }; + } catch (error) { + try { + await rm(runtimeHome, { force: true, recursive: true }); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + "OpenAI runtime home creation and cleanup failed.", ); } - } - - for (const [key, value] of nestedEntries) { - appendTomlObject(lines, value, [...path, key]); + throw error; } } -function stringifyToml(object: Record): string { - const lines: string[] = []; - appendTomlObject(lines, object); - return `${lines.join("\n")}\n`; -} - -async function writeFileIfChanged( - path: string, - contents: string, - options?: { mode?: number }, -): Promise { - const existing = await readFile(path, "utf8").catch(() => null); - - if (existing === contents) { - if (options?.mode !== undefined) { - const fileStat = await stat(path); - const currentMode = fileStat.mode & 0o777; +export async function cleanupOpenAiRuntimeHome(state: OpenAiRuntimeHomeState): Promise { + let cleanupRoot = state.cleanupRoot; + + if (cleanupRoot === null) { + cleanupRoot = await mkdtemp(OPENAI_RUNTIME_HOME_CLEANUP_PREFIX); + const cleanupPath = join(cleanupRoot, "runtime-home"); + + try { + await rename(state.cleanupPath, cleanupPath); + } catch (error) { + let cleanupError: unknown = null; + try { + await rmdir(cleanupRoot); + } catch (failure) { + cleanupError = failure; + } - if (currentMode !== options.mode) { - await chmod(path, options.mode); + if (hasErrorCode(error, "ENOENT") && cleanupError === null) { + return false; } + throw cleanupError === null + ? error + : new AggregateError([error, cleanupError], "OpenAI cleanup quarantine failed."); } - return false; + state.cleanupPath = cleanupPath; + state.cleanupRoot = cleanupRoot; } - await writeFile(path, contents, { encoding: "utf8" }); + const identity = await readRuntimeHomeIdentity(state.cleanupPath); - if (options?.mode !== undefined) { - await chmod(path, options.mode); + if (identity === null) { + if ((await readPathStats(state.cleanupPath)) !== null) { + throw new Error( + `OpenAI cleanup preserved an unexpected runtime home at ${state.cleanupPath}.`, + ); + } + await rmdir(cleanupRoot); + state.cleanupPath = state.runtimeHome; + state.cleanupRoot = null; + return true; } - return true; + if (identity.dev !== state.dev || identity.ino !== state.ino) { + throw new Error(`OpenAI cleanup preserved an unexpected runtime home at ${state.cleanupPath}.`); + } + + try { + await rm(state.cleanupPath, { recursive: true }); + await rmdir(cleanupRoot); + state.cleanupPath = state.runtimeHome; + state.cleanupRoot = null; + return true; + } catch (error) { + if (hasErrorCode(error, "ENOENT")) { + await rmdir(cleanupRoot).catch((cleanupError: unknown) => { + if (!hasErrorCode(cleanupError, "ENOENT")) { + throw cleanupError; + } + }); + state.cleanupPath = state.runtimeHome; + state.cleanupRoot = null; + return true; + } + throw error; + } } -export async function materializeOpenAiApiKeyAuthState( - input: OpenAiApiKeyAuthStateInput, -): Promise { +export async function materializeOpenAiAuthState( + input: OpenAiAuthStateInput, +): Promise { const authJsonPath = join(input.runtimeHome, "auth.json"); const apiKey = readOpenAiApiKey(input.env); - if (!apiKey) { + if (apiKey === null) { return { authJsonPath, hasApiKey: false, @@ -160,8 +258,7 @@ export async function materializeOpenAiApiKeyAuthState( }; } - await mkdir(input.runtimeHome, { recursive: true }); - const written = await writeFileIfChanged( + await writeFileAtomicallyAtPath( authJsonPath, `${JSON.stringify( { @@ -179,7 +276,7 @@ export async function materializeOpenAiApiKeyAuthState( return { authJsonPath, hasApiKey: true, - written, + written: true, }; } @@ -224,8 +321,10 @@ export async function materializeOpenAiModelProviderConfig( const config = mergeProviderOptions(generatedConfig, input.providerOptions ?? {}); - await mkdir(input.runtimeHome, { recursive: true }); - const written = await writeFileIfChanged(configTomlPath, stringifyToml(config)); + const written = await writeFileAtomicallyAtPath(configTomlPath, Bun.TOML.stringify(config)!, { + mode: 0o666, + skipIfUnchanged: true, + }); return { configTomlPath, diff --git a/src/runtimes/openai/contract-adapter-state.ts b/src/runtimes/openai/contract-adapter-state.ts deleted file mode 100644 index 9533029..0000000 --- a/src/runtimes/openai/contract-adapter-state.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { AuthorityOutcomeUnknownError } from "../../contract"; -import type { JsonRpcId } from "./app-server-json"; -import type { PendingServerRequest } from "./contract-interactions"; -import { OpenAiPrivateCitationStreamFilter } from "./private-citation-filter"; -import type { ContractProjection } from "../contract-projection"; - -export class OpenAiContractAdapterState { - readonly #interactions = new Map(); - readonly #maxPendingServerRequestBytes: number; - readonly #messageFilters = new Map(); - #pendingServerRequestBytes = 0; - readonly #receiptTimes = new Map(); - readonly #textEncoder = new TextEncoder(); - #unknownReceiptEventId: string | undefined; - - constructor(maxPendingServerRequestBytes: number) { - this.#maxPendingServerRequestBytes = maxPendingServerRequestBytes; - } - - findInteraction(requestId: JsonRpcId): PendingServerRequest | undefined { - return [...this.#interactions.values()].find((pending) => pending.requestId === requestId); - } - - interaction(interactionId: string): PendingServerRequest | undefined { - return this.#interactions.get(interactionId); - } - - reserveInteraction(pending: Omit): PendingServerRequest { - const bytes = this.#textEncoder.encode(JSON.stringify(pending)).byteLength; - - if (bytes > this.#maxPendingServerRequestBytes - this.#pendingServerRequestBytes) { - throw new RangeError("OpenAI app-server pending request budget is exhausted."); - } - - const tracked = { ...pending, bytes }; - this.#interactions.set(pending.interaction.id, tracked); - this.#pendingServerRequestBytes += bytes; - return tracked; - } - - async commitInteraction( - pending: PendingServerRequest, - projection: ContractProjection, - ): Promise { - if (projection.interaction(pending.interaction.id) !== undefined) { - return; - } - - pending.commit ??= projection - .putInteraction( - pending.interaction.runId, - pending.method, - { - providerEventId: `${pending.method}:${String(pending.requestId)}`.slice(0, 256), - type: "provider", - }, - pending.interaction, - ) - .then(() => {}) - .finally(() => { - pending.commit = undefined; - }); - await pending.commit; - } - - dropInteraction(interactionId: string): PendingServerRequest | undefined { - const pending = this.#interactions.get(interactionId); - - if (pending !== undefined) { - this.#pendingServerRequestBytes -= pending.bytes; - this.#interactions.delete(interactionId); - } - - return pending; - } - - releaseTurn(turnId: string): PendingServerRequest[] { - const dropped: PendingServerRequest[] = []; - - for (const [id, pending] of this.#interactions) { - if (pending.turnId === turnId) { - this.dropInteraction(id); - dropped.push(pending); - } - } - - const prefix = `${turnId}\u0000`; - for (const key of this.#messageFilters.keys()) { - if (key.startsWith(prefix)) { - this.#messageFilters.delete(key); - } - } - - return dropped; - } - - messageFilter(turnId: string, itemId: string): OpenAiPrivateCitationStreamFilter { - const key = `${turnId}\u0000${itemId}`; - let filter = this.#messageFilters.get(key); - - if (filter === undefined) { - filter = new OpenAiPrivateCitationStreamFilter(); - this.#messageFilters.set(key, filter); - } - - return filter; - } - - deleteMessageFilter(turnId: string, itemId: string): void { - this.#messageFilters.delete(`${turnId}\u0000${itemId}`); - } - - async withReceiptTime( - eventId: string, - now: () => string, - operation: (occurredAt: string) => Promise, - ): Promise { - const occurredAt = this.#receiptTimes.get(eventId) ?? now(); - this.#receiptTimes.set(eventId, occurredAt); - - try { - const result = await operation(occurredAt); - this.#receiptTimes.delete(eventId); - if (this.#unknownReceiptEventId === eventId) { - this.#unknownReceiptEventId = undefined; - } - return result; - } catch (error) { - if (error instanceof AuthorityOutcomeUnknownError) { - this.#unknownReceiptEventId ??= eventId; - - if (this.#unknownReceiptEventId !== eventId) { - this.#receiptTimes.delete(eventId); - } - } else { - this.#receiptTimes.delete(eventId); - if (this.#unknownReceiptEventId === eventId) { - this.#unknownReceiptEventId = undefined; - } - } - throw error; - } - } - - clear(): void { - this.#interactions.clear(); - this.#messageFilters.clear(); - this.#pendingServerRequestBytes = 0; - this.#receiptTimes.clear(); - this.#unknownReceiptEventId = undefined; - } -} diff --git a/src/runtimes/openai/contract-adapter-types.ts b/src/runtimes/openai/contract-adapter-types.ts deleted file mode 100644 index 235cce8..0000000 --- a/src/runtimes/openai/contract-adapter-types.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { MutationCause, Run, TokenUsage } from "../../contract"; -import type { ContractAuthorityUpdate, ContractProjectionOptions } from "../contract-projection"; -import type { JsonRpcId } from "./app-server-json"; - -export interface OpenAiTurnState { - cause: MutationCause; - run: Run; - runId: string; - threadId: string; - turnId: string; - usageBaseline?: TokenUsage; -} - -export interface PendingOpenAiTurnAttachment { - cause: MutationCause; - mutationId: string; - run: Run; - task?: Promise | undefined; - turn: OpenAiTurnState; -} - -export interface OpenAiAuthorityUpdate extends ContractAuthorityUpdate { - readonly turnId: string; -} - -export interface OpenAiContractAdapterOptions extends Omit { - readonly authority: (update: OpenAiAuthorityUpdate) => Promise; - readonly createId?: (() => string) | undefined; - readonly interactionTimeoutMs?: number | undefined; - readonly maxPendingServerRequestBytes?: number | undefined; -} - -export interface OpenAiTurnAttachment { - readonly cause: MutationCause; - readonly run: Run; - readonly threadId: string; - readonly turnId: string; -} - -export interface OpenAiServerReply { - readonly id: JsonRpcId; - readonly result: unknown; -} diff --git a/src/runtimes/openai/contract-adapter.ts b/src/runtimes/openai/contract-adapter.ts deleted file mode 100644 index 7a18749..0000000 --- a/src/runtimes/openai/contract-adapter.ts +++ /dev/null @@ -1,743 +0,0 @@ -import { isDeepStrictEqual } from "node:util"; - -import { - assertProtocolAdmission, - authorityContent, - interactionSchema, - runSchema, -} from "../../contract"; -import type { - Interaction, - InteractionResolution, - Item, - ProtocolAdmissionLimits, -} from "../../contract"; -import { createDriverId } from "../../protocol/id"; -import { readArray, readNonEmptyString, readRecord, readString } from "./app-server-json"; -import type { JsonObject, JsonRpcId } from "./app-server-json"; -import { ContractProjection } from "../contract-projection"; -import { - monotonicUsage, - type NativeItemLifecycle, - projectOpenAiItem, - providerEventId, - provenance, - readFiniteNumber, - requireRecord, - requireString, - subtractUsage, - toUsage, -} from "./contract-items"; -import { - type PendingServerRequest, - projectOpenAiInteraction, - toOpenAiRequestResult, -} from "./contract-interactions"; -import { OpenAiContractTurnInbox } from "./contract-turn-inbox"; -import { finishOpenAiTurn, projectOpenAiPlan } from "./contract-turn-lifecycle"; -import { OpenAiContractAdapterState } from "./contract-adapter-state"; -import type { - OpenAiAuthorityUpdate, - OpenAiContractAdapterOptions, - OpenAiServerReply, - OpenAiTurnAttachment, - OpenAiTurnState, - PendingOpenAiTurnAttachment, -} from "./contract-adapter-types"; - -export { OPENAI_APP_SERVER_MCP_ELICITATION_EXTENSION } from "./contract-interactions"; - -const DEFAULT_INTERACTION_TIMEOUT_MS = 5 * 60 * 1_000; -const DEFAULT_PENDING_SERVER_REQUEST_BYTES = 8 * 1_024 * 1_024; -export type { - OpenAiAuthorityUpdate, - OpenAiContractAdapterOptions, - OpenAiServerReply, - OpenAiTurnAttachment, -} from "./contract-adapter-types"; - -export class OpenAiContractAdapter { - readonly #admissionLimits: ProtocolAdmissionLimits | undefined; - readonly #authority: OpenAiContractAdapterOptions["authority"]; - readonly #createId: () => string; - readonly #inbox: OpenAiContractTurnInbox; - readonly #interactionTimeoutMs: number; - readonly #maxPendingServerRequestBytes: number; - readonly #pendingTurns = new Map(); - readonly #projection: ContractProjection; - readonly #sessionId: string; - readonly #state: OpenAiContractAdapterState; - readonly #turns = new Map(); - #disposed = false; - - constructor(options: OpenAiContractAdapterOptions) { - this.#admissionLimits = - options.admissionLimits === undefined ? undefined : { ...options.admissionLimits }; - this.#authority = options.authority; - this.#createId = options.createId ?? createDriverId; - this.#interactionTimeoutMs = options.interactionTimeoutMs ?? DEFAULT_INTERACTION_TIMEOUT_MS; - this.#maxPendingServerRequestBytes = - options.maxPendingServerRequestBytes ?? DEFAULT_PENDING_SERVER_REQUEST_BYTES; - this.#state = new OpenAiContractAdapterState(this.#maxPendingServerRequestBytes); - this.#sessionId = options.sessionId; - this.#projection = new ContractProjection({ - admissionLimits: this.#admissionLimits, - authority: async (update) => { - const turn = [...this.#turns.values()].find((value) => value.runId === update.runId); - - if (turn === undefined) { - throw new Error(`OpenAI app-server update references unknown run ${update.runId}.`); - } - - await options.authority({ ...update, turnId: turn.turnId }); - }, - now: options.now, - preview: options.preview, - previewCheckpointBytes: options.previewCheckpointBytes, - previewReplaceIntervalMs: options.previewReplaceIntervalMs, - sessionId: options.sessionId, - }); - this.#inbox = new OpenAiContractTurnInbox({ - dispatch: (method, params) => this.#dispatchNotification(method, params), - replayEnd: (params) => this.#onTurnEnd(params, "turn/completed"), - }); - - if ( - [this.#interactionTimeoutMs, this.#maxPendingServerRequestBytes].some( - (value) => !Number.isSafeInteger(value) || value < 1, - ) - ) { - throw new RangeError("OpenAI Contract adapter limits must be finite and positive."); - } - } - - /** Bind turn/started to the Run already created by the Coordinator. */ - async attachTurn(input: OpenAiTurnAttachment): Promise { - this.#assertActive(); - const run = runSchema.parse({ - ...input.run, - provenance: provenance("turn/started", { - threadId: input.threadId, - turnId: input.turnId, - }), - }); - - if (run.status !== "active") { - throw new Error(`OpenAI turn ${input.turnId} requires an active run.`); - } - - const existing = this.#turns.get(input.turnId); - - if (existing !== undefined) { - if (existing.runId !== run.id || existing.threadId !== input.threadId) { - throw new Error(`OpenAI turn ${input.turnId} is already registered to another run.`); - } - if ( - !isDeepStrictEqual(existing.run, run) || - !isDeepStrictEqual(existing.cause, input.cause) - ) { - throw new Error(`OpenAI turn ${input.turnId} is registered with different state.`); - } - - this.#projection.attachRun(run); - await this.#inbox.replay(input.turnId); - return; - } - - const ended = this.#inbox.ended(input.turnId); - - if (ended !== undefined) { - if (ended.runId !== run.id || ended.threadId !== input.threadId) { - throw new Error(`OpenAI turn ${input.turnId} is already registered to another run.`); - } - if (!isDeepStrictEqual(ended.run, run) || !isDeepStrictEqual(ended.cause, input.cause)) { - throw new Error(`OpenAI turn ${input.turnId} is registered with different state.`); - } - return; - } - - const pending = this.#pendingTurns.get(input.turnId); - - if (pending !== undefined) { - if ( - pending.turn.runId !== run.id || - pending.turn.threadId !== input.threadId || - !isDeepStrictEqual(pending.run, run) || - !isDeepStrictEqual(pending.cause, input.cause) - ) { - throw new Error(`OpenAI turn ${input.turnId} changed while its attachment was pending.`); - } - - await this.#commitTurn(pending); - await this.#inbox.replay(input.turnId); - return; - } - - if ( - [ - ...this.#turns.values(), - ...[...this.#pendingTurns.values()].map((entry) => entry.turn), - ].some((turn) => turn.runId === run.id) - ) { - throw new Error(`OpenAI run ${run.id} is already attached to another turn.`); - } - - const turn = { - cause: input.cause, - run, - runId: run.id, - threadId: input.threadId, - turnId: input.turnId, - }; - const attachment = { - cause: input.cause, - mutationId: this.#createId(), - run, - turn, - }; - this.#pendingTurns.set(input.turnId, attachment); - await this.#commitTurn(attachment); - await this.#inbox.replay(input.turnId); - } - - async handleNotification(method: string, value: unknown): Promise { - this.#assertActive(); - const params = requireRecord(value, `${method} params`); - const eventTurnId = - readNonEmptyString(params, "turnId") ?? readNonEmptyString(readRecord(params, "turn"), "id"); - - if (eventTurnId !== null && this.#inbox.hasEnded(eventTurnId)) { - return; - } - - if ( - eventTurnId !== null && - (!this.#turns.has(eventTurnId) || this.#inbox.shouldBuffer(eventTurnId)) - ) { - if (method === "turn/completed") { - this.#inbox.rememberEnd(eventTurnId, params); - } else { - this.#inbox.rememberNotification(eventTurnId, method, params); - } - return; - } - - await this.#dispatchNotification(method, params); - } - - async #dispatchNotification(method: string, params: JsonObject): Promise { - switch (method) { - case "item/started": - await this.#onItem(params, "started", method); - return; - case "item/completed": - await this.#onItem(params, "completed", method); - return; - case "item/agentMessage/delta": - await this.#onText(params, method, "message.text", "delta"); - return; - case "item/reasoning/summaryPartAdded": - await this.#onReasoningPart(params, method); - return; - case "item/reasoning/summaryTextDelta": - await this.#onText(params, method, "reasoning.text", "delta"); - return; - case "item/commandExecution/outputDelta": - await this.#onText(params, method, "terminal.stdout", "delta"); - return; - case "item/mcpToolCall/progress": - await this.#onProgress(params, method); - return; - case "serverRequest/resolved": - await this.#onRequestResolved(params, method); - return; - case "item/fileChange/patchUpdated": - await this.#onPatch(params, method); - return; - case "turn/plan/updated": - await this.#onPlan(params, method); - return; - case "thread/tokenUsage/updated": - await this.#onUsage(params, method); - return; - case "turn/completed": - await this.#onTurnEnd(params, method); - return; - default: - return; - } - } - - async handleServerRequest( - method: string, - requestId: JsonRpcId, - value: unknown, - ): Promise { - this.#assertActive(); - const params = requireRecord(value, `${method} params`); - const turnId = readNonEmptyString(params, "turnId"); - - if (turnId === null) { - return null; - } - - const turn = this.#turns.get(turnId); - - if (turn === undefined) { - throw new Error(`OpenAI app-server request ${String(requestId)} arrived before attachment.`); - } - - if (this.#projection.run(turn.runId)?.status !== "active") { - return null; - } - - const existing = this.#state.findInteraction(requestId); - - if (existing !== undefined) { - const pending = existing; - - if ( - pending.method !== method || - pending.turnId !== turnId || - !isDeepStrictEqual(pending.params, params) - ) { - throw new Error( - `OpenAI app-server request ${String(requestId)} changed identity or content.`, - ); - } - - await this.#state.commitInteraction(pending, this.#projection); - return pending.interaction.id; - } - - const interaction = this.#projectInteraction(method, requestId, params, turn); - - if (interaction === null) { - return null; - } - - const pending = { - interaction, - method, - params: structuredClone(params), - requestId, - turnId, - }; - const tracked = this.#state.reserveInteraction(pending); - await this.#state.commitInteraction(tracked, this.#projection); - return interaction.id; - } - - resolveInteraction( - interactionId: string, - resolution: InteractionResolution, - ): OpenAiServerReply | null { - this.#assertActive(); - const pending = this.#state.interaction(interactionId); - - if (pending === undefined) { - return null; - } - - const result = this.#toRequestResult(pending, resolution); - this.#dropInteraction(interactionId); - return { id: pending.requestId, result }; - } - - dispose(): void { - this.#disposed = true; - this.#projection.dispose(); - this.#inbox.dispose(); - this.#pendingTurns.clear(); - this.#state.clear(); - this.#turns.clear(); - } - - async #onItem(params: JsonObject, lifecycle: NativeItemLifecycle, method: string): Promise { - const turn = this.#requireTurn(params, method); - const item = requireRecord(params["item"], `${method} params.item`); - const itemId = requireString(item, "id", `${method} params.item`); - const existing = this.#projection.item(turn.runId, itemId); - - if (existing !== undefined && existing.status !== "active") { - return; - } - - const eventId = providerEventId(method, params); - await this.#withReceiptTime(eventId, async (occurredAt) => { - const projected = this.#projectItem(turn, item, lifecycle, occurredAt, method); - - if (projected === null) { - return; - } - - await this.#projection.putItem( - turn.runId, - method, - { providerEventId: eventId, type: "provider" }, - projected, - ); - }); - - if (lifecycle === "completed") { - this.#state.deleteMessageFilter(turn.turnId, itemId); - } - } - - async #onPatch(params: JsonObject, method: string): Promise { - const turn = this.#requireTurn(params, method); - const itemId = requireString(params, "itemId", `${method} params`); - const existing = this.#projection.item(turn.runId, itemId); - - if (existing !== undefined && existing.status !== "active") { - return; - } - - const eventId = providerEventId(method, params); - await this.#withReceiptTime(eventId, async (occurredAt) => { - const projected = this.#projectItem( - turn, - { - changes: readArray(params, "changes"), - id: itemId, - status: "inProgress", - type: "fileChange", - }, - "started", - occurredAt, - method, - ); - - if (projected === null) { - return; - } - - await this.#projection.putItem( - turn.runId, - method, - { providerEventId: eventId, type: "provider" }, - projected, - ); - }); - } - - async #onPlan(params: JsonObject, method: string): Promise { - const turn = this.#requireTurn(params, method); - const itemId = "turn-plan"; - const previous = this.#projection.item(turn.runId, itemId); - const eventId = providerEventId(method, params); - await this.#withReceiptTime(eventId, async (occurredAt) => { - const plan = projectOpenAiPlan(turn, params, occurredAt, method, previous); - - await this.#projection.putItem( - turn.runId, - method, - { providerEventId: eventId, type: "provider" }, - plan, - ); - }); - } - - async #onUsage(params: JsonObject, method: string): Promise { - const turn = this.#requireTurn(params, method); - const tokenUsage = readRecord(params, "tokenUsage"); - const last = toUsage(readRecord(tokenUsage, "last")); - const total = toUsage(readRecord(tokenUsage, "total")); - - if (last === undefined && total === undefined) { - return; - } - - if (total !== undefined && turn.usageBaseline === undefined) { - turn.usageBaseline = subtractUsage(total, last ?? total); - } - - const candidate = - total === undefined ? last! : subtractUsage(total, turn.usageBaseline ?? total); - const usage = monotonicUsage(this.#projection.run(turn.runId)?.usage, candidate); - - await this.#projection.updateUsage( - turn.runId, - method, - { providerEventId: providerEventId(method, params), type: "provider" }, - usage, - ); - } - - async #onTurnEnd(params: JsonObject, method: string): Promise { - const nativeTurn = requireRecord(params["turn"], `${method} params.turn`); - const turnId = requireString(nativeTurn, "id", `${method} params.turn`); - const turn = this.#turns.get(turnId); - - if (turn === undefined || this.#projection.run(turn.runId)?.status !== "active") { - return; - } - - await finishOpenAiTurn({ - method, - params, - projectItem: (state, item, lifecycle, occurredAt, event) => - this.#projectItem(state, item, lifecycle, occurredAt, event), - projection: this.#projection, - release: (id) => this.#releaseTurn(id), - rememberEnded: (state) => this.#inbox.rememberEnded(state.turnId, state), - turn, - withReceiptTime: (eventId, operation) => this.#withReceiptTime(eventId, operation), - }); - } - - async #onRequestResolved(params: JsonObject, method: string): Promise { - const requestId = params["requestId"]; - const threadId = requireString(params, "threadId", `${method} params`); - - if (typeof requestId !== "number" && typeof requestId !== "string") { - throw new Error(`${method} params.requestId must be a string or number.`); - } - - const pending = this.#state.findInteraction(requestId); - - if (pending === undefined) { - return; - } - - const interactionId = pending.interaction.id; - const turn = this.#turns.get(pending.turnId); - - if (turn === undefined) { - this.#dropInteraction(interactionId); - return; - } - - if (turn.threadId !== threadId) { - throw new Error(`${method} params references the wrong thread.`); - } - - const eventId = `${method}:${String(requestId)}`.slice(0, 256); - await this.#withReceiptTime(eventId, async (endedAt) => { - const interaction = interactionSchema.parse({ - ...pending.interaction, - endedAt, - status: "expired", - }); - - await this.#projection.putInteraction( - turn.runId, - method, - { providerEventId: eventId, type: "provider" }, - interaction, - ); - this.#dropInteraction(interactionId); - }); - } - - async #onText( - params: JsonObject, - method: string, - channel: "message.text" | "reasoning.text" | "terminal.stdout", - textField: string, - ): Promise { - const turn = this.#requireTurn(params, method); - const itemId = requireString(params, "itemId", `${method} params`); - const rawDelta = readString(params, textField); - const item = this.#projection.item(turn.runId, itemId); - - if ( - rawDelta === null || - rawDelta.length === 0 || - item === undefined || - item.status !== "active" || - (channel === "message.text" && item.kind !== "message") || - (channel === "reasoning.text" && item.kind !== "reasoning") || - (channel === "terminal.stdout" && item.kind !== "terminal") - ) { - return; - } - - const delta = - channel === "message.text" - ? this.#messageFilter(turn.turnId, itemId).push(rawDelta).text - : rawDelta; - - if (delta.length === 0) { - return; - } - - await this.#projection.appendText({ - cause: { - providerEventId: `${providerEventId(method, params)}:checkpoint`.slice(0, 256), - type: "provider", - }, - channel, - delta, - event: method, - itemId, - runId: turn.runId, - }); - } - - async #onReasoningPart(params: JsonObject, method: string): Promise { - const summaryIndex = readFiniteNumber(params, "summaryIndex"); - - if (summaryIndex === null || !Number.isInteger(summaryIndex) || summaryIndex < 1) { - return; - } - - const turn = this.#requireTurn(params, method); - const itemId = requireString(params, "itemId", `${method} params`); - const item = this.#projection.item(turn.runId, itemId); - - if (item === undefined || item.status !== "active" || item.kind !== "reasoning") { - return; - } - - await this.#projection.appendText({ - cause: { - providerEventId: `${providerEventId(method, params)}:checkpoint`.slice(0, 256), - type: "provider", - }, - channel: "reasoning.text", - delta: "\n\n", - event: method, - itemId, - runId: turn.runId, - }); - } - - async #onProgress(params: JsonObject, method: string): Promise { - const turn = this.#requireTurn(params, method); - const itemId = requireString(params, "itemId", `${method} params`); - const message = readString(params, "message"); - - if (message === null) { - return; - } - - await this.#projection.replacePreview({ - channel: "tool.progress", - itemId, - runId: turn.runId, - text: message, - }); - } - - #projectItem( - turn: OpenAiTurnState, - native: JsonObject, - lifecycle: NativeItemLifecycle, - occurredAt: string, - event: string, - ): Item | null { - return projectOpenAiItem(turn, native, lifecycle, occurredAt, event, (runId, itemId) => - this.#projection.item(runId, itemId), - ); - } - - #projectInteraction( - method: string, - requestId: JsonRpcId, - params: JsonObject, - turn: OpenAiTurnState, - ): Interaction | null { - return projectOpenAiInteraction(method, requestId, params, turn, { - createId: this.#createId, - interactionTimeoutMs: this.#interactionTimeoutMs, - item: (runId, itemId) => this.#projection.item(runId, itemId), - now: () => this.#projection.now(), - }); - } - - #toRequestResult(pending: PendingServerRequest, resolution: InteractionResolution): unknown { - return toOpenAiRequestResult(pending, resolution); - } - - #requireTurn(params: JsonObject, method: string): OpenAiTurnState { - const turnId = requireString(params, "turnId", `${method} params`); - const turn = this.#turns.get(turnId); - - if (turn === undefined) { - throw new Error(`OpenAI app-server event ${method} references unknown turn ${turnId}.`); - } - - const threadId = readNonEmptyString(params, "threadId"); - - if (threadId !== null && threadId !== turn.threadId) { - throw new Error(`OpenAI app-server event ${method} references the wrong thread.`); - } - - return turn; - } - - async #commitTurn(pending: PendingOpenAiTurnAttachment): Promise { - const update: OpenAiAuthorityUpdate = { - cause: pending.cause, - event: "turn/started", - mutationId: pending.mutationId, - operations: [{ entity: "run", op: "put", value: pending.run }], - runId: pending.run.id, - sessionId: this.#sessionId, - turnId: pending.turn.turnId, - }; - pending.task ??= Promise.resolve() - .then(async () => { - this.#assertActive(); - if (this.#admissionLimits !== undefined) { - assertProtocolAdmission( - update, - this.#admissionLimits, - authorityContent(update.operations), - ); - } - - await this.#authority(update); - - if (this.#disposed) { - return; - } - - this.#turns.set(pending.turn.turnId, pending.turn); - this.#projection.attachRun(pending.run); - this.#pendingTurns.delete(pending.turn.turnId); - }) - .finally(() => { - pending.task = undefined; - }); - await pending.task; - } - - async #withReceiptTime( - eventId: string, - operation: (occurredAt: string) => Promise, - ): Promise { - return this.#state.withReceiptTime( - eventId, - () => this.#projection.now().toISOString(), - operation, - ); - } - - #dropInteraction(interactionId: string): void { - const pending = this.#state.dropInteraction(interactionId); - - if (pending !== undefined) { - this.#projection.releaseInteraction(interactionId); - } - } - - #releaseTurn(turnId: string): void { - this.#turns.delete(turnId); - - for (const pending of this.#state.releaseTurn(turnId)) { - this.#projection.releaseInteraction(pending.interaction.id); - } - } - - #messageFilter(turnId: string, itemId: string) { - return this.#state.messageFilter(turnId, itemId); - } - - #assertActive(): void { - if (this.#disposed) { - throw new Error("OpenAI Contract adapter is disposed."); - } - } -} diff --git a/src/runtimes/openai/contract-interactions.ts b/src/runtimes/openai/contract-interactions.ts deleted file mode 100644 index 2523856..0000000 --- a/src/runtimes/openai/contract-interactions.ts +++ /dev/null @@ -1,393 +0,0 @@ -import { interactionSchema } from "../../contract"; -import type { Interaction, InteractionResolution, Item } from "../../contract"; -import { asJsonValue } from "../contract-adapter-meta"; -import { isRecord, readArray, readNonEmptyString, readString } from "./app-server-json"; -import type { JsonObject, JsonRpcId } from "./app-server-json"; -import { - dynamicToolName, - provenance, - readFiniteNumber, - requireRecord, - toNativeToolContent, -} from "./contract-items"; - -export const OPENAI_APP_SERVER_MCP_ELICITATION_EXTENSION = "openai.app-server/mcp-elicitation"; - -export interface OpenAiInteractionTurn { - readonly runId: string; - readonly threadId: string; - readonly turnId: string; -} - -export interface PendingServerRequest { - bytes: number; - commit?: Promise | undefined; - interaction: Interaction; - method: string; - params: JsonObject; - requestId: JsonRpcId; - turnId: string; -} - -export function toInputAnswer(params: JsonObject, questionId: string, answer: string): string { - const question = readArray(params, "questions").find( - (entry) => isRecord(entry) && readString(entry, "id") === questionId, - ); - - if (!isRecord(question)) { - return answer; - } - - for (const [index, option] of readArray(question, "options").entries()) { - if (!isRecord(option)) { - continue; - } - - const label = readNonEmptyString(option, "label"); - - if (label !== null && String(index) === answer) { - return label; - } - } - - return answer; -} - -export function selectedOption( - interaction: Interaction, - resolution: Extract["value"], -): string | null { - if (resolution.type === "cancelled") { - return null; - } - - if ( - interaction.kind !== "permission" || - !interaction.request.options.some((option) => option.id === resolution.optionId) - ) { - throw new Error("OpenAI permission resolution selected an unavailable option."); - } - - return resolution.optionId; -} - -export function projectOpenAiInteraction( - method: string, - requestId: JsonRpcId, - params: JsonObject, - turn: OpenAiInteractionTurn, - options: { - readonly createId: () => string; - readonly interactionTimeoutMs: number; - readonly item: (runId: string, itemId: string) => Item | undefined; - readonly now: () => Date; - }, -): Interaction | null { - const createdAt = options.now().toISOString(); - const itemId = - readNonEmptyString(params, "itemId") ?? - (method === "item/tool/call" ? readNonEmptyString(params, "callId") : null); - const knownItem = - itemId !== null && options.item(turn.runId, itemId) !== undefined ? itemId : undefined; - const requestedTimeoutMs = readFiniteNumber(params, "autoResolutionMs"); - const timeoutMs = - requestedTimeoutMs !== null && - Number.isSafeInteger(requestedTimeoutMs) && - requestedTimeoutMs > 0 - ? Math.min(requestedTimeoutMs, options.interactionTimeoutMs) - : options.interactionTimeoutMs; - const common = { - audience: "participants", - blocking: true, - createdAt, - expiresAt: new Date(Date.parse(createdAt) + timeoutMs).toISOString(), - id: options.createId(), - ...(knownItem === undefined ? {} : { itemId: knownItem }), - provenance: provenance(method, { - ...(itemId === null ? {} : { itemId }), - requestId: String(requestId), - threadId: turn.threadId, - turnId: turn.turnId, - }), - runId: turn.runId, - status: "open", - }; - - if ( - method === "item/commandExecution/requestApproval" || - method === "item/fileChange/requestApproval" || - method === "item/permissions/requestApproval" - ) { - const command = readNonEmptyString(params, "command"); - const reason = readString(params, "reason"); - const availableDecisions = params["availableDecisions"]; - const allowed = - method === "item/commandExecution/requestApproval" && Array.isArray(availableDecisions) - ? new Set(availableDecisions.filter((value) => typeof value === "string")) - : null; - const options = [ - { - decision: "accept", - effect: "allow", - id: "accept_once", - label: "Allow once", - scope: "once", - }, - { - decision: "acceptForSession", - effect: "allow", - id: "accept_session", - label: "Allow for session", - scope: "session", - }, - { decision: "decline", effect: "deny", id: "decline", label: "Decline", scope: "once" }, - ].flatMap(({ decision, ...option }) => - allowed === null || allowed.has(decision) ? [option] : [], - ); - - if (options.length === 0) { - return null; - } - - return interactionSchema.parse({ - ...common, - kind: "permission", - request: { - ...(reason === null ? {} : { description: reason }), - options, - subject: - knownItem === undefined - ? { - operation: method, - targets: [itemId ?? command ?? method], - type: "resource", - } - : { itemId: knownItem, type: "item" }, - title: - method === "item/fileChange/requestApproval" - ? "Approve file changes" - : method === "item/permissions/requestApproval" - ? "Approve runtime permissions" - : "Approve command execution", - }, - }); - } - - if (method === "item/tool/requestUserInput") { - const questions = readArray(params, "questions").flatMap((entry) => { - if (!isRecord(entry)) { - return []; - } - - const id = readNonEmptyString(entry, "id"); - const prompt = readNonEmptyString(entry, "question"); - - if (id === null || prompt === null) { - return []; - } - - const nativeOptions = entry["options"]; - const mappedOptions = Array.isArray(nativeOptions) - ? nativeOptions.flatMap((option, index) => { - if (!isRecord(option)) { - return []; - } - - const label = readNonEmptyString(option, "label"); - const description = readString(option, "description"); - return label === null - ? [] - : [ - { - ...(description === null ? {} : { description }), - id: String(index), - label, - }, - ]; - }) - : []; - const options = mappedOptions.length === 0 ? undefined : mappedOptions; - - return [ - { - ...(options !== undefined && entry["isOther"] === true ? { allowOther: true } : {}), - id, - ...(options === undefined ? {} : { options }), - prompt, - required: true, - type: - options === undefined - ? entry["isSecret"] === true - ? "secret" - : "text" - : "single_select", - }, - ]; - }); - - return questions.length === 0 - ? null - : interactionSchema.parse({ - ...common, - kind: "input", - request: { questions }, - }); - } - - if (method === "item/tool/call") { - const tool = dynamicToolName(params); - const input = asJsonValue(params["arguments"]); - - return tool === null - ? null - : interactionSchema.parse({ - ...common, - kind: "tool", - request: { - ...(input === undefined ? {} : { input }), - name: tool, - }, - }); - } - - if (method === "mcpServer/elicitation/request") { - const request = asJsonValue(params); - - return request === undefined - ? null - : interactionSchema.parse({ - ...common, - kind: "extension", - name: OPENAI_APP_SERVER_MCP_ELICITATION_EXTENSION, - request, - }); - } - - return null; -} - -export function toOpenAiRequestResult( - pending: PendingServerRequest, - resolution: InteractionResolution, -): unknown { - if ( - pending.method === "item/commandExecution/requestApproval" || - pending.method === "item/fileChange/requestApproval" - ) { - if (resolution.kind !== "permission") { - throw new Error("OpenAI approval request requires a permission resolution."); - } - - const optionId = selectedOption(pending.interaction, resolution.value); - const decision = - optionId === null - ? "cancel" - : optionId === "accept_once" - ? "accept" - : optionId === "accept_session" - ? "acceptForSession" - : optionId === "decline" - ? "decline" - : null; - - if (decision === null) { - throw new Error("OpenAI approval resolution selected an unknown option."); - } - - return { decision }; - } - - if (pending.method === "item/permissions/requestApproval") { - if (resolution.kind !== "permission") { - throw new Error("OpenAI permission profile request requires a permission resolution."); - } - - const optionId = selectedOption(pending.interaction, resolution.value); - const accepted = optionId === "accept_once" || optionId === "accept_session"; - - if ( - optionId !== null && - optionId !== "accept_once" && - optionId !== "accept_session" && - optionId !== "decline" - ) { - throw new Error("OpenAI permission resolution selected an unknown option."); - } - - return { - permissions: - accepted && isRecord(pending.params["permissions"]) ? pending.params["permissions"] : {}, - scope: optionId === "accept_session" ? "session" : "turn", - }; - } - - if (pending.method === "item/tool/requestUserInput") { - if (resolution.kind !== "input") { - throw new Error("OpenAI user input request requires an input resolution."); - } - - return { - answers: - resolution.value.type === "cancelled" - ? {} - : Object.fromEntries( - Object.entries(resolution.value.answers).map(([id, answers]) => [ - id, - { - answers: answers.map((answer) => toInputAnswer(pending.params, id, answer)), - }, - ]), - ), - }; - } - - if (pending.method === "item/tool/call") { - if (resolution.kind !== "tool") { - throw new Error("OpenAI dynamic tool request requires a tool resolution."); - } - - if (resolution.value.type === "completed") { - const contentItems = resolution.value.output.flatMap(toNativeToolContent); - - if (resolution.value.structuredOutput !== undefined) { - contentItems.push({ - text: JSON.stringify(resolution.value.structuredOutput), - type: "inputText", - }); - } - - return { contentItems, success: true }; - } - - const message = - resolution.value.type === "failed" ? resolution.value.error.message : "Tool call cancelled."; - return { - contentItems: [{ text: message, type: "inputText" }], - success: false, - }; - } - - if (pending.method === "mcpServer/elicitation/request") { - if ( - resolution.kind !== "extension" || - resolution.name !== OPENAI_APP_SERVER_MCP_ELICITATION_EXTENSION - ) { - throw new Error("OpenAI MCP elicitation requires its namespaced extension resolution."); - } - - const value = requireRecord(resolution.value, "OpenAI MCP elicitation resolution"); - const action = readString(value, "action"); - - if (action !== "accept" && action !== "decline" && action !== "cancel") { - throw new Error("OpenAI MCP elicitation resolution has an unsupported action."); - } - - return { - _meta: asJsonValue(value["_meta"]) ?? null, - action, - content: asJsonValue(value["content"]) ?? null, - }; - } - - throw new Error(`Unsupported OpenAI app-server request: ${pending.method}.`); -} diff --git a/src/runtimes/openai/contract-items.ts b/src/runtimes/openai/contract-items.ts deleted file mode 100644 index d8a332f..0000000 --- a/src/runtimes/openai/contract-items.ts +++ /dev/null @@ -1,543 +0,0 @@ -import { itemSchema } from "../../contract"; -import type { ContentBlock, FileChange, Item, ProtocolError, TokenUsage } from "../../contract"; -import { asJsonValue, createProviderMeta } from "../contract-adapter-meta"; -import { isRecord, readArray, readNonEmptyString, readRecord, readString } from "./app-server-json"; -import type { JsonObject } from "./app-server-json"; -import { filterOpenAiPrivateCitations } from "./private-citation-filter"; - -const PROVIDER_EXTENSION_ITEM = "openai.app-server/thread-item"; -const providerMeta = createProviderMeta("openai"); - -export type NativeItemLifecycle = "completed" | "started"; - -export interface OpenAiItemTurn { - readonly runId: string; - readonly threadId: string; - readonly turnId: string; -} - -type DynamicToolContentItem = - | { imageUrl: string; type: "inputImage" } - | { text: string; type: "inputText" }; - -export function requireRecord(value: unknown, label: string): JsonObject { - if (!isRecord(value)) { - throw new Error(`${label} must be an object.`); - } - - return value; -} - -export function requireString(value: JsonObject, key: string, label: string): string { - const entry = readNonEmptyString(value, key); - - if (entry === null) { - throw new Error(`${label}.${key} must be a non-empty string.`); - } - - return entry; -} - -export function readFiniteNumber(value: JsonObject, key: string): number | null { - const entry = value[key]; - return typeof entry === "number" && Number.isFinite(entry) ? entry : null; -} - -const usageKeys = ["cachedInput", "input", "output", "reasoning", "total"] as const; -const nativeUsageKeys = { - cachedInput: "cachedInputTokens", - input: "inputTokens", - output: "outputTokens", - reasoning: "reasoningOutputTokens", - total: "totalTokens", -} as const satisfies Record<(typeof usageKeys)[number], string>; - -export function toUsage(value: JsonObject | null): TokenUsage | undefined { - if (value === null) { - return undefined; - } - - const usage: TokenUsage = {}; - - for (const key of usageKeys) { - const entry = readFiniteNumber(value, nativeUsageKeys[key]); - - if (entry !== null && entry >= 0 && Number.isSafeInteger(entry)) { - usage[key] = entry; - } - } - - return Object.keys(usage).length > 0 ? usage : undefined; -} - -export function subtractUsage(total: TokenUsage, baseline: TokenUsage): TokenUsage { - const usage: TokenUsage = {}; - - for (const key of usageKeys) { - const value = total[key]; - - if (value !== undefined) { - usage[key] = Math.max(0, value - (baseline[key] ?? 0)); - } - } - - return usage; -} - -export function monotonicUsage(previous: TokenUsage | undefined, next: TokenUsage): TokenUsage { - const usage: TokenUsage = {}; - - for (const key of usageKeys) { - const value = next[key] ?? previous?.[key]; - - if (value !== undefined) { - usage[key] = Math.max(value, previous?.[key] ?? 0); - } - } - - return usage; -} - -export function latestTimestamp(previous: string | undefined, next: string): string { - return previous !== undefined && Date.parse(previous) > Date.parse(next) ? previous : next; -} - -export function textContent(text: string): ContentBlock[] { - return text.length === 0 ? [] : [{ text, type: "text" }]; -} - -export function providerEventId(method: string, params: JsonObject): string { - const ids = [ - readString(params, "turnId"), - readString(readRecord(params, "turn"), "id"), - readString(params, "itemId"), - readString(readRecord(params, "item"), "id"), - ].filter((entry) => entry !== null); - return [method, ...ids].join(":").slice(0, 256); -} - -export function provenance( - event: string, - input: { itemId?: string; requestId?: string; threadId: string; turnId: string }, -) { - return providerMeta.provenance(event, input); -} - -export function itemStatus(item: JsonObject, lifecycle: NativeItemLifecycle): Item["status"] { - if (lifecycle === "started") { - return "active"; - } - - const status = readString(item, "status"); - - if (status === "failed") { - return "failed"; - } - - if (status === "declined") { - return "cancelled"; - } - - return "completed"; -} - -export function itemError(item: JsonObject, type: string): ProtocolError { - const error = readRecord(item, "error"); - - return { - code: `openai.${type}.failed`, - message: readString(error, "message") ?? `${type} failed.`, - retryable: false, - }; -} - -export function toFileChanges(item: JsonObject): FileChange[] { - return readArray(item, "changes").flatMap((entry) => { - if (!isRecord(entry)) { - return []; - } - - const path = readNonEmptyString(entry, "path"); - const kind = readRecord(entry, "kind"); - const type = readString(kind, "type"); - - if (path === null || (type !== "add" && type !== "delete" && type !== "update")) { - return []; - } - - const movePath = readNonEmptyString(kind, "move_path"); - const diff = readString(entry, "diff"); - - if (type === "update" && movePath !== null) { - return [ - { - ...(diff === null || diff.length === 0 ? {} : { diff: { text: diff, type: "text" } }), - oldPath: path, - operation: "move", - path: movePath, - }, - ]; - } - - return [ - { - ...(diff === null || diff.length === 0 ? {} : { diff: { text: diff, type: "text" } }), - operation: type === "add" ? "create" : type === "delete" ? "delete" : "update", - path, - }, - ]; - }); -} - -export function dynamicToolName(value: JsonObject): string | null { - const tool = readNonEmptyString(value, "tool"); - const namespace = readNonEmptyString(value, "namespace"); - return tool === null ? null : namespace === null ? tool : `${namespace}/${tool}`; -} - -export function toNativeToolContent(block: ContentBlock): DynamicToolContentItem[] { - if (block.type === "text") { - return [{ text: block.text, type: "inputText" }]; - } - - if (block.type === "json") { - return [{ text: JSON.stringify(block.value), type: "inputText" }]; - } - - if (block.type === "resource_link" && block.mediaType?.startsWith("image/")) { - return [{ imageUrl: block.uri, type: "inputImage" }]; - } - - if (block.type === "inline_blob" && block.mediaType.startsWith("image/")) { - return [ - { - imageUrl: `data:${block.mediaType};base64,${block.data}`, - type: "inputImage", - }, - ]; - } - - if (block.type === "extension") { - return [{ text: JSON.stringify(block.value), type: "inputText" }]; - } - - return []; -} - -export function fromNativeToolContent(value: JsonObject | null): ContentBlock[] { - return readArray(value, "contentItems").flatMap((entry) => { - if (!isRecord(entry)) { - const json = asJsonValue(entry); - return json === undefined ? [] : [{ type: "json", value: json }]; - } - - if (readString(entry, "type") === "inputText") { - const text = readString(entry, "text"); - return text === null ? [] : [{ text, type: "text" }]; - } - - if (readString(entry, "type") === "inputImage") { - const imageUrl = readNonEmptyString(entry, "imageUrl"); - const dataUrl = imageUrl?.match(/^data:([^;,]+);base64,(.+)$/su); - - if (dataUrl?.[1] !== undefined && dataUrl[2] !== undefined) { - return [{ data: dataUrl[2], mediaType: dataUrl[1], type: "inline_blob" }]; - } - - if (imageUrl !== null && URL.canParse(imageUrl)) { - return [{ type: "resource_link", uri: imageUrl }]; - } - } - - const json = asJsonValue(entry); - return json === undefined ? [] : [{ type: "json", value: json }]; - }); -} - -export function projectOpenAiItem( - turn: OpenAiItemTurn, - native: JsonObject, - lifecycle: NativeItemLifecycle, - occurredAt: string, - event: string, - lookupItem: (runId: string, itemId: string) => Item | undefined, -): Item | null { - const id = readNonEmptyString(native, "id"); - const type = readString(native, "type"); - - if (id === null || type === null || type === "userMessage") { - return null; - } - - const existing = lookupItem(turn.runId, id); - const status = itemStatus(native, lifecycle); - const updatedAt = latestTimestamp(existing?.updatedAt, occurredAt); - const base = { - audience: type === "hookPrompt" ? "operators" : "participants", - createdAt: existing?.createdAt ?? occurredAt, - ...(status === "active" ? {} : { endedAt: updatedAt }), - ...(status === "failed" ? { error: itemError(native, type) } : {}), - id, - provenance: provenance(event, { - itemId: id, - threadId: turn.threadId, - turnId: turn.turnId, - }), - runId: turn.runId, - status, - updatedAt, - }; - if (type === "agentMessage") { - const nativeText = readString(native, "text"); - - if (nativeText === null) { - return null; - } - - const phase = readString(native, "phase"); - const text = filterOpenAiPrivateCitations(nativeText).text; - return itemSchema.parse({ - ...base, - content: textContent(text), - kind: "message", - ...(phase === "commentary" - ? { phase: "commentary" } - : phase === "final_answer" - ? { phase: "final" } - : {}), - role: "agent", - }); - } - - if (type === "reasoning") { - const text = readArray(native, "summary") - .filter((entry) => typeof entry === "string") - .join("\n\n"); - - return itemSchema.parse({ - ...base, - content: textContent(text), - kind: "reasoning", - }); - } - - if (type === "commandExecution") { - const command = readString(native, "command"); - - if (command === null) { - return null; - } - - const exitCode = readFiniteNumber(native, "exitCode"); - const aggregatedOutput = readString(native, "aggregatedOutput") ?? ""; - const cwd = readNonEmptyString(native, "cwd"); - - return itemSchema.parse({ - ...base, - command, - ...(cwd === null ? {} : { cwd }), - ...(exitCode === null ? {} : { exitCode }), - kind: "terminal", - stderr: [], - stdout: textContent(aggregatedOutput), - }); - } - - if (type === "fileChange") { - const changes = toFileChanges(native); - - return changes.length === 0 && existing?.kind !== "change" - ? null - : itemSchema.parse({ ...base, changes, kind: "change" }); - } - - if (type === "mcpToolCall") { - const server = readNonEmptyString(native, "server"); - const tool = readNonEmptyString(native, "tool"); - - if (server === null || tool === null) { - return null; - } - - const result = readRecord(native, "result"); - const jsonInput = asJsonValue(native["arguments"]); - const output = readArray(result, "content").flatMap((entry) => { - if (isRecord(entry) && readString(entry, "type") === "text") { - const text = readString(entry, "text"); - - if (text !== null) { - return [{ text, type: "text" }]; - } - } - - const value = asJsonValue(entry); - return value === undefined ? [] : [{ type: "json", value }]; - }); - const structuredOutput = asJsonValue(result?.["structuredContent"]); - - return itemSchema.parse({ - ...base, - category: "other", - ...(jsonInput === undefined ? {} : { input: jsonInput }), - kind: "tool", - name: tool, - origin: "mcp", - ...(output.length === 0 ? {} : { output }), - server, - ...(structuredOutput === undefined || structuredOutput === null ? {} : { structuredOutput }), - }); - } - - if (type === "webSearch") { - const query = readString(native, "query"); - - if (query === null) { - return null; - } - - const action = asJsonValue(native["action"]); - const structuredOutput = asJsonValue(native["results"]); - - return itemSchema.parse({ - ...base, - category: "search", - input: { - ...(action === undefined || action === null ? {} : { action }), - query, - }, - kind: "tool", - name: "web_search", - origin: "provider", - ...(structuredOutput === undefined || structuredOutput === null ? {} : { structuredOutput }), - }); - } - - if (type === "plan") { - const text = readString(native, "text"); - - if (text === null) { - return null; - } - - const completed = lifecycle === "completed" || readString(native, "status") === "completed"; - - return itemSchema.parse({ - ...base, - entries: [{ id: "0", status: completed ? "completed" : "pending", text }], - kind: "plan", - }); - } - - if (type === "imageGeneration") { - const result = readNonEmptyString(native, "result"); - const revisedPrompt = readNonEmptyString(native, "revisedPrompt"); - const savedPath = readNonEmptyString(native, "savedPath"); - - return itemSchema.parse({ - ...base, - category: "other", - ...(revisedPrompt === null ? {} : { input: { revisedPrompt } }), - kind: "tool", - ...(savedPath === null ? {} : { locations: [{ path: savedPath }] }), - name: "image_generation", - origin: "provider", - ...(result === null - ? {} - : { output: [{ data: result, mediaType: "image/png", type: "inline_blob" }] }), - }); - } - - if (type === "dynamicToolCall") { - const name = dynamicToolName(native); - - if (name === null) { - return null; - } - - const input = asJsonValue(native["arguments"]); - const output = fromNativeToolContent(native); - - return itemSchema.parse({ - ...base, - category: "other", - ...(input === undefined ? {} : { input }), - kind: "tool", - name, - origin: "provider", - ...(output.length === 0 ? {} : { output }), - }); - } - - if (type === "collabAgentToolCall") { - const name = readNonEmptyString(native, "tool"); - - if (name === null) { - return null; - } - - const input = asJsonValue({ - model: native["model"] ?? null, - prompt: native["prompt"] ?? null, - reasoningEffort: native["reasoningEffort"] ?? null, - receiverThreadIds: readArray(native, "receiverThreadIds"), - senderThreadId: native["senderThreadId"] ?? null, - }); - const structuredOutput = asJsonValue(native["agentsStates"]); - - return itemSchema.parse({ - ...base, - category: "agent", - ...(input === undefined ? {} : { input }), - kind: "tool", - name, - origin: "provider", - ...(structuredOutput === undefined ? {} : { structuredOutput }), - }); - } - - if (type === "subAgentActivity") { - const input = asJsonValue({ - agentPath: native["agentPath"] ?? null, - agentThreadId: native["agentThreadId"] ?? null, - kind: native["kind"] ?? null, - }); - - return itemSchema.parse({ - ...base, - category: "agent", - ...(input === undefined ? {} : { input }), - kind: "tool", - name: "sub_agent_activity", - origin: "provider", - }); - } - - if (type === "imageView" || type === "sleep") { - const name = - readNonEmptyString(native, "tool") ?? - (type === "imageView" ? "image_view" : type === "sleep" ? "sleep" : type); - const input = native["arguments"] ?? native["path"] ?? native["durationMs"]; - const output = native["contentItems"]; - const jsonInput = asJsonValue(input); - const jsonOutput = asJsonValue(output); - - return itemSchema.parse({ - ...base, - category: type === "imageView" ? "read" : "other", - ...(jsonInput === undefined ? {} : { input: jsonInput }), - kind: "tool", - name, - origin: "provider", - ...(jsonOutput !== undefined - ? { output: [{ type: "json", value: jsonOutput }], structuredOutput: jsonOutput } - : {}), - }); - } - - return itemSchema.parse({ - ...base, - kind: "extension", - name: PROVIDER_EXTENSION_ITEM, - value: asJsonValue(native) ?? { nativeType: type }, - }); -} diff --git a/src/runtimes/openai/contract-turn-inbox.ts b/src/runtimes/openai/contract-turn-inbox.ts deleted file mode 100644 index e2753eb..0000000 --- a/src/runtimes/openai/contract-turn-inbox.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { isDeepStrictEqual } from "node:util"; - -import type { JsonObject } from "./app-server-json"; - -const MAX_PENDING_TURN_BYTES = 8 * 1_024 * 1_024; -const MAX_TRACKED_TURNS = 1_024; - -interface PendingTurnEnd { - readonly bytes: number; - readonly params: JsonObject; - task?: Promise | undefined; -} - -interface PendingTurnNotification { - readonly bytes: number; - readonly method: string; - readonly params: JsonObject; -} - -export interface OpenAiContractTurnInboxOptions { - readonly dispatch: (method: string, params: JsonObject) => Promise; - readonly replayEnd: (params: JsonObject) => Promise; -} - -export class OpenAiContractTurnInbox { - readonly #dispatch: OpenAiContractTurnInboxOptions["dispatch"]; - readonly #ended = new Map(); - readonly #pendingEnds = new Map(); - readonly #pendingNotifications = new Map(); - readonly #pendingReplays = new Map>(); - readonly #replayEnd: OpenAiContractTurnInboxOptions["replayEnd"]; - readonly #textEncoder = new TextEncoder(); - #disposed = false; - #pendingEndBytes = 0; - #pendingNotificationBytes = 0; - #pendingNotificationCount = 0; - - constructor(options: OpenAiContractTurnInboxOptions) { - this.#dispatch = options.dispatch; - this.#replayEnd = options.replayEnd; - } - - ended(turnId: string): TTurn | undefined { - return this.#ended.get(turnId); - } - - hasEnded(turnId: string): boolean { - return this.#ended.has(turnId); - } - - shouldBuffer(turnId: string): boolean { - return ( - this.#pendingNotifications.has(turnId) || - this.#pendingEnds.has(turnId) || - this.#pendingReplays.has(turnId) - ); - } - - rememberNotification(turnId: string, method: string, params: JsonObject): void { - if (this.#pendingEnds.has(turnId)) { - throw new Error( - `OpenAI app-server event ${method} arrived after terminal Turn ${turnId} before attachment.`, - ); - } - - const snapshot = structuredClone(params); - const bytes = this.#textEncoder.encode(JSON.stringify(snapshot)).byteLength; - - if ( - this.#pendingNotificationCount >= MAX_TRACKED_TURNS || - bytes > MAX_PENDING_TURN_BYTES - this.#pendingNotificationBytes - ) { - throw new RangeError("OpenAI app-server pending Turn event limit is exhausted."); - } - - const queue = this.#pendingNotifications.get(turnId) ?? []; - queue.push({ bytes, method, params: snapshot }); - this.#pendingNotifications.set(turnId, queue); - this.#pendingNotificationBytes += bytes; - this.#pendingNotificationCount += 1; - } - - rememberEnd(turnId: string, params: JsonObject): void { - const existing = this.#pendingEnds.get(turnId); - - if (existing !== undefined) { - if (!isDeepStrictEqual(existing.params, params)) { - throw new Error(`OpenAI terminal Turn ${turnId} changed before attachment.`); - } - return; - } - - const snapshot = structuredClone(params); - const bytes = this.#textEncoder.encode(JSON.stringify(snapshot)).byteLength; - - if ( - this.#pendingEnds.size >= MAX_TRACKED_TURNS || - bytes > MAX_PENDING_TURN_BYTES - this.#pendingEndBytes - ) { - throw new RangeError("OpenAI app-server pending terminal Turn limit is exhausted."); - } - - this.#pendingEnds.set(turnId, { bytes, params: snapshot }); - this.#pendingEndBytes += bytes; - } - - async replay(turnId: string): Promise { - const existing = this.#pendingReplays.get(turnId); - if (existing !== undefined) { - await existing; - return; - } - - const task = this.#drain(turnId).finally(() => { - if (this.#pendingReplays.get(turnId) === task) { - this.#pendingReplays.delete(turnId); - } - }); - this.#pendingReplays.set(turnId, task); - await task; - } - - rememberEnded(turnId: string, turn: TTurn): void { - this.#ended.delete(turnId); - this.#ended.set(turnId, turn); - - if (this.#ended.size > MAX_TRACKED_TURNS) { - const oldest = this.#ended.keys().next().value; - if (oldest !== undefined) { - this.#ended.delete(oldest); - } - } - } - - dispose(): void { - this.#disposed = true; - this.#ended.clear(); - this.#pendingEnds.clear(); - this.#pendingNotifications.clear(); - this.#pendingReplays.clear(); - this.#pendingEndBytes = 0; - this.#pendingNotificationBytes = 0; - this.#pendingNotificationCount = 0; - } - - async #drain(turnId: string): Promise { - for (;;) { - const queue = this.#pendingNotifications.get(turnId); - const pending = queue?.[0]; - - if (queue !== undefined && pending !== undefined) { - await this.#dispatch(pending.method, pending.params); - if (this.#disposed || this.#pendingNotifications.get(turnId) !== queue) { - return; - } - - queue.shift(); - this.#pendingNotificationBytes -= pending.bytes; - this.#pendingNotificationCount -= 1; - if (queue.length === 0) { - this.#pendingNotifications.delete(turnId); - } - continue; - } - - await this.#replayPendingEnd(turnId); - if (!this.#pendingNotifications.has(turnId) && !this.#pendingEnds.has(turnId)) { - return; - } - } - } - - async #replayPendingEnd(turnId: string): Promise { - const pending = this.#pendingEnds.get(turnId); - if (pending === undefined) { - return; - } - - pending.task ??= this.#replayEnd(pending.params) - .then(() => { - if (this.#pendingEnds.get(turnId) === pending) { - this.#pendingEnds.delete(turnId); - this.#pendingEndBytes -= pending.bytes; - } - }) - .finally(() => { - pending.task = undefined; - }); - await pending.task; - } -} diff --git a/src/runtimes/openai/contract-turn-lifecycle.ts b/src/runtimes/openai/contract-turn-lifecycle.ts deleted file mode 100644 index b57d528..0000000 --- a/src/runtimes/openai/contract-turn-lifecycle.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { itemSchema } from "../../contract"; -import type { Item, ProtocolError } from "../../contract"; -import type { ContractProjection } from "../contract-projection"; -import { isRecord, readArray, readNonEmptyString, readRecord, readString } from "./app-server-json"; -import type { JsonObject } from "./app-server-json"; -import { - latestTimestamp, - type NativeItemLifecycle, - provenance, - providerEventId, -} from "./contract-items"; - -export interface OpenAiTurnLifecycleState { - readonly runId: string; - readonly threadId: string; - readonly turnId: string; -} - -export function projectOpenAiPlan( - turn: OpenAiTurnLifecycleState, - params: JsonObject, - occurredAt: string, - event: string, - previous: Item | undefined, -): Item { - const explanation = readString(params, "explanation"); - - return itemSchema.parse({ - audience: "participants", - createdAt: previous?.createdAt ?? occurredAt, - entries: readArray(params, "plan").flatMap((entry, index) => { - if (!isRecord(entry)) { - return []; - } - - const text = readNonEmptyString(entry, "step"); - const status = readString(entry, "status"); - return text === null - ? [] - : [ - { - id: String(index), - status: - status === "completed" - ? "completed" - : status === "inProgress" - ? "in_progress" - : "pending", - text, - }, - ]; - }), - ...(explanation === null ? {} : { explanation }), - id: "turn-plan", - kind: "plan", - provenance: provenance(event, { - itemId: "turn-plan", - threadId: turn.threadId, - turnId: turn.turnId, - }), - runId: turn.runId, - status: "active", - updatedAt: latestTimestamp(previous?.updatedAt, occurredAt), - }); -} - -export interface FinishOpenAiTurnOptions { - readonly method: string; - readonly params: JsonObject; - readonly projectItem: ( - turn: TTurn, - item: JsonObject, - lifecycle: NativeItemLifecycle, - occurredAt: string, - method: string, - ) => Item | null; - readonly projection: ContractProjection; - readonly release: (turnId: string) => void; - readonly rememberEnded: (turn: TTurn) => void; - readonly turn: TTurn; - readonly withReceiptTime: ( - eventId: string, - operation: (occurredAt: string) => Promise, - ) => Promise; -} - -export async function finishOpenAiTurn( - options: FinishOpenAiTurnOptions, -): Promise { - const { method, params, projection, turn } = options; - const nativeTurn = readRecord(params, "turn"); - const status = readString(nativeTurn, "status"); - - if (status === "inProgress") { - return; - } - - if (status !== "completed" && status !== "failed" && status !== "interrupted") { - throw new Error(`${method} params.turn.status is unsupported.`); - } - - await options.withReceiptTime(providerEventId(method, params), async (endedAt) => { - const terminalItems: Item[] = []; - const completedItemIds = new Set(); - - for (const nativeItem of readArray(nativeTurn, "items")) { - if (!isRecord(nativeItem)) { - continue; - } - - const itemId = readNonEmptyString(nativeItem, "id"); - - if (itemId === null) { - continue; - } - - const existing = projection.item(turn.runId, itemId); - - if (existing !== undefined && existing.status !== "active") { - continue; - } - - const projected = options.projectItem(turn, nativeItem, "completed", endedAt, method); - - if (projected !== null) { - completedItemIds.add(itemId); - terminalItems.push(projected); - } - } - - const activeItems = projection.items(turn.runId).filter((item) => item.status === "active"); - const incompleteSnapshot = - status === "completed" && - activeItems.some( - (item) => - !completedItemIds.has(item.id) && !(item.kind === "plan" && item.id === "turn-plan"), - ); - const runStatus = - status === "failed" || incompleteSnapshot - ? "failed" - : status === "interrupted" - ? "cancelled" - : "completed"; - const runError: ProtocolError = { - code: incompleteSnapshot ? "openai.turn.incomplete" : "openai.turn.failed", - message: incompleteSnapshot - ? "OpenAI turn completed without authoritative snapshots for active items." - : (readString(readRecord(nativeTurn, "error"), "message") ?? "OpenAI turn failed."), - retryable: false, - }; - - if (status === "completed") { - const plan = activeItems.find( - (item) => item.kind === "plan" && item.id === "turn-plan" && !completedItemIds.has(item.id), - ); - - if (plan !== undefined) { - terminalItems.push( - itemSchema.parse({ - ...plan, - endedAt, - status: "completed", - updatedAt: latestTimestamp(plan.updatedAt, endedAt), - }), - ); - } - } - - await projection.finishRun({ - cause: { providerEventId: providerEventId(method, params), type: "provider" }, - event: method, - ...(runStatus === "failed" ? { error: runError } : {}), - ...(runStatus === "completed" ? { finishReason: "success" } : {}), - runId: turn.runId, - status: runStatus, - terminalItems, - }); - options.rememberEnded(turn); - options.release(turn.turnId); - }); -} diff --git a/src/runtimes/openai/event-translator.ts b/src/runtimes/openai/event-translator.ts index 233f14c..6b30214 100644 --- a/src/runtimes/openai/event-translator.ts +++ b/src/runtimes/openai/event-translator.ts @@ -1,4 +1,5 @@ import type { DriverEventInput } from "../../protocol/events"; +import { toRuntimePublicId } from "../runtime-public-id"; import { isRecord, readArray, @@ -9,45 +10,113 @@ import { } from "./app-server-json"; import type { JsonObject } from "./app-server-json"; -export function toOpenAiToolName(item: JsonObject): string | null { - const itemType = readString(item, "type"); - - if (itemType === "commandExecution") { - return "Shell"; +function withoutPrivateMeta(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(withoutPrivateMeta); } - if (itemType === "fileChange") { - return "File change"; + if (!isRecord(value)) { + return value; } - if (itemType === "mcpToolCall") { - const server = readNonEmptyString(item, "server"); - const tool = readNonEmptyString(item, "tool"); + return Object.fromEntries( + Object.entries(value).flatMap(([key, entry]) => + key === "_meta" ? [] : [[key, withoutPrivateMeta(entry)]], + ), + ); +} - if (server !== null && tool !== null) { - return `${server}.${tool}`; - } +export function toOpenAiMessagePhase(item: JsonObject): "commentary" | "final" | null { + if (readString(item, "delivery") === "async") return "commentary"; - return tool ?? "MCP tool"; + switch (readString(item, "phase")) { + case "commentary": + return "commentary"; + case "final_answer": + return "final"; + default: + return null; } +} - if (itemType === "dynamicToolCall") { - return readString(item, "tool") ?? "Tool"; - } +export function toOpenAiToolName(item: JsonObject): string | null { + const itemType = readString(item, "type"); - if (itemType === "webSearch") { - return "Web search"; - } + switch (itemType) { + case "commandExecution": + return "Shell"; + case "fileChange": + return "File change"; + case "mcpToolCall": { + const server = readNonEmptyString(item, "server"); + const tool = readNonEmptyString(item, "tool"); - if (itemType === "collabAgentToolCall") { - return readNonEmptyString(item, "tool") ?? "Agent collaboration"; + return server !== null && tool !== null ? `${server}.${tool}` : (tool ?? "MCP tool"); + } + case "dynamicToolCall": + return readString(item, "tool") ?? "Tool"; + case "collabAgentToolCall": + switch (readString(item, "tool")) { + case "spawnAgent": + return "Spawn agent"; + case "sendInput": + return "Send input to agent"; + case "resumeAgent": + return "Resume agent"; + case "wait": + return "Wait for agents"; + case "closeAgent": + return "Close agent"; + case "sendMessage": + return "Send message to agent"; + case "followupTask": + return "Follow up with agent"; + case "interruptAgent": + return "Interrupt agent"; + case "listAgents": + return "List agents"; + default: + return null; + } + case "webSearch": + return "Web search"; + case "imageView": + return "View image"; + case "sleep": + return "Sleep"; + case "imageGeneration": + return "Image generation"; + default: + return null; } +} - if (itemType === "subAgentActivity") { - return "Sub-agent activity"; +export function toOpenAiCollaborationOutput(item: JsonObject): JsonObject | null { + if (readString(item, "type") !== "collabAgentToolCall") { + return null; } - return null; + const agentsStates = readRecord(item, "agentsStates") ?? {}; + const senderThreadId = readString(item, "senderThreadId"); + + return { + agentsStates: Object.fromEntries( + Object.entries(agentsStates).map(([threadId, state]) => [ + toRuntimePublicId(threadId, "openai-thread"), + state, + ]), + ), + model: readString(item, "model"), + prompt: readString(item, "prompt"), + reasoningEffort: readString(item, "reasoningEffort"), + receiverThreadIds: readArray(item, "receiverThreadIds") + .filter((entry): entry is string => typeof entry === "string") + .map((threadId) => toRuntimePublicId(threadId, "openai-thread")), + senderThreadId: + senderThreadId === null ? null : toRuntimePublicId(senderThreadId, "openai-thread"), + status: readString(item, "status"), + tool: readString(item, "tool"), + }; } export function toOpenAiToolResultText(item: JsonObject): string | null { @@ -58,25 +127,9 @@ export function toOpenAiToolResultText(item: JsonObject): string | null { } if (itemType === "fileChange") { - const changes = readArray(item, "changes"); - if (changes.length === 0) { - return null; - } - - return changes - .map((change) => { - if (!isRecord(change)) { - return null; - } - - const path = readNonEmptyString(change, "path"); - const diff = readNonEmptyString(change, "diff"); - return [path, diff] - .filter((entry): entry is string => entry !== null && entry.length > 0) - .join("\n"); - }) - .filter((entry): entry is string => entry !== null && entry.length > 0) - .join("\n\n"); + // file.change.updated is the durable authority for file paths. Repeating + // provider diffs here can exceed CMA admission without adding information. + return null; } if (itemType === "mcpToolCall") { @@ -85,32 +138,86 @@ export function toOpenAiToolResultText(item: JsonObject): string | null { return readString(error, "message") ?? "MCP tool failed."; } - return stringifyForDisplay(item["result"]); + return stringifyForDisplay(withoutPrivateMeta(readRecord(item, "result")?.["content"])); } if (itemType === "dynamicToolCall") { return stringifyForDisplay(item["contentItems"] ?? item["success"]); } + if (itemType === "collabAgentToolCall") { + return stringifyForDisplay(toOpenAiCollaborationOutput(item)); + } + if (itemType === "webSearch") { return readString(item, "query"); } - if (itemType === "collabAgentToolCall") { - return stringifyForDisplay(item["agentsStates"]); + if (itemType === "sleep") { + const durationMs = item["durationMs"]; + return typeof durationMs === "number" && Number.isSafeInteger(durationMs) + ? `Slept for ${String(durationMs)} ms.` + : null; } - if (itemType === "subAgentActivity") { - return stringifyForDisplay({ - agentPath: item["agentPath"] ?? null, - agentThreadId: item["agentThreadId"] ?? null, - kind: item["kind"] ?? null, - }); + if (itemType === "imageView") { + return readString(item, "path"); + } + + if (itemType === "imageGeneration") { + if (readString(item, "status") === "failed") { + return "Image generation failed."; + } + + return "Image generated."; } return null; } +export function toOpenAiToolRawInput(item: JsonObject): string | null { + switch (readString(item, "type")) { + case "commandExecution": + return readString(item, "command"); + case "mcpToolCall": + case "dynamicToolCall": + return item["arguments"] === undefined ? null : (JSON.stringify(item["arguments"]) ?? null); + default: + return null; + } +} + +export function toOpenAiToolStructuredOutput(item: JsonObject): unknown | null { + switch (readString(item, "type")) { + case "commandExecution": + return { + commandActions: item["commandActions"] ?? [], + cwd: item["cwd"] ?? null, + durationMs: item["durationMs"] ?? null, + exitCode: item["exitCode"] ?? null, + pluginId: item["pluginId"] ?? null, + processId: item["processId"] ?? null, + scriptPath: item["scriptPath"] ?? null, + source: item["source"] ?? null, + }; + case "mcpToolCall": + return withoutPrivateMeta(readRecord(item, "result")?.["structuredContent"] ?? null); + case "dynamicToolCall": + return { + contentItems: item["contentItems"] ?? null, + durationMs: item["durationMs"] ?? null, + namespace: item["namespace"] ?? null, + success: item["success"] ?? null, + }; + case "imageGeneration": { + const failure = readRecord(item, "failure"); + return failure === null ? null : { failure }; + } + default: + return null; + } +} + export function toOpenAiFileChangeEvents(item: JsonObject): DriverEventInput[] { if (readString(item, "type") !== "fileChange") { return []; @@ -129,18 +236,22 @@ export function toOpenAiFileChangeEvents(item: JsonObject): DriverEventInput[] { return []; } + const movePath = changeType === "update" ? readNonEmptyString(kind, "move_path") : null; + const changes = + movePath === null || movePath === path + ? [{ change: changeType === "delete" ? ("delete" as const) : ("upsert" as const), path }] + : [ + { change: "delete" as const, path }, + { change: "upsert" as const, path: movePath }, + ]; + return [ { actor: "tool", kind: "file.change.updated", origin: "file", payload: { - changes: [ - { - change: changeType === "delete" ? "delete" : "upsert", - path, - }, - ], + changes, status: "completed", }, }, diff --git a/src/runtimes/openai/generated-json-schema/InitializeResponse.json b/src/runtimes/openai/generated-json-schema/InitializeResponse.json new file mode 100644 index 0000000..795eae0 --- /dev/null +++ b/src/runtimes/openai/generated-json-schema/InitializeResponse.json @@ -0,0 +1,33 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + } + }, + "properties": { + "codexHome": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Absolute path to the server's $CODEX_HOME directory." + }, + "platformFamily": { + "description": "Platform family for the running app-server target, for example `\"unix\"` or `\"windows\"`.", + "type": "string" + }, + "platformOs": { + "description": "Operating system for the running app-server target, for example `\"macos\"`, `\"linux\"`, or `\"windows\"`.", + "type": "string" + }, + "userAgent": { + "type": "string" + } + }, + "required": ["codexHome", "platformFamily", "platformOs", "userAgent"], + "title": "InitializeResponse", + "type": "object" +} diff --git a/src/runtimes/openai/generated-json-schema/README.md b/src/runtimes/openai/generated-json-schema/README.md new file mode 100644 index 0000000..f7a3d01 --- /dev/null +++ b/src/runtimes/openai/generated-json-schema/README.md @@ -0,0 +1,7 @@ +# OpenAI app-server JSON schemas + +These eight runtime schemas are selected from `@openai/codex@0.152.0` output and must not be edited by hand. + +```sh +bun scripts/sync-openai-generated.mjs +``` diff --git a/src/runtimes/openai/generated-json-schema/ServerNotification.json b/src/runtimes/openai/generated-json-schema/ServerNotification.json new file mode 100644 index 0000000..b4ad59e --- /dev/null +++ b/src/runtimes/openai/generated-json-schema/ServerNotification.json @@ -0,0 +1,6437 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "AccountLoginCompletedNotification": { + "properties": { + "error": { + "type": ["string", "null"] + }, + "loginId": { + "type": ["string", "null"] + }, + "onboardingEntrypoint": { + "anyOf": [ + { + "$ref": "#/definitions/DesktopOnboardingEntrypoint" + }, + { + "type": "null" + } + ] + }, + "success": { + "type": "boolean" + } + }, + "required": ["success"], + "type": "object" + }, + "AccountRateLimitsUpdatedNotification": { + "description": "Sparse rolling rate-limit update.\n\nClients should merge available values into the most recent `account/rateLimits/read` response or refetch that snapshot. Nullable account metadata may be unavailable in a rolling update and does not clear a previously observed value.", + "properties": { + "rateLimits": { + "$ref": "#/definitions/RateLimitSnapshot" + } + }, + "required": ["rateLimits"], + "type": "object" + }, + "AccountUpdatedNotification": { + "properties": { + "authMode": { + "anyOf": [ + { + "$ref": "#/definitions/AuthMode" + }, + { + "type": "null" + } + ] + }, + "planType": { + "anyOf": [ + { + "$ref": "#/definitions/PlanType" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier from the selected permissions profile's `extends` setting, when present.", + "type": ["string", "null"] + }, + "id": { + "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + } + }, + "required": ["id"], + "type": "object" + }, + "AdditionalFileSystemPermissions": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": ["array", "null"] + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": ["integer", "null"] + }, + "read": { + "description": "This will be removed in favor of `entries`.", + "items": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": ["array", "null"] + }, + "write": { + "description": "This will be removed in favor of `entries`.", + "items": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": ["array", "null"] + } + }, + "type": "object" + }, + "AdditionalNetworkPermissions": { + "properties": { + "enabled": { + "type": ["boolean", "null"] + } + }, + "type": "object" + }, + "AgentMessageDelivery": { + "enum": ["async"], + "type": "string" + }, + "AgentMessageDeltaNotification": { + "properties": { + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": ["delta", "itemId", "threadId", "turnId"], + "type": "object" + }, + "AgentPath": { + "type": "string" + }, + "AppBranding": { + "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", + "properties": { + "category": { + "type": ["string", "null"] + }, + "developer": { + "type": ["string", "null"] + }, + "isDiscoverableApp": { + "type": "boolean" + }, + "privacyPolicy": { + "type": ["string", "null"] + }, + "termsOfService": { + "type": ["string", "null"] + }, + "website": { + "type": ["string", "null"] + } + }, + "required": ["isDiscoverableApp"], + "type": "object" + }, + "AppInfo": { + "description": "EXPERIMENTAL - app metadata returned by app-list APIs.", + "properties": { + "appMetadata": { + "anyOf": [ + { + "$ref": "#/definitions/AppMetadata" + }, + { + "type": "null" + } + ] + }, + "branding": { + "anyOf": [ + { + "$ref": "#/definitions/AppBranding" + }, + { + "type": "null" + } + ] + }, + "description": { + "type": ["string", "null"] + }, + "distributionChannel": { + "type": ["string", "null"] + }, + "iconAssets": { + "additionalProperties": { + "type": "string" + }, + "type": ["object", "null"] + }, + "iconDarkAssets": { + "additionalProperties": { + "type": "string" + }, + "type": ["object", "null"] + }, + "id": { + "type": "string" + }, + "installUrl": { + "type": ["string", "null"] + }, + "isAccessible": { + "default": false, + "type": "boolean" + }, + "isEnabled": { + "default": true, + "description": "Whether this app is enabled in config.toml. Example: ```toml [apps.bad_app] enabled = false ```", + "type": "boolean" + }, + "labels": { + "additionalProperties": { + "type": "string" + }, + "type": ["object", "null"] + }, + "logoUrl": { + "type": ["string", "null"] + }, + "logoUrlDark": { + "type": ["string", "null"] + }, + "name": { + "type": "string" + }, + "pluginDisplayNames": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": ["id", "name"], + "type": "object" + }, + "AppListUpdatedNotification": { + "description": "EXPERIMENTAL - notification emitted when the app list changes.", + "properties": { + "data": { + "items": { + "$ref": "#/definitions/AppInfo" + }, + "type": "array" + } + }, + "required": ["data"], + "type": "object" + }, + "AppMetadata": { + "properties": { + "categories": { + "items": { + "type": "string" + }, + "type": ["array", "null"] + }, + "developer": { + "type": ["string", "null"] + }, + "firstPartyRequiresInstall": { + "type": ["boolean", "null"] + }, + "review": { + "anyOf": [ + { + "$ref": "#/definitions/AppReview" + }, + { + "type": "null" + } + ] + }, + "screenshots": { + "items": { + "$ref": "#/definitions/AppScreenshot" + }, + "type": ["array", "null"] + }, + "seoDescription": { + "type": ["string", "null"] + }, + "showInComposerWhenUnlinked": { + "type": ["boolean", "null"] + }, + "subCategories": { + "items": { + "type": "string" + }, + "type": ["array", "null"] + }, + "version": { + "type": ["string", "null"] + }, + "versionId": { + "type": ["string", "null"] + }, + "versionNotes": { + "type": ["string", "null"] + } + }, + "type": "object" + }, + "AppReview": { + "properties": { + "status": { + "type": "string" + } + }, + "required": ["status"], + "type": "object" + }, + "AppScreenshot": { + "properties": { + "fileId": { + "type": ["string", "null"] + }, + "url": { + "type": ["string", "null"] + }, + "userPrompt": { + "type": "string" + } + }, + "required": ["userPrompt"], + "type": "object" + }, + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + "enum": ["user", "auto_review", "guardian_subagent"], + "type": "string" + }, + "AskForApproval": { + "oneOf": [ + { + "enum": ["untrusted", "on-request", "never"], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "granular": { + "properties": { + "mcp_elicitations": { + "type": "boolean" + }, + "request_permissions": { + "default": false, + "type": "boolean" + }, + "rules": { + "type": "boolean" + }, + "sandbox_approval": { + "type": "boolean" + }, + "skill_approval": { + "default": false, + "type": "boolean" + } + }, + "required": ["mcp_elicitations", "rules", "sandbox_approval"], + "type": "object" + } + }, + "required": ["granular"], + "title": "GranularAskForApproval", + "type": "object" + } + ] + }, + "AuthMode": { + "description": "Authentication mode for OpenAI-backed providers.", + "oneOf": [ + { + "description": "OpenAI API key provided by the caller and stored by Codex.", + "enum": ["apikey"], + "type": "string" + }, + { + "description": "ChatGPT OAuth managed by Codex (tokens persisted and refreshed by Codex).", + "enum": ["chatgpt"], + "type": "string" + }, + { + "description": "[UNSTABLE] FOR OPENAI INTERNAL USE ONLY - DO NOT USE.\n\nChatGPT auth tokens are supplied by an external host app and are only stored in memory. Token refresh must be handled by the external host app.", + "enum": ["chatgptAuthTokens"], + "type": "string" + }, + { + "description": "Backend auth supplied as request headers.", + "enum": ["headers"], + "type": "string" + }, + { + "description": "Programmatic Codex auth backed by a registered Agent Identity.", + "enum": ["agentIdentity"], + "type": "string" + }, + { + "description": "Programmatic Codex auth backed by a personal access token.", + "enum": ["personalAccessToken"], + "type": "string" + }, + { + "description": "Amazon Bedrock bearer token managed by Codex.", + "enum": ["bedrockApiKey"], + "type": "string" + }, + { + "description": "Amazon Bedrock AWS access keys managed by Codex.", + "enum": ["bedrockAccessKeys"], + "type": "string" + } + ] + }, + "AuthRecoveryNotification": { + "properties": { + "message": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": ["message", "provider", "threadId", "turnId"], + "type": "object" + }, + "AutoReviewDecisionSource": { + "description": "[UNSTABLE] Source that produced a terminal approval auto-review decision.", + "enum": ["agent"], + "type": "string" + }, + "ByteRange": { + "properties": { + "end": { + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "start": { + "format": "uint", + "minimum": 0.0, + "type": "integer" + } + }, + "required": ["end", "start"], + "type": "object" + }, + "CodexErrorInfo": { + "description": "This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.", + "oneOf": [ + { + "enum": [ + "contextWindowExceeded", + "sessionBudgetExceeded", + "usageLimitExceeded", + "rateLimitExceeded", + "serverOverloaded", + "cyberPolicy", + "misalignmentPolicyViolation", + "internalServerError", + "unauthorized", + "badRequest", + "threadRollbackFailed", + "sandboxError", + "other" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": ["integer", "null"] + } + }, + "type": "object" + } + }, + "required": ["httpConnectionFailed"], + "title": "HttpConnectionFailedCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Failed to connect to the response SSE stream.", + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": ["integer", "null"] + } + }, + "type": "object" + } + }, + "required": ["responseStreamConnectionFailed"], + "title": "ResponseStreamConnectionFailedCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "The response SSE stream disconnected in the middle of a turn before completion.", + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": ["integer", "null"] + } + }, + "type": "object" + } + }, + "required": ["responseStreamDisconnected"], + "title": "ResponseStreamDisconnectedCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Reached the retry limit for responses.", + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": ["integer", "null"] + } + }, + "type": "object" + } + }, + "required": ["responseTooManyFailedAttempts"], + "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": ["turnKind"], + "type": "object" + } + }, + "required": ["activeTurnNotSteerable"], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" + } + ] + }, + "CollabAgentState": { + "properties": { + "message": { + "type": ["string", "null"] + }, + "status": { + "$ref": "#/definitions/CollabAgentStatus" + } + }, + "required": ["status"], + "type": "object" + }, + "CollabAgentStatus": { + "enum": [ + "pendingInit", + "running", + "interrupted", + "completed", + "errored", + "shutdown", + "notFound" + ], + "type": "string" + }, + "CollabAgentTool": { + "enum": [ + "spawnAgent", + "sendInput", + "resumeAgent", + "wait", + "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents" + ], + "type": "string" + }, + "CollabAgentToolCallStatus": { + "enum": ["inProgress", "completed", "failed", "interrupted"], + "type": "string" + }, + "CollaborationMode": { + "description": "Collaboration mode for a Codex session.", + "properties": { + "mode": { + "$ref": "#/definitions/ModeKind" + }, + "settings": { + "$ref": "#/definitions/Settings" + } + }, + "required": ["mode", "settings"], + "type": "object" + }, + "CommandAction": { + "oneOf": [ + { + "properties": { + "command": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": { + "enum": ["read"], + "title": "ReadCommandActionType", + "type": "string" + } + }, + "required": ["command", "name", "path", "type"], + "title": "ReadCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": ["string", "null"] + }, + "type": { + "enum": ["listFiles"], + "title": "ListFilesCommandActionType", + "type": "string" + } + }, + "required": ["command", "type"], + "title": "ListFilesCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": ["string", "null"] + }, + "query": { + "type": ["string", "null"] + }, + "type": { + "enum": ["search"], + "title": "SearchCommandActionType", + "type": "string" + } + }, + "required": ["command", "type"], + "title": "SearchCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "type": { + "enum": ["unknown"], + "title": "UnknownCommandActionType", + "type": "string" + } + }, + "required": ["command", "type"], + "title": "UnknownCommandAction", + "type": "object" + } + ] + }, + "CommandExecOutputDeltaNotification": { + "description": "Base64-encoded output chunk emitted for a streaming `command/exec` request.\n\nThese notifications are connection-scoped. If the originating connection closes, the server terminates the process.", + "properties": { + "capReached": { + "description": "`true` on the final streamed chunk for a stream when `outputBytesCap` truncated later output on that stream.", + "type": "boolean" + }, + "deltaBase64": { + "description": "Base64-encoded output bytes.", + "type": "string" + }, + "processId": { + "description": "Client-supplied, connection-scoped `processId` from the original `command/exec` request.", + "type": "string" + }, + "stream": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecOutputStream" + } + ], + "description": "Output stream for this chunk." + } + }, + "required": ["capReached", "deltaBase64", "processId", "stream"], + "type": "object" + }, + "CommandExecOutputStream": { + "description": "Stream label for `command/exec/outputDelta` notifications.", + "oneOf": [ + { + "description": "stdout stream. PTY mode multiplexes terminal output here.", + "enum": ["stdout"], + "type": "string" + }, + { + "description": "stderr stream.", + "enum": ["stderr"], + "type": "string" + } + ] + }, + "CommandExecutionOutputDeltaNotification": { + "properties": { + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": ["delta", "itemId", "threadId", "turnId"], + "type": "object" + }, + "CommandExecutionSource": { + "enum": ["agent", "userShell", "unifiedExecStartup", "unifiedExecInteraction"], + "type": "string" + }, + "CommandExecutionStatus": { + "enum": ["inProgress", "completed", "failed", "declined"], + "type": "string" + }, + "ConfigWarningNotification": { + "properties": { + "details": { + "description": "Optional extra guidance or error details.", + "type": ["string", "null"] + }, + "path": { + "description": "Optional path to the config file that triggered the warning.", + "type": ["string", "null"] + }, + "range": { + "anyOf": [ + { + "$ref": "#/definitions/TextRange" + }, + { + "type": "null" + } + ], + "description": "Optional range for the error location inside the config file." + }, + "summary": { + "description": "Concise summary of the warning.", + "type": "string" + } + }, + "required": ["summary"], + "type": "object" + }, + "ContextCompactedNotification": { + "description": "Deprecated: Use `ContextCompaction` item type instead.", + "properties": { + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": ["threadId", "turnId"], + "type": "object" + }, + "CreditsSnapshot": { + "properties": { + "balance": { + "type": ["string", "null"] + }, + "hasCredits": { + "type": "boolean" + }, + "unlimited": { + "type": "boolean" + } + }, + "required": ["hasCredits", "unlimited"], + "type": "object" + }, + "DeprecationNoticeNotification": { + "properties": { + "details": { + "description": "Optional extra guidance, such as migration steps or rationale.", + "type": ["string", "null"] + }, + "summary": { + "description": "Concise summary of what is deprecated.", + "type": "string" + } + }, + "required": ["summary"], + "type": "object" + }, + "DesktopOnboardingEntrypoint": { + "enum": ["life_sciences"], + "type": "string" + }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": ["inputText"], + "title": "InputTextDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": ["text", "type"], + "title": "InputTextDynamicToolCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "enum": ["inputImage"], + "title": "InputImageDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": ["imageUrl", "type"], + "title": "InputImageDynamicToolCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "audioUrl": { + "type": "string" + }, + "type": { + "enum": ["inputAudio"], + "title": "InputAudioDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": ["audioUrl", "type"], + "title": "InputAudioDynamicToolCallOutputContentItem", + "type": "object" + } + ] + }, + "DynamicToolCallStatus": { + "enum": ["inProgress", "completed", "failed"], + "type": "string" + }, + "EnvironmentConnectionNotification": { + "properties": { + "environmentId": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": ["environmentId", "threadId"], + "type": "object" + }, + "ErrorNotification": { + "properties": { + "error": { + "$ref": "#/definitions/TurnError" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "willRetry": { + "type": "boolean" + } + }, + "required": ["error", "threadId", "turnId", "willRetry"], + "type": "object" + }, + "ExternalAgentConfigImportCompletedNotification": { + "properties": { + "importId": { + "type": "string" + }, + "itemTypeResults": { + "items": { + "$ref": "#/definitions/ExternalAgentConfigImportTypeResult" + }, + "type": "array" + } + }, + "required": ["importId", "itemTypeResults"], + "type": "object" + }, + "ExternalAgentConfigImportItemTypeFailure": { + "properties": { + "cwd": { + "type": ["string", "null"] + }, + "errorType": { + "type": ["string", "null"] + }, + "failureStage": { + "type": "string" + }, + "itemType": { + "$ref": "#/definitions/ExternalAgentConfigMigrationItemType" + }, + "message": { + "type": "string" + }, + "source": { + "type": ["string", "null"] + }, + "subErrorType": { + "type": ["string", "null"] + } + }, + "required": ["failureStage", "itemType", "message"], + "type": "object" + }, + "ExternalAgentConfigImportItemTypeSuccess": { + "properties": { + "cwd": { + "type": ["string", "null"] + }, + "itemType": { + "$ref": "#/definitions/ExternalAgentConfigMigrationItemType" + }, + "source": { + "type": ["string", "null"] + }, + "target": { + "type": ["string", "null"] + }, + "title": { + "default": null, + "description": "Original title for an imported session; null for other item types.", + "type": ["string", "null"] + } + }, + "required": ["itemType"], + "type": "object" + }, + "ExternalAgentConfigImportProgressNotification": { + "properties": { + "importId": { + "type": "string" + }, + "itemTypeResults": { + "items": { + "$ref": "#/definitions/ExternalAgentConfigImportTypeResult" + }, + "type": "array" + } + }, + "required": ["importId", "itemTypeResults"], + "type": "object" + }, + "ExternalAgentConfigImportTypeResult": { + "properties": { + "failures": { + "items": { + "$ref": "#/definitions/ExternalAgentConfigImportItemTypeFailure" + }, + "type": "array" + }, + "itemType": { + "$ref": "#/definitions/ExternalAgentConfigMigrationItemType" + }, + "successes": { + "items": { + "$ref": "#/definitions/ExternalAgentConfigImportItemTypeSuccess" + }, + "type": "array" + } + }, + "required": ["failures", "itemType", "successes"], + "type": "object" + }, + "ExternalAgentConfigMigrationItemType": { + "enum": [ + "AGENTS_MD", + "CONFIG", + "SKILLS", + "PLUGINS", + "MCP_SERVER_CONFIG", + "SUBAGENTS", + "HOOKS", + "COMMANDS", + "MEMORY", + "SESSIONS" + ], + "type": "string" + }, + "FileChangeOutputDeltaNotification": { + "description": "Deprecated legacy notification for `apply_patch` textual output.\n\nThe server no longer emits this notification.", + "properties": { + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": ["delta", "itemId", "threadId", "turnId"], + "type": "object" + }, + "FileChangePatchUpdatedNotification": { + "properties": { + "changes": { + "items": { + "$ref": "#/definitions/FileUpdateChange" + }, + "type": "array" + }, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": ["changes", "itemId", "threadId", "turnId"], + "type": "object" + }, + "FileSystemAccessMode": { + "enum": ["read", "write", "deny"], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": { + "enum": ["path"], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": ["path", "type"], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": ["glob_pattern"], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": ["pattern", "type"], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": ["special"], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": ["type", "value"], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": ["access", "path"], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": ["root"], + "type": "string" + } + }, + "required": ["kind"], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": ["minimal"], + "type": "string" + } + }, + "required": ["kind"], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": ["project_roots"], + "type": "string" + }, + "subpath": { + "anyOf": [ + { + "$ref": "#/definitions/LegacyAppPathString" + }, + { + "type": "null" + } + ] + } + }, + "required": ["kind"], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": ["tmpdir"], + "type": "string" + } + }, + "required": ["kind"], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": ["slash_tmp"], + "type": "string" + } + }, + "required": ["kind"], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": ["unknown"], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "anyOf": [ + { + "$ref": "#/definitions/LegacyAppPathString" + }, + { + "type": "null" + } + ] + } + }, + "required": ["kind", "path"], + "type": "object" + } + ] + }, + "FileUpdateChange": { + "properties": { + "diff": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/PatchChangeKind" + }, + "path": { + "type": "string" + } + }, + "required": ["diff", "kind", "path"], + "type": "object" + }, + "FsChangedNotification": { + "description": "Filesystem watch notification emitted for `fs/watch` subscribers.", + "properties": { + "changedPaths": { + "description": "File or directory paths associated with this event.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "watchId": { + "description": "Watch identifier previously provided to `fs/watch`.", + "type": "string" + } + }, + "required": ["changedPaths", "watchId"], + "type": "object" + }, + "FunctionCallOutputBody": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "$ref": "#/definitions/FunctionCallOutputContentItem" + }, + "type": "array" + } + ] + }, + "FunctionCallOutputContentItem": { + "description": "Responses API compatible content items that can be returned by a tool call. This is a subset of ContentItem with the types we support as function call outputs.", + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": ["input_text"], + "title": "InputTextFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": ["text", "type"], + "title": "InputTextFunctionCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, + "image_url": { + "type": "string" + }, + "type": { + "enum": ["input_image"], + "title": "InputImageFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": ["image_url", "type"], + "title": "InputImageFunctionCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "audio_url": { + "type": "string" + }, + "type": { + "enum": ["input_audio"], + "title": "InputAudioFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": ["audio_url", "type"], + "title": "InputAudioFunctionCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": ["encrypted_content"], + "title": "EncryptedContentFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": ["encrypted_content", "type"], + "title": "EncryptedContentFunctionCallOutputContentItem", + "type": "object" + } + ] + }, + "FuzzyFileSearchMatchType": { + "enum": ["file", "directory"], + "type": "string" + }, + "FuzzyFileSearchResult": { + "description": "Superset of [`codex_file_search::FileMatch`]", + "properties": { + "file_name": { + "type": "string" + }, + "indices": { + "items": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "type": ["array", "null"] + }, + "match_type": { + "$ref": "#/definitions/FuzzyFileSearchMatchType" + }, + "path": { + "type": "string" + }, + "root": { + "type": "string" + }, + "score": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + } + }, + "required": ["file_name", "match_type", "path", "root", "score"], + "type": "object" + }, + "FuzzyFileSearchSessionCompletedNotification": { + "properties": { + "sessionId": { + "type": "string" + } + }, + "required": ["sessionId"], + "type": "object" + }, + "FuzzyFileSearchSessionUpdatedNotification": { + "properties": { + "files": { + "items": { + "$ref": "#/definitions/FuzzyFileSearchResult" + }, + "type": "array" + }, + "query": { + "type": "string" + }, + "sessionId": { + "type": "string" + } + }, + "required": ["files", "query", "sessionId"], + "type": "object" + }, + "GitInfo": { + "properties": { + "branch": { + "type": ["string", "null"] + }, + "originUrl": { + "type": ["string", "null"] + }, + "sha": { + "type": ["string", "null"] + } + }, + "type": "object" + }, + "GuardianApprovalReview": { + "description": "[UNSTABLE] Temporary approval auto-review payload used by `item/autoApprovalReview/*` notifications. This shape is expected to change soon.", + "properties": { + "rationale": { + "type": ["string", "null"] + }, + "riskLevel": { + "anyOf": [ + { + "$ref": "#/definitions/GuardianRiskLevel" + }, + { + "type": "null" + } + ] + }, + "status": { + "$ref": "#/definitions/GuardianApprovalReviewStatus" + }, + "userAuthorization": { + "anyOf": [ + { + "$ref": "#/definitions/GuardianUserAuthorization" + }, + { + "type": "null" + } + ] + } + }, + "required": ["status"], + "type": "object" + }, + "GuardianApprovalReviewAction": { + "oneOf": [ + { + "properties": { + "command": { + "type": "string" + }, + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "source": { + "$ref": "#/definitions/GuardianCommandSource" + }, + "type": { + "enum": ["command"], + "title": "CommandGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": ["command", "cwd", "source", "type"], + "title": "CommandGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "argv": { + "items": { + "type": "string" + }, + "type": "array" + }, + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "program": { + "type": "string" + }, + "source": { + "$ref": "#/definitions/GuardianCommandSource" + }, + "type": { + "enum": ["execve"], + "title": "ExecveGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": ["argv", "cwd", "program", "source", "type"], + "title": "ExecveGuardianApprovalReviewAction", + "type": "object" + }, + { + "description": "A child approval for input to an existing command execution item.", + "properties": { + "approvalId": { + "type": "string" + }, + "cwd": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "processId": { + "type": "string" + }, + "stdin": { + "type": "string" + }, + "type": { + "enum": ["writeStdin"], + "title": "WriteStdinGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": ["approvalId", "cwd", "processId", "stdin", "type"], + "title": "WriteStdinGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "files": { + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "type": { + "enum": ["applyPatch"], + "title": "ApplyPatchGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": ["cwd", "files", "type"], + "title": "ApplyPatchGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "host": { + "type": "string" + }, + "port": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "protocol": { + "$ref": "#/definitions/NetworkApprovalProtocol" + }, + "target": { + "type": "string" + }, + "type": { + "enum": ["networkAccess"], + "title": "NetworkAccessGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": ["host", "port", "protocol", "target", "type"], + "title": "NetworkAccessGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "connectorId": { + "type": ["string", "null"] + }, + "connectorName": { + "type": ["string", "null"] + }, + "server": { + "type": "string" + }, + "toolName": { + "type": "string" + }, + "toolTitle": { + "type": ["string", "null"] + }, + "type": { + "enum": ["mcpToolCall"], + "title": "McpToolCallGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": ["server", "toolName", "type"], + "title": "McpToolCallGuardianApprovalReviewAction", + "type": "object" + }, + { + "properties": { + "permissions": { + "$ref": "#/definitions/RequestPermissionProfile" + }, + "reason": { + "type": ["string", "null"] + }, + "type": { + "enum": ["requestPermissions"], + "title": "RequestPermissionsGuardianApprovalReviewActionType", + "type": "string" + } + }, + "required": ["permissions", "type"], + "title": "RequestPermissionsGuardianApprovalReviewAction", + "type": "object" + } + ] + }, + "GuardianApprovalReviewStatus": { + "description": "[UNSTABLE] Lifecycle state for an approval auto-review.", + "enum": ["inProgress", "approved", "denied", "timedOut", "aborted"], + "type": "string" + }, + "GuardianCommandSource": { + "enum": ["shell", "unifiedExec"], + "type": "string" + }, + "GuardianRiskLevel": { + "description": "[UNSTABLE] Risk level assigned by approval auto-review.", + "enum": ["low", "medium", "high", "critical"], + "type": "string" + }, + "GuardianUserAuthorization": { + "description": "[UNSTABLE] Authorization level assigned by approval auto-review.", + "enum": ["unknown", "low", "medium", "high"], + "type": "string" + }, + "GuardianWarningNotification": { + "properties": { + "message": { + "description": "Concise guardian warning message for the user.", + "type": "string" + }, + "threadId": { + "description": "Thread target for the guardian warning.", + "type": "string" + } + }, + "required": ["message", "threadId"], + "type": "object" + }, + "HookCompletedNotification": { + "properties": { + "run": { + "$ref": "#/definitions/HookRunSummary" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": ["string", "null"] + } + }, + "required": ["run", "threadId"], + "type": "object" + }, + "HookEventName": { + "enum": [ + "preToolUse", + "permissionRequest", + "postToolUse", + "preCompact", + "postCompact", + "sessionStart", + "sessionEnd", + "userPromptSubmit", + "subagentStart", + "subagentStop", + "stop", + "interrupt" + ], + "type": "string" + }, + "HookExecutionMode": { + "enum": ["sync", "async"], + "type": "string" + }, + "HookHandlerType": { + "enum": ["command", "mcpTool", "prompt", "agent"], + "type": "string" + }, + "HookOutputEntry": { + "properties": { + "kind": { + "$ref": "#/definitions/HookOutputEntryKind" + }, + "text": { + "type": "string" + } + }, + "required": ["kind", "text"], + "type": "object" + }, + "HookOutputEntryKind": { + "enum": ["warning", "stop", "feedback", "context", "error"], + "type": "string" + }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["hookRunId", "text"], + "type": "object" + }, + "HookRunStatus": { + "enum": ["running", "completed", "failed", "blocked", "stopped"], + "type": "string" + }, + "HookRunSummary": { + "properties": { + "completedAt": { + "format": "int64", + "type": ["integer", "null"] + }, + "displayOrder": { + "format": "int64", + "type": "integer" + }, + "durationMs": { + "format": "int64", + "type": ["integer", "null"] + }, + "entries": { + "items": { + "$ref": "#/definitions/HookOutputEntry" + }, + "type": "array" + }, + "eventName": { + "$ref": "#/definitions/HookEventName" + }, + "executionMode": { + "$ref": "#/definitions/HookExecutionMode" + }, + "handlerType": { + "$ref": "#/definitions/HookHandlerType" + }, + "id": { + "type": "string" + }, + "scope": { + "$ref": "#/definitions/HookScope" + }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/HookSource" + } + ], + "default": "unknown" + }, + "sourcePath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "startedAt": { + "format": "int64", + "type": "integer" + }, + "status": { + "$ref": "#/definitions/HookRunStatus" + }, + "statusMessage": { + "type": ["string", "null"] + } + }, + "required": [ + "displayOrder", + "entries", + "eventName", + "executionMode", + "handlerType", + "id", + "scope", + "sourcePath", + "startedAt", + "status" + ], + "type": "object" + }, + "HookScope": { + "enum": ["thread", "turn"], + "type": "string" + }, + "HookSource": { + "enum": [ + "system", + "user", + "project", + "mdm", + "sessionFlags", + "plugin", + "cloudRequirements", + "cloudManagedConfig", + "legacyManagedConfigFile", + "legacyManagedConfigMdm", + "unknown" + ], + "type": "string" + }, + "HookStartedNotification": { + "properties": { + "run": { + "$ref": "#/definitions/HookRunSummary" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": ["string", "null"] + } + }, + "required": ["run", "threadId"], + "type": "object" + }, + "ImageDetail": { + "enum": ["auto", "low", "high", "original"], + "type": "string" + }, + "ImageGenerationFailure": { + "oneOf": [ + { + "properties": { + "limitId": { + "type": "string" + }, + "resetsAt": { + "format": "int64", + "type": ["integer", "null"] + }, + "type": { + "enum": ["usageLimitExceeded"], + "title": "UsageLimitExceededImageGenerationFailureType", + "type": "string" + } + }, + "required": ["limitId", "type"], + "title": "UsageLimitExceededImageGenerationFailure", + "type": "object" + } + ] + }, + "ItemCompletedNotification": { + "properties": { + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle completed.", + "format": "int64", + "type": "integer" + }, + "item": { + "$ref": "#/definitions/ThreadItem" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": ["completedAtMs", "item", "threadId", "turnId"], + "type": "object" + }, + "ItemGuardianApprovalReviewCompletedNotification": { + "description": "[UNSTABLE] Temporary notification payload for approval auto-review. This shape is expected to change soon.", + "properties": { + "action": { + "$ref": "#/definitions/GuardianApprovalReviewAction" + }, + "completedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review completed.", + "format": "int64", + "type": "integer" + }, + "decisionSource": { + "$ref": "#/definitions/AutoReviewDecisionSource" + }, + "review": { + "$ref": "#/definitions/GuardianApprovalReview" + }, + "reviewId": { + "description": "Stable identifier for this review.", + "type": "string" + }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review started.", + "format": "int64", + "type": "integer" + }, + "targetItemId": { + "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - stdin reviews, which refer to the existing parent command item and have a separate approval ID in the action payload - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.", + "type": ["string", "null"] + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": [ + "action", + "completedAtMs", + "decisionSource", + "review", + "reviewId", + "startedAtMs", + "threadId", + "turnId" + ], + "type": "object" + }, + "ItemGuardianApprovalReviewStartedNotification": { + "description": "[UNSTABLE] Temporary notification payload for approval auto-review. This shape is expected to change soon.", + "properties": { + "action": { + "$ref": "#/definitions/GuardianApprovalReviewAction" + }, + "review": { + "$ref": "#/definitions/GuardianApprovalReview" + }, + "reviewId": { + "description": "Stable identifier for this review.", + "type": "string" + }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review started.", + "format": "int64", + "type": "integer" + }, + "targetItemId": { + "description": "Identifier for the reviewed item or tool call when one exists.\n\nIn most cases, one review maps to one target item. The exceptions are - execve reviews, where a single command may contain multiple execve calls to review (only possible when using the shell_zsh_fork feature) - stdin reviews, which refer to the existing parent command item and have a separate approval ID in the action payload - network policy reviews, where there is no target item\n\nA network call is triggered by a CommandExecution item, so having a target_item_id set to the CommandExecution item would be misleading because the review is about the network call, not the command execution. Therefore, target_item_id is set to None for network policy reviews.", + "type": ["string", "null"] + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": ["action", "review", "reviewId", "startedAtMs", "threadId", "turnId"], + "type": "object" + }, + "ItemStartedNotification": { + "properties": { + "item": { + "$ref": "#/definitions/ThreadItem" + }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this item lifecycle started.", + "format": "int64", + "type": "integer" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": ["item", "startedAtMs", "threadId", "turnId"], + "type": "object" + }, + "LegacyAppPathString": { + "type": "string" + }, + "McpServerEventNotification": { + "properties": { + "method": { + "type": "string" + }, + "params": true + }, + "required": ["method", "params"], + "type": "object" + }, + "McpServerEventStreamNotification": { + "properties": { + "notification": { + "$ref": "#/definitions/McpServerEventNotification" + }, + "subscriptionId": { + "type": "string" + } + }, + "required": ["notification", "subscriptionId"], + "type": "object" + }, + "McpServerOauthLoginCompletedNotification": { + "properties": { + "error": { + "type": ["string", "null"] + }, + "name": { + "type": "string" + }, + "success": { + "type": "boolean" + }, + "threadId": { + "type": ["string", "null"] + } + }, + "required": ["name", "success"], + "type": "object" + }, + "McpServerStartupFailureReason": { + "enum": ["reauthenticationRequired"], + "type": "string" + }, + "McpServerStartupState": { + "enum": ["starting", "ready", "failed", "cancelled"], + "type": "string" + }, + "McpServerStatusUpdatedNotification": { + "properties": { + "error": { + "type": ["string", "null"] + }, + "failureReason": { + "anyOf": [ + { + "$ref": "#/definitions/McpServerStartupFailureReason" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/McpServerStartupState" + }, + "threadId": { + "type": ["string", "null"] + } + }, + "required": ["name", "status"], + "type": "object" + }, + "McpToolCallAppContext": { + "properties": { + "actionName": { + "type": ["string", "null"] + }, + "appName": { + "type": ["string", "null"] + }, + "connectorId": { + "type": "string" + }, + "linkId": { + "type": ["string", "null"] + }, + "resourceUri": { + "type": ["string", "null"] + } + }, + "required": ["connectorId"], + "type": "object" + }, + "McpToolCallError": { + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "type": "object" + }, + "McpToolCallProgressNotification": { + "properties": { + "itemId": { + "type": "string" + }, + "message": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": ["itemId", "message", "threadId", "turnId"], + "type": "object" + }, + "McpToolCallResult": { + "properties": { + "_meta": true, + "content": { + "items": true, + "type": "array" + }, + "structuredContent": true + }, + "required": ["content"], + "type": "object" + }, + "McpToolCallStatus": { + "enum": ["inProgress", "completed", "failed"], + "type": "string" + }, + "MemoryCitation": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/MemoryCitationEntry" + }, + "type": "array" + }, + "threadIds": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": ["entries", "threadIds"], + "type": "object" + }, + "MemoryCitationEntry": { + "properties": { + "lineEnd": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "lineStart": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "note": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": ["lineEnd", "lineStart", "note", "path"], + "type": "object" + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": ["commentary"], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": ["final_answer"], + "type": "string" + } + ] + }, + "MisalignmentErrorDetails": { + "properties": { + "detailedExplanation": { + "description": "A substantive localized explanation is required before offering continuation.", + "type": ["string", "null"] + }, + "errorType": { + "description": "Open-ended classification; clients must accept categories added by Responses.", + "type": ["string", "null"] + }, + "steer": { + "anyOf": [ + { + "$ref": "#/definitions/MisalignmentSteer" + }, + { + "type": "null" + } + ], + "description": "Instruction to submit as the next turn's user input if continuation is confirmed." + } + }, + "type": "object" + }, + "MisalignmentSteer": { + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "type": "object" + }, + "ModeKind": { + "description": "Initial collaboration mode to use when the TUI starts.", + "enum": ["plan", "default"], + "type": "string" + }, + "ModelRerouteReason": { + "enum": ["highRiskCyberActivity"], + "type": "string" + }, + "ModelReroutedNotification": { + "properties": { + "fromModel": { + "type": "string" + }, + "reason": { + "$ref": "#/definitions/ModelRerouteReason" + }, + "threadId": { + "type": "string" + }, + "toModel": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": ["fromModel", "reason", "threadId", "toModel", "turnId"], + "type": "object" + }, + "ModelSafetyBufferingUpdatedNotification": { + "properties": { + "fasterModel": { + "type": ["string", "null"] + }, + "model": { + "type": "string" + }, + "reasons": { + "items": { + "type": "string" + }, + "type": "array" + }, + "showBufferingUi": { + "type": "boolean" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "useCases": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": ["model", "reasons", "showBufferingUi", "threadId", "turnId", "useCases"], + "type": "object" + }, + "ModelVerification": { + "enum": ["trustedAccessForCyber"], + "type": "string" + }, + "ModelVerificationNotification": { + "properties": { + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "verifications": { + "items": { + "$ref": "#/definitions/ModelVerification" + }, + "type": "array" + } + }, + "required": ["threadId", "turnId", "verifications"], + "type": "object" + }, + "MultiAgentMode": { + "description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.", + "oneOf": [ + { + "enum": ["explicitRequestOnly", "proactive"], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": ["custom"], + "title": "CustomMultiAgentMode", + "type": "object" + } + ] + }, + "NetworkAccess": { + "enum": ["restricted", "enabled"], + "type": "string" + }, + "NetworkApprovalProtocol": { + "enum": ["http", "https", "socks5Tcp", "socks5Udp"], + "type": "string" + }, + "NonSteerableTurnKind": { + "enum": ["review", "compact"], + "type": "string" + }, + "PatchApplyStatus": { + "enum": ["inProgress", "completed", "failed", "declined"], + "type": "string" + }, + "PatchChangeKind": { + "oneOf": [ + { + "properties": { + "type": { + "enum": ["add"], + "title": "AddPatchChangeKindType", + "type": "string" + } + }, + "required": ["type"], + "title": "AddPatchChangeKind", + "type": "object" + }, + { + "properties": { + "type": { + "enum": ["delete"], + "title": "DeletePatchChangeKindType", + "type": "string" + } + }, + "required": ["type"], + "title": "DeletePatchChangeKind", + "type": "object" + }, + { + "properties": { + "move_path": { + "type": ["string", "null"] + }, + "type": { + "enum": ["update"], + "title": "UpdatePatchChangeKindType", + "type": "string" + } + }, + "required": ["type"], + "title": "UpdatePatchChangeKind", + "type": "object" + } + ] + }, + "Personality": { + "enum": ["none", "friendly", "pragmatic"], + "type": "string" + }, + "PlanDeltaNotification": { + "description": "EXPERIMENTAL - proposed plan streaming deltas for plan items. Clients should not assume concatenated deltas match the completed plan item content.", + "properties": { + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": ["delta", "itemId", "threadId", "turnId"], + "type": "object" + }, + "PlanType": { + "enum": [ + "free", + "go", + "plus", + "pro", + "prolite", + "team", + "self_serve_business_prolite", + "self_serve_business_usage_based", + "business", + "ent26", + "enterprise_cbp_automation", + "enterprise_cbp_usage_based", + "enterprise", + "edu", + "edu_plus", + "edu_pro", + "unknown" + ], + "type": "string" + }, + "ProcessExitedNotification": { + "description": "Final process exit notification for `process/spawn`.", + "properties": { + "exitCode": { + "description": "Process exit code.", + "format": "int32", + "type": "integer" + }, + "processHandle": { + "description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.", + "type": "string" + }, + "stderr": { + "description": "Buffered stderr capture.\n\nEmpty when stderr was streamed via `process/outputDelta`.", + "type": "string" + }, + "stderrCapReached": { + "description": "Whether stderr reached `outputBytesCap`.\n\nIn streaming mode, stderr is empty and cap state is also reported on the final stderr `process/outputDelta` notification.", + "type": "boolean" + }, + "stdout": { + "description": "Buffered stdout capture.\n\nEmpty when stdout was streamed via `process/outputDelta`.", + "type": "string" + }, + "stdoutCapReached": { + "description": "Whether stdout reached `outputBytesCap`.\n\nIn streaming mode, stdout is empty and cap state is also reported on the final stdout `process/outputDelta` notification.", + "type": "boolean" + } + }, + "required": [ + "exitCode", + "processHandle", + "stderr", + "stderrCapReached", + "stdout", + "stdoutCapReached" + ], + "type": "object" + }, + "ProcessOutputDeltaNotification": { + "description": "Base64-encoded output chunk emitted for a streaming `process/spawn` request.", + "properties": { + "capReached": { + "description": "True on the final streamed chunk for this stream when output was truncated by `outputBytesCap`.", + "type": "boolean" + }, + "deltaBase64": { + "description": "Base64-encoded output bytes.", + "type": "string" + }, + "processHandle": { + "description": "Client-supplied, connection-scoped `processHandle` from `process/spawn`.", + "type": "string" + }, + "stream": { + "allOf": [ + { + "$ref": "#/definitions/ProcessOutputStream" + } + ], + "description": "Output stream this chunk belongs to." + } + }, + "required": ["capReached", "deltaBase64", "processHandle", "stream"], + "type": "object" + }, + "ProcessOutputStream": { + "description": "Stream label for `process/outputDelta` notifications.", + "oneOf": [ + { + "description": "stdout stream. PTY mode multiplexes terminal output here.", + "enum": ["stdout"], + "type": "string" + }, + { + "description": "stderr stream.", + "enum": ["stderr"], + "type": "string" + } + ] + }, + "ProjectChangeType": { + "enum": ["created", "updated", "deleted"], + "type": "string" + }, + "ProjectChangedNotification": { + "properties": { + "changeType": { + "$ref": "#/definitions/ProjectChangeType" + }, + "projectId": { + "type": "string" + } + }, + "required": ["changeType", "projectId"], + "type": "object" + }, + "RateLimitReachedType": { + "enum": [ + "rate_limit_reached", + "workspace_owner_credits_depleted", + "workspace_member_credits_depleted", + "workspace_owner_usage_limit_reached", + "workspace_member_usage_limit_reached" + ], + "type": "string" + }, + "RateLimitSnapshot": { + "properties": { + "credits": { + "anyOf": [ + { + "$ref": "#/definitions/CreditsSnapshot" + }, + { + "type": "null" + } + ] + }, + "individualLimit": { + "anyOf": [ + { + "$ref": "#/definitions/SpendControlLimitSnapshot" + }, + { + "type": "null" + } + ] + }, + "limitId": { + "type": ["string", "null"] + }, + "limitName": { + "type": ["string", "null"] + }, + "planType": { + "anyOf": [ + { + "$ref": "#/definitions/PlanType" + }, + { + "type": "null" + } + ] + }, + "primary": { + "anyOf": [ + { + "$ref": "#/definitions/RateLimitWindow" + }, + { + "type": "null" + } + ] + }, + "rateLimitReachedType": { + "anyOf": [ + { + "$ref": "#/definitions/RateLimitReachedType" + }, + { + "type": "null" + } + ] + }, + "secondary": { + "anyOf": [ + { + "$ref": "#/definitions/RateLimitWindow" + }, + { + "type": "null" + } + ] + }, + "spendControlReached": { + "description": "Backend-reported spend-control state. `None` is unavailable, not a sparse-update recovery.", + "type": ["boolean", "null"] + } + }, + "type": "object" + }, + "RateLimitWindow": { + "properties": { + "resetsAt": { + "format": "int64", + "type": ["integer", "null"] + }, + "usedPercent": { + "format": "int32", + "type": "integer" + }, + "windowDurationMins": { + "format": "int64", + "type": ["integer", "null"] + } + }, + "required": ["usedPercent"], + "type": "object" + }, + "RealtimeConversationVersion": { + "enum": ["v1", "v2", "v3"], + "type": "string" + }, + "ReasoningEffort": { + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, + "type": "string" + }, + "ReasoningSummary": { + "description": "A summary of the reasoning performed by the model. This can be useful for debugging and understanding the model's reasoning process. See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries", + "oneOf": [ + { + "enum": ["auto", "concise", "detailed"], + "type": "string" + }, + { + "description": "Option to disable reasoning summaries.", + "enum": ["none"], + "type": "string" + } + ] + }, + "ReasoningSummaryPartAddedNotification": { + "properties": { + "itemId": { + "type": "string" + }, + "summaryIndex": { + "format": "int64", + "type": "integer" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": ["itemId", "summaryIndex", "threadId", "turnId"], + "type": "object" + }, + "ReasoningSummaryTextDeltaNotification": { + "properties": { + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "summaryIndex": { + "format": "int64", + "type": "integer" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": ["delta", "itemId", "summaryIndex", "threadId", "turnId"], + "type": "object" + }, + "ReasoningTextDeltaNotification": { + "properties": { + "contentIndex": { + "format": "int64", + "type": "integer" + }, + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": ["contentIndex", "delta", "itemId", "threadId", "turnId"], + "type": "object" + }, + "RemoteControlConnectionStatus": { + "enum": ["disabled", "connecting", "connected", "errored"], + "type": "string" + }, + "RemoteControlStatusChangedNotification": { + "description": "Current remote-control connection status and remote identity exposed to clients.", + "properties": { + "environmentId": { + "type": ["string", "null"] + }, + "installationId": { + "type": "string" + }, + "serverName": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/RemoteControlConnectionStatus" + } + }, + "required": ["installationId", "serverName", "status"], + "type": "object" + }, + "RequestId": { + "anyOf": [ + { + "type": "string" + }, + { + "format": "int64", + "type": "integer" + } + ] + }, + "RequestPermissionProfile": { + "additionalProperties": false, + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalNetworkPermissions" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "SandboxPolicy": { + "oneOf": [ + { + "properties": { + "type": { + "enum": ["dangerFullAccess"], + "title": "DangerFullAccessSandboxPolicyType", + "type": "string" + } + }, + "required": ["type"], + "title": "DangerFullAccessSandboxPolicy", + "type": "object" + }, + { + "properties": { + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "enum": ["readOnly"], + "title": "ReadOnlySandboxPolicyType", + "type": "string" + } + }, + "required": ["type"], + "title": "ReadOnlySandboxPolicy", + "type": "object" + }, + { + "properties": { + "networkAccess": { + "allOf": [ + { + "$ref": "#/definitions/NetworkAccess" + } + ], + "default": "restricted" + }, + "type": { + "enum": ["externalSandbox"], + "title": "ExternalSandboxSandboxPolicyType", + "type": "string" + } + }, + "required": ["type"], + "title": "ExternalSandboxSandboxPolicy", + "type": "object" + }, + { + "properties": { + "excludeSlashTmp": { + "default": false, + "type": "boolean" + }, + "excludeTmpdirEnvVar": { + "default": false, + "type": "boolean" + }, + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "enum": ["workspaceWrite"], + "title": "WorkspaceWriteSandboxPolicyType", + "type": "string" + }, + "writableRoots": { + "default": [], + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + } + }, + "required": ["type"], + "title": "WorkspaceWriteSandboxPolicy", + "type": "object" + } + ] + }, + "ServerRequestResolvedNotification": { + "properties": { + "requestId": { + "$ref": "#/definitions/RequestId" + }, + "threadId": { + "type": "string" + } + }, + "required": ["requestId", "threadId"], + "type": "object" + }, + "SessionSource": { + "oneOf": [ + { + "enum": ["cli", "vscode", "exec", "appServer", "unknown"], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": ["custom"], + "title": "CustomSessionSource", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "subAgent": { + "$ref": "#/definitions/SubAgentSource" + } + }, + "required": ["subAgent"], + "title": "SubAgentSessionSource", + "type": "object" + } + ] + }, + "Settings": { + "description": "Settings for a collaboration mode.", + "properties": { + "developer_instructions": { + "type": ["string", "null"] + }, + "model": { + "type": "string" + }, + "reasoning_effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + } + }, + "required": ["model"], + "type": "object" + }, + "SkillsChangedNotification": { + "description": "Notification emitted when watched local skill files change.\n\nTreat this as an invalidation signal and re-run `skills/list` with the client's current parameters when refreshed skill metadata is needed.", + "type": "object" + }, + "SpendControlLimitSnapshot": { + "properties": { + "limit": { + "type": "string" + }, + "remainingPercent": { + "format": "int32", + "type": "integer" + }, + "resetsAt": { + "format": "int64", + "type": "integer" + }, + "used": { + "type": "string" + } + }, + "required": ["limit", "remainingPercent", "resetsAt", "used"], + "type": "object" + }, + "StrictReviewRequiredNotification": { + "properties": { + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this review started.", + "format": "int64", + "type": "integer" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": ["startedAtMs", "threadId", "turnId"], + "type": "object" + }, + "SubAgentActivityKind": { + "enum": ["started", "interacted", "interrupted", "completed"], + "type": "string" + }, + "SubAgentSource": { + "oneOf": [ + { + "enum": ["review", "compact", "memory_consolidation"], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "thread_spawn": { + "properties": { + "agent_nickname": { + "default": null, + "type": ["string", "null"] + }, + "agent_path": { + "anyOf": [ + { + "$ref": "#/definitions/AgentPath" + }, + { + "type": "null" + } + ], + "default": null + }, + "agent_role": { + "default": null, + "type": ["string", "null"] + }, + "depth": { + "format": "int32", + "type": "integer" + }, + "parent_thread_id": { + "$ref": "#/definitions/ThreadId" + } + }, + "required": ["depth", "parent_thread_id"], + "type": "object" + } + }, + "required": ["thread_spawn"], + "title": "ThreadSpawnSubAgentSource", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "other": { + "type": "string" + } + }, + "required": ["other"], + "title": "OtherSubAgentSource", + "type": "object" + } + ] + }, + "TerminalInteractionNotification": { + "properties": { + "itemId": { + "type": "string" + }, + "processId": { + "type": "string" + }, + "stdin": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": ["itemId", "processId", "stdin", "threadId", "turnId"], + "type": "object" + }, + "TextElement": { + "properties": { + "byteRange": { + "allOf": [ + { + "$ref": "#/definitions/ByteRange" + } + ], + "description": "Byte range in the parent `text` buffer that this element occupies." + }, + "placeholder": { + "description": "Optional human-readable placeholder for the element, displayed in the UI.", + "type": ["string", "null"] + } + }, + "required": ["byteRange"], + "type": "object" + }, + "TextPosition": { + "properties": { + "column": { + "description": "1-based column number (in Unicode scalar values).", + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "line": { + "description": "1-based line number.", + "format": "uint", + "minimum": 0.0, + "type": "integer" + } + }, + "required": ["column", "line"], + "type": "object" + }, + "TextRange": { + "properties": { + "end": { + "$ref": "#/definitions/TextPosition" + }, + "start": { + "$ref": "#/definitions/TextPosition" + } + }, + "required": ["end", "start"], + "type": "object" + }, + "Thread": { + "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": ["string", "null"] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": ["string", "null"] + }, + "canAcceptDirectInput": { + "description": "Whether the app server accepts direct turn input for this loaded thread. `None` means the capability is unavailable, such as for an unloaded stored thread.", + "type": ["boolean", "null"] + }, + "cliVersion": { + "description": "Version of the CLI that created the thread.", + "type": "string" + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the thread was created.", + "format": "int64", + "type": "integer" + }, + "cwd": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Working directory captured for the thread." + }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, + "extra": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadExtra" + }, + { + "type": "null" + } + ], + "description": "Optional implementation-specific thread data." + }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": ["string", "null"] + }, + "gitInfo": { + "anyOf": [ + { + "$ref": "#/definitions/GitInfo" + }, + { + "type": "null" + } + ], + "description": "Optional Git metadata captured when the thread was created." + }, + "historyMode": { + "allOf": [ + { + "$ref": "#/definitions/ThreadHistoryMode" + } + ], + "default": "legacy", + "description": "Persisted thread history contract selected when this thread was created." + }, + "id": { + "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", + "type": "string" + }, + "modelProvider": { + "description": "Model provider used for this thread (for example, 'openai').", + "type": "string" + }, + "name": { + "description": "Optional user-facing thread title.", + "type": ["string", "null"] + }, + "parentThreadId": { + "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", + "type": ["string", "null"] + }, + "path": { + "description": "[UNSTABLE] Path to the thread on disk.", + "type": ["string", "null"] + }, + "preview": { + "description": "Usually the first user message in the thread, if available.", + "type": "string" + }, + "projectId": { + "description": "Canonical project assignment owned by app-server, if any.", + "type": ["string", "null"] + }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": ["integer", "null"] + }, + "section": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSection" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The independently persisted section selected for this thread, if any." + }, + "sectionEnteredAt": { + "default": null, + "description": "Unix timestamp in seconds when the thread entered its current section.", + "format": "int64", + "type": ["integer", "null"] + }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/SessionSource" + } + ], + "description": "Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.)." + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/ThreadStatus" + } + ], + "description": "Current runtime status for the thread." + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, + "turns": { + "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", + "items": { + "$ref": "#/definitions/Turn" + }, + "type": "array" + }, + "updatedAt": { + "description": "Unix timestamp (in seconds) when the thread was last updated.", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "cliVersion", + "createdAt", + "cwd", + "ephemeral", + "id", + "modelProvider", + "preview", + "projectId", + "sessionId", + "source", + "status", + "turns", + "updatedAt" + ], + "type": "object" + }, + "ThreadActiveFlag": { + "enum": ["waitingOnApproval", "waitingOnUserInput"], + "type": "string" + }, + "ThreadArchivedNotification": { + "properties": { + "threadId": { + "type": "string" + } + }, + "required": ["threadId"], + "type": "object" + }, + "ThreadClosedNotification": { + "properties": { + "threadId": { + "type": "string" + } + }, + "required": ["threadId"], + "type": "object" + }, + "ThreadDeletedNotification": { + "properties": { + "threadId": { + "type": "string" + } + }, + "required": ["threadId"], + "type": "object" + }, + "ThreadExtra": { + "description": "Extra app-server data for a thread.", + "type": "object" + }, + "ThreadGoal": { + "properties": { + "createdAt": { + "format": "int64", + "type": "integer" + }, + "objective": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/ThreadGoalStatus" + }, + "threadId": { + "type": "string" + }, + "timeUsedSeconds": { + "format": "int64", + "type": "integer" + }, + "tokenBudget": { + "format": "int64", + "type": ["integer", "null"] + }, + "tokensUsed": { + "format": "int64", + "type": "integer" + }, + "updatedAt": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "createdAt", + "objective", + "status", + "threadId", + "timeUsedSeconds", + "tokensUsed", + "updatedAt" + ], + "type": "object" + }, + "ThreadGoalClearedNotification": { + "properties": { + "threadId": { + "type": "string" + } + }, + "required": ["threadId"], + "type": "object" + }, + "ThreadGoalStatus": { + "enum": ["active", "paused", "blocked", "usageLimited", "budgetLimited", "complete"], + "type": "string" + }, + "ThreadGoalUpdatedNotification": { + "properties": { + "goal": { + "$ref": "#/definitions/ThreadGoal" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": ["string", "null"] + } + }, + "required": ["goal", "threadId"], + "type": "object" + }, + "ThreadHistoryMode": { + "enum": ["legacy", "paginated"], + "type": "string" + }, + "ThreadId": { + "type": "string" + }, + "ThreadItem": { + "oneOf": [ + { + "properties": { + "clientId": { + "type": ["string", "null"] + }, + "content": { + "items": { + "$ref": "#/definitions/UserInput" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": ["userMessage"], + "title": "UserMessageThreadItemType", + "type": "string" + } + }, + "required": ["content", "id", "type"], + "title": "UserMessageThreadItem", + "type": "object" + }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": ["hookPrompt"], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": ["fragments", "id", "type"], + "title": "HookPromptThreadItem", + "type": "object" + }, + { + "properties": { + "delivery": { + "anyOf": [ + { + "$ref": "#/definitions/AgentMessageDelivery" + }, + { + "type": "null" + } + ], + "default": null + }, + "id": { + "type": "string" + }, + "memoryCitation": { + "anyOf": [ + { + "$ref": "#/definitions/MemoryCitation" + }, + { + "type": "null" + } + ], + "default": null + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, + "text": { + "type": "string" + }, + "type": { + "enum": ["agentMessage"], + "title": "AgentMessageThreadItemType", + "type": "string" + } + }, + "required": ["id", "text", "type"], + "title": "AgentMessageThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "namespace": { + "type": ["string", "null"] + }, + "output": { + "$ref": "#/definitions/FunctionCallOutputBody" + }, + "type": { + "enum": ["functionCallOutput"], + "title": "FunctionCallOutputThreadItemType", + "type": "string" + } + }, + "required": ["id", "name", "output", "type"], + "title": "FunctionCallOutputThreadItem", + "type": "object" + }, + { + "description": "EXPERIMENTAL - proposed plan item content. The completed plan item is authoritative and may not match the concatenation of `PlanDelta` text.", + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "type": { + "enum": ["plan"], + "title": "PlanThreadItemType", + "type": "string" + } + }, + "required": ["id", "text", "type"], + "title": "PlanThreadItem", + "type": "object" + }, + { + "properties": { + "content": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "summary": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "enum": ["reasoning"], + "title": "ReasoningThreadItemType", + "type": "string" + } + }, + "required": ["id", "type"], + "title": "ReasoningThreadItem", + "type": "object" + }, + { + "properties": { + "aggregatedOutput": { + "description": "The command's output, aggregated from stdout and stderr.", + "type": ["string", "null"] + }, + "command": { + "description": "The command to be executed.", + "type": "string" + }, + "commandActions": { + "description": "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", + "items": { + "$ref": "#/definitions/CommandAction" + }, + "type": "array" + }, + "cwd": { + "allOf": [ + { + "$ref": "#/definitions/LegacyAppPathString" + } + ], + "description": "The command's working directory." + }, + "durationMs": { + "description": "The duration of the command execution in milliseconds.", + "format": "int64", + "type": ["integer", "null"] + }, + "exitCode": { + "description": "The command's exit code.", + "format": "int32", + "type": ["integer", "null"] + }, + "id": { + "type": "string" + }, + "pluginId": { + "default": null, + "description": "Trusted first-party plugin id when this command resolves to one plugin script.", + "type": ["string", "null"] + }, + "processId": { + "description": "Identifier for the underlying PTY process (when available).", + "type": ["string", "null"] + }, + "scriptPath": { + "default": null, + "description": "Safe plugin-relative path when this command resolves to one plugin script.", + "type": ["string", "null"] + }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, + "status": { + "$ref": "#/definitions/CommandExecutionStatus" + }, + "type": { + "enum": ["commandExecution"], + "title": "CommandExecutionThreadItemType", + "type": "string" + } + }, + "required": ["command", "commandActions", "cwd", "id", "status", "type"], + "title": "CommandExecutionThreadItem", + "type": "object" + }, + { + "properties": { + "changes": { + "items": { + "$ref": "#/definitions/FileUpdateChange" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/PatchApplyStatus" + }, + "type": { + "enum": ["fileChange"], + "title": "FileChangeThreadItemType", + "type": "string" + } + }, + "required": ["changes", "id", "status", "type"], + "title": "FileChangeThreadItem", + "type": "object" + }, + { + "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, + "arguments": true, + "durationMs": { + "description": "The duration of the MCP tool call in milliseconds.", + "format": "int64", + "type": ["integer", "null"] + }, + "error": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallError" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", + "type": ["string", "null"] + }, + "pluginId": { + "type": ["string", "null"] + }, + "readOnlyHint": { + "type": ["boolean", "null"] + }, + "result": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallResult" + }, + { + "type": "null" + } + ] + }, + "server": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/McpToolCallStatus" + }, + "tool": { + "type": "string" + }, + "type": { + "enum": ["mcpToolCall"], + "title": "McpToolCallThreadItemType", + "type": "string" + } + }, + "required": ["arguments", "id", "server", "status", "tool", "type"], + "title": "McpToolCallThreadItem", + "type": "object" + }, + { + "properties": { + "arguments": true, + "contentItems": { + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + }, + "type": ["array", "null"] + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "format": "int64", + "type": ["integer", "null"] + }, + "id": { + "type": "string" + }, + "namespace": { + "type": ["string", "null"] + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": ["boolean", "null"] + }, + "tool": { + "type": "string" + }, + "type": { + "enum": ["dynamicToolCall"], + "title": "DynamicToolCallThreadItemType", + "type": "string" + } + }, + "required": ["arguments", "id", "status", "tool", "type"], + "title": "DynamicToolCallThreadItem", + "type": "object" + }, + { + "properties": { + "agentsStates": { + "additionalProperties": { + "$ref": "#/definitions/CollabAgentState" + }, + "description": "Last known status of the target agents, when available.", + "type": "object" + }, + "id": { + "description": "Unique identifier for this collab tool call.", + "type": "string" + }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": ["string", "null"] + }, + "prompt": { + "description": "Prompt text sent as part of the collab tool call, when available.", + "type": ["string", "null"] + }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ], + "description": "Reasoning effort requested for the spawned agent, when applicable." + }, + "receiverThreadIds": { + "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", + "items": { + "type": "string" + }, + "type": "array" + }, + "senderThreadId": { + "description": "Thread ID of the agent issuing the collab request.", + "type": "string" + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/CollabAgentToolCallStatus" + } + ], + "description": "Current status of the collab tool call." + }, + "tool": { + "allOf": [ + { + "$ref": "#/definitions/CollabAgentTool" + } + ], + "description": "Name of the collab tool that was invoked." + }, + "type": { + "enum": ["collabAgentToolCall"], + "title": "CollabAgentToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "agentsStates", + "id", + "receiverThreadIds", + "senderThreadId", + "status", + "tool", + "type" + ], + "title": "CollabAgentToolCallThreadItem", + "type": "object" + }, + { + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "enum": ["subAgentActivity"], + "title": "SubAgentActivityThreadItemType", + "type": "string" + } + }, + "required": ["agentPath", "agentThreadId", "id", "kind", "type"], + "title": "SubAgentActivityThreadItem", + "type": "object" + }, + { + "properties": { + "action": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchAction" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "query": { + "type": "string" + }, + "results": { + "default": null, + "description": "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.", + "items": true, + "type": ["array", "null"] + }, + "type": { + "enum": ["webSearch"], + "title": "WebSearchThreadItemType", + "type": "string" + } + }, + "required": ["id", "query", "type"], + "title": "WebSearchThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": { + "enum": ["imageView"], + "title": "ImageViewThreadItemType", + "type": "string" + } + }, + "required": ["id", "path", "type"], + "title": "ImageViewThreadItem", + "type": "object" + }, + { + "description": "Display item emitted by the interruptible `clock.sleep` tool.", + "properties": { + "durationMs": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "id": { + "type": "string" + }, + "type": { + "enum": ["sleep"], + "title": "SleepThreadItemType", + "type": "string" + } + }, + "required": ["durationMs", "id", "type"], + "title": "SleepThreadItem", + "type": "object" + }, + { + "properties": { + "failure": { + "anyOf": [ + { + "$ref": "#/definitions/ImageGenerationFailure" + }, + { + "type": "null" + } + ], + "default": null + }, + "id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "revisedPrompt": { + "type": ["string", "null"] + }, + "savedPath": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string" + }, + "transparentBackground": { + "default": null, + "type": ["boolean", "null"] + }, + "type": { + "enum": ["imageGeneration"], + "title": "ImageGenerationThreadItemType", + "type": "string" + } + }, + "required": ["id", "result", "status", "type"], + "title": "ImageGenerationThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "enum": ["enteredReviewMode"], + "title": "EnteredReviewModeThreadItemType", + "type": "string" + } + }, + "required": ["id", "review", "type"], + "title": "EnteredReviewModeThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "enum": ["exitedReviewMode"], + "title": "ExitedReviewModeThreadItemType", + "type": "string" + } + }, + "required": ["id", "review", "type"], + "title": "ExitedReviewModeThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "type": { + "enum": ["contextCompaction"], + "title": "ContextCompactionThreadItemType", + "type": "string" + } + }, + "required": ["id", "type"], + "title": "ContextCompactionThreadItem", + "type": "object" + } + ] + }, + "ThreadNameUpdatedNotification": { + "properties": { + "threadId": { + "type": "string" + }, + "threadName": { + "type": ["string", "null"] + } + }, + "required": ["threadId"], + "type": "object" + }, + "ThreadProjectUpdatedNotification": { + "properties": { + "projectId": { + "type": ["string", "null"] + }, + "threadId": { + "type": "string" + } + }, + "required": ["projectId", "threadId"], + "type": "object" + }, + "ThreadQueueChangedNotification": { + "properties": { + "threadId": { + "type": "string" + } + }, + "required": ["threadId"], + "type": "object" + }, + "ThreadRealtimeAudioChunk": { + "description": "EXPERIMENTAL - thread realtime audio chunk.", + "properties": { + "data": { + "type": "string" + }, + "itemId": { + "type": ["string", "null"] + }, + "numChannels": { + "format": "uint16", + "minimum": 0.0, + "type": "integer" + }, + "sampleRate": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "samplesPerChannel": { + "format": "uint32", + "minimum": 0.0, + "type": ["integer", "null"] + } + }, + "required": ["data", "numChannels", "sampleRate"], + "type": "object" + }, + "ThreadRealtimeBemItemPresentation": { + "description": "EXPERIMENTAL - how an existing agent item appears in a realtime conversation.", + "oneOf": [ + { + "properties": { + "type": { + "enum": ["wholeItem"], + "title": "WholeItemThreadRealtimeBemItemPresentationType", + "type": "string" + } + }, + "required": ["type"], + "title": "WholeItemThreadRealtimeBemItemPresentation", + "type": "object" + }, + { + "properties": { + "type": { + "enum": ["inlineMarkdown"], + "title": "InlineMarkdownThreadRealtimeBemItemPresentationType", + "type": "string" + } + }, + "required": ["type"], + "title": "InlineMarkdownThreadRealtimeBemItemPresentation", + "type": "object" + }, + { + "properties": { + "index": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "type": { + "enum": ["inlineVisualization"], + "title": "InlineVisualizationThreadRealtimeBemItemPresentationType", + "type": "string" + } + }, + "required": ["index", "type"], + "title": "InlineVisualizationThreadRealtimeBemItemPresentation", + "type": "object" + } + ] + }, + "ThreadRealtimeClosedNotification": { + "description": "EXPERIMENTAL - emitted when thread realtime transport closes.", + "properties": { + "reason": { + "type": ["string", "null"] + }, + "threadId": { + "type": "string" + } + }, + "required": ["threadId"], + "type": "object" + }, + "ThreadRealtimeErrorNotification": { + "description": "EXPERIMENTAL - emitted when thread realtime encounters an error.", + "properties": { + "message": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": ["message", "threadId"], + "type": "object" + }, + "ThreadRealtimeItem": { + "description": "EXPERIMENTAL - a thread-scoped realtime item in the canonical timeline.", + "oneOf": [ + { + "properties": { + "type": { + "enum": ["realtimeSessionStarted"], + "title": "RealtimeSessionStartedThreadRealtimeItemType", + "type": "string" + } + }, + "required": ["type"], + "title": "RealtimeSessionStartedThreadRealtimeItem", + "type": "object" + }, + { + "properties": { + "role": { + "$ref": "#/definitions/ThreadRealtimeTranscriptRole" + }, + "text": { + "type": "string" + }, + "type": { + "enum": ["transcriptSegment"], + "title": "TranscriptSegmentThreadRealtimeItemType", + "type": "string" + } + }, + "required": ["role", "text", "type"], + "title": "TranscriptSegmentThreadRealtimeItem", + "type": "object" + }, + { + "properties": { + "item_id": { + "type": "string" + }, + "presentation": { + "$ref": "#/definitions/ThreadRealtimeBemItemPresentation" + }, + "turn_id": { + "type": "string" + }, + "type": { + "enum": ["bemItemPromoted"], + "title": "BemItemPromotedThreadRealtimeItemType", + "type": "string" + } + }, + "required": ["item_id", "presentation", "turn_id", "type"], + "title": "BemItemPromotedThreadRealtimeItem", + "type": "object" + }, + { + "properties": { + "outcome": { + "$ref": "#/definitions/ThreadRealtimeSessionOutcome" + }, + "type": { + "enum": ["realtimeSessionClosed"], + "title": "RealtimeSessionClosedThreadRealtimeItemType", + "type": "string" + } + }, + "required": ["outcome", "type"], + "title": "RealtimeSessionClosedThreadRealtimeItem", + "type": "object" + } + ], + "properties": { + "id": { + "type": "string" + }, + "realtimeSessionId": { + "type": "string" + } + }, + "required": ["id", "realtimeSessionId"], + "type": "object" + }, + "ThreadRealtimeItemAddedNotification": { + "description": "EXPERIMENTAL - raw non-audio thread realtime item emitted by the backend.", + "properties": { + "item": true, + "threadId": { + "type": "string" + } + }, + "required": ["item", "threadId"], + "type": "object" + }, + "ThreadRealtimeItemCompletedNotification": { + "description": "EXPERIMENTAL - a realtime timeline item published after canonical commit.", + "properties": { + "item": { + "$ref": "#/definitions/ThreadRealtimeItem" + }, + "threadId": { + "type": "string" + } + }, + "required": ["item", "threadId"], + "type": "object" + }, + "ThreadRealtimeItemStartedNotification": { + "description": "EXPERIMENTAL - a realtime timeline item started before its content streams.", + "properties": { + "item": { + "$ref": "#/definitions/ThreadRealtimeItem" + }, + "threadId": { + "type": "string" + } + }, + "required": ["item", "threadId"], + "type": "object" + }, + "ThreadRealtimeItemTranscriptDeltaNotification": { + "description": "EXPERIMENTAL - text appended to an active realtime transcript item.", + "properties": { + "delta": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": ["delta", "itemId", "threadId"], + "type": "object" + }, + "ThreadRealtimeOutputAudioDeltaNotification": { + "description": "EXPERIMENTAL - streamed output audio emitted by thread realtime.", + "properties": { + "audio": { + "$ref": "#/definitions/ThreadRealtimeAudioChunk" + }, + "threadId": { + "type": "string" + } + }, + "required": ["audio", "threadId"], + "type": "object" + }, + "ThreadRealtimeSdpNotification": { + "description": "EXPERIMENTAL - emitted with the remote SDP for a WebRTC realtime session.", + "properties": { + "sdp": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": ["sdp", "threadId"], + "type": "object" + }, + "ThreadRealtimeSessionOutcome": { + "enum": ["ended", "failed"], + "type": "string" + }, + "ThreadRealtimeStartedNotification": { + "description": "EXPERIMENTAL - emitted when thread realtime startup is accepted.", + "properties": { + "realtimeSessionId": { + "type": ["string", "null"] + }, + "threadId": { + "type": "string" + }, + "version": { + "$ref": "#/definitions/RealtimeConversationVersion" + } + }, + "required": ["threadId", "version"], + "type": "object" + }, + "ThreadRealtimeTranscriptDeltaNotification": { + "description": "EXPERIMENTAL - flat transcript delta emitted whenever realtime transcript text changes.", + "properties": { + "delta": { + "description": "Live transcript delta from the realtime event.", + "type": "string" + }, + "role": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": ["delta", "role", "threadId"], + "type": "object" + }, + "ThreadRealtimeTranscriptDoneNotification": { + "description": "EXPERIMENTAL - final transcript text emitted when realtime completes a transcript part.", + "properties": { + "role": { + "type": "string" + }, + "text": { + "description": "Final complete text for the transcript part.", + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": ["role", "text", "threadId"], + "type": "object" + }, + "ThreadRealtimeTranscriptRole": { + "enum": ["user", "assistant"], + "type": "string" + }, + "ThreadRevertedNotification": { + "properties": { + "threadId": { + "type": "string" + } + }, + "required": ["threadId"], + "type": "object" + }, + "ThreadSection": { + "description": "An independently persisted, user-visible thread section.", + "properties": { + "appearance": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSectionAppearance" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional appearance synchronized across clients." + }, + "id": { + "description": "Opaque UUIDv7 identity that remains stable when the section is renamed.", + "type": "string" + }, + "name": { + "description": "The current user-visible section name.", + "type": "string" + } + }, + "required": ["id", "name"], + "type": "object" + }, + "ThreadSectionAppearance": { + "description": "Extensible visual presentation for a custom thread section.", + "properties": { + "color": { + "type": ["string", "null"] + }, + "icon": { + "type": ["string", "null"] + } + }, + "type": "object" + }, + "ThreadSettings": { + "properties": { + "activePermissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/ActivePermissionProfile" + }, + { + "type": "null" + } + ] + }, + "approvalPolicy": { + "$ref": "#/definitions/AskForApproval" + }, + "approvalsReviewer": { + "$ref": "#/definitions/ApprovalsReviewer" + }, + "collaborationMode": { + "$ref": "#/definitions/CollaborationMode" + }, + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "effort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "model": { + "type": "string" + }, + "modelProvider": { + "type": "string" + }, + "multiAgentMode": { + "allOf": [ + { + "$ref": "#/definitions/MultiAgentMode" + } + ], + "default": "explicitRequestOnly", + "description": "@deprecated Always `explicitRequestOnly`. Use `effort` for Ultra behavior." + }, + "personality": { + "anyOf": [ + { + "$ref": "#/definitions/Personality" + }, + { + "type": "null" + } + ] + }, + "sandboxPolicy": { + "$ref": "#/definitions/SandboxPolicy" + }, + "serviceTier": { + "type": ["string", "null"] + }, + "summary": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningSummary" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "approvalPolicy", + "approvalsReviewer", + "collaborationMode", + "cwd", + "model", + "modelProvider", + "sandboxPolicy" + ], + "type": "object" + }, + "ThreadSettingsUpdatedNotification": { + "properties": { + "threadId": { + "type": "string" + }, + "threadSettings": { + "$ref": "#/definitions/ThreadSettings" + } + }, + "required": ["threadId", "threadSettings"], + "type": "object" + }, + "ThreadSource": { + "type": "string" + }, + "ThreadStartedNotification": { + "properties": { + "thread": { + "$ref": "#/definitions/Thread" + } + }, + "required": ["thread"], + "type": "object" + }, + "ThreadStatus": { + "oneOf": [ + { + "properties": { + "type": { + "enum": ["notLoaded"], + "title": "NotLoadedThreadStatusType", + "type": "string" + } + }, + "required": ["type"], + "title": "NotLoadedThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": ["idle"], + "title": "IdleThreadStatusType", + "type": "string" + } + }, + "required": ["type"], + "title": "IdleThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": ["systemError"], + "title": "SystemErrorThreadStatusType", + "type": "string" + } + }, + "required": ["type"], + "title": "SystemErrorThreadStatus", + "type": "object" + }, + { + "properties": { + "activeFlags": { + "items": { + "$ref": "#/definitions/ThreadActiveFlag" + }, + "type": "array" + }, + "type": { + "enum": ["active"], + "title": "ActiveThreadStatusType", + "type": "string" + } + }, + "required": ["activeFlags", "type"], + "title": "ActiveThreadStatus", + "type": "object" + } + ] + }, + "ThreadStatusChangedNotification": { + "properties": { + "status": { + "$ref": "#/definitions/ThreadStatus" + }, + "threadId": { + "type": "string" + } + }, + "required": ["status", "threadId"], + "type": "object" + }, + "ThreadTokenUsage": { + "properties": { + "last": { + "$ref": "#/definitions/TokenUsageBreakdown" + }, + "modelContextWindow": { + "format": "int64", + "type": ["integer", "null"] + }, + "total": { + "$ref": "#/definitions/TokenUsageBreakdown" + } + }, + "required": ["last", "total"], + "type": "object" + }, + "ThreadTokenUsageUpdatedNotification": { + "properties": { + "threadId": { + "type": "string" + }, + "tokenUsage": { + "$ref": "#/definitions/ThreadTokenUsage" + }, + "turnId": { + "type": "string" + } + }, + "required": ["threadId", "tokenUsage", "turnId"], + "type": "object" + }, + "ThreadUnarchivedNotification": { + "properties": { + "threadId": { + "type": "string" + } + }, + "required": ["threadId"], + "type": "object" + }, + "TokenUsageBreakdown": { + "properties": { + "cacheWriteInputTokens": { + "default": 0, + "format": "int64", + "type": "integer" + }, + "cachedInputTokens": { + "format": "int64", + "type": "integer" + }, + "inputTokens": { + "format": "int64", + "type": "integer" + }, + "outputTokens": { + "format": "int64", + "type": "integer" + }, + "reasoningOutputTokens": { + "format": "int64", + "type": "integer" + }, + "totalTokens": { + "format": "int64", + "type": "integer" + } + }, + "required": [ + "cachedInputTokens", + "inputTokens", + "outputTokens", + "reasoningOutputTokens", + "totalTokens" + ], + "type": "object" + }, + "Turn": { + "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": ["integer", "null"] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": ["integer", "null"] + }, + "error": { + "anyOf": [ + { + "$ref": "#/definitions/TurnError" + }, + { + "type": "null" + } + ], + "description": "Only populated when the Turn's status is failed." + }, + "id": { + "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", + "type": "string" + }, + "items": { + "description": "Thread items currently included in this turn payload.", + "items": { + "$ref": "#/definitions/ThreadItem" + }, + "type": "array" + }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": ["integer", "null"] + }, + "status": { + "$ref": "#/definitions/TurnStatus" + } + }, + "required": ["id", "items", "status"], + "type": "object" + }, + "TurnCompletedNotification": { + "properties": { + "threadId": { + "type": "string" + }, + "turn": { + "$ref": "#/definitions/Turn" + } + }, + "required": ["threadId", "turn"], + "type": "object" + }, + "TurnDiffUpdatedNotification": { + "description": "Notification that the turn-level unified diff has changed. Contains the latest aggregated diff across all file changes in the turn.", + "properties": { + "diff": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": ["diff", "threadId", "turnId"], + "type": "object" + }, + "TurnError": { + "properties": { + "additionalDetails": { + "default": null, + "type": ["string", "null"] + }, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/definitions/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + }, + "misalignment": { + "anyOf": [ + { + "$ref": "#/definitions/MisalignmentErrorDetails" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional public explanation and continuation instruction for a misalignment block." + } + }, + "required": ["message"], + "type": "object" + }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": ["notLoaded"], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": ["summary"], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": ["full"], + "type": "string" + } + ] + }, + "TurnModerationMetadataNotification": { + "properties": { + "metadata": true, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": ["metadata", "threadId", "turnId"], + "type": "object" + }, + "TurnPlanStep": { + "properties": { + "status": { + "$ref": "#/definitions/TurnPlanStepStatus" + }, + "step": { + "type": "string" + } + }, + "required": ["status", "step"], + "type": "object" + }, + "TurnPlanStepStatus": { + "enum": ["pending", "inProgress", "completed"], + "type": "string" + }, + "TurnPlanUpdatedNotification": { + "properties": { + "explanation": { + "type": ["string", "null"] + }, + "plan": { + "items": { + "$ref": "#/definitions/TurnPlanStep" + }, + "type": "array" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": ["plan", "threadId", "turnId"], + "type": "object" + }, + "TurnStartedNotification": { + "properties": { + "threadId": { + "type": "string" + }, + "turn": { + "$ref": "#/definitions/Turn" + } + }, + "required": ["threadId", "turn"], + "type": "object" + }, + "TurnStatus": { + "enum": ["completed", "interrupted", "failed", "inProgress"], + "type": "string" + }, + "UserInput": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "text_elements": { + "default": [], + "description": "UI-defined spans within `text` used to render or persist special elements.", + "items": { + "$ref": "#/definitions/TextElement" + }, + "type": "array" + }, + "type": { + "enum": ["text"], + "title": "TextUserInputType", + "type": "string" + } + }, + "required": ["text", "type"], + "title": "TextUserInput", + "type": "object" + }, + { + "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, + "type": { + "enum": ["image"], + "title": "ImageUserInputType", + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["type", "url"], + "title": "ImageUserInput", + "type": "object" + }, + { + "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, + "path": { + "type": "string" + }, + "type": { + "enum": ["localImage"], + "title": "LocalImageUserInputType", + "type": "string" + } + }, + "required": ["path", "type"], + "title": "LocalImageUserInput", + "type": "object" + }, + { + "properties": { + "type": { + "enum": ["audio"], + "title": "AudioUserInputType", + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["type", "url"], + "title": "AudioUserInput", + "type": "object" + }, + { + "properties": { + "path": { + "type": "string" + }, + "type": { + "enum": ["localAudio"], + "title": "LocalAudioUserInputType", + "type": "string" + } + }, + "required": ["path", "type"], + "title": "LocalAudioUserInput", + "type": "object" + }, + { + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": ["skill"], + "title": "SkillUserInputType", + "type": "string" + } + }, + "required": ["name", "path", "type"], + "title": "SkillUserInput", + "type": "object" + }, + { + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": ["mention"], + "title": "MentionUserInputType", + "type": "string" + } + }, + "required": ["name", "path", "type"], + "title": "MentionUserInput", + "type": "object" + } + ] + }, + "WarningNotification": { + "properties": { + "message": { + "description": "Concise warning message for the user.", + "type": "string" + }, + "threadId": { + "description": "Optional thread target when the warning applies to a specific thread.", + "type": ["string", "null"] + } + }, + "required": ["message"], + "type": "object" + }, + "WebSearchAction": { + "oneOf": [ + { + "properties": { + "queries": { + "items": { + "type": "string" + }, + "type": ["array", "null"] + }, + "query": { + "type": ["string", "null"] + }, + "type": { + "enum": ["search"], + "title": "SearchWebSearchActionType", + "type": "string" + } + }, + "required": ["type"], + "title": "SearchWebSearchAction", + "type": "object" + }, + { + "properties": { + "type": { + "enum": ["openPage"], + "title": "OpenPageWebSearchActionType", + "type": "string" + }, + "url": { + "type": ["string", "null"] + } + }, + "required": ["type"], + "title": "OpenPageWebSearchAction", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": ["string", "null"] + }, + "type": { + "enum": ["findInPage"], + "title": "FindInPageWebSearchActionType", + "type": "string" + }, + "url": { + "type": ["string", "null"] + } + }, + "required": ["type"], + "title": "FindInPageWebSearchAction", + "type": "object" + }, + { + "properties": { + "type": { + "enum": ["other"], + "title": "OtherWebSearchActionType", + "type": "string" + } + }, + "required": ["type"], + "title": "OtherWebSearchAction", + "type": "object" + } + ] + }, + "WindowsSandboxSetupCompletedNotification": { + "properties": { + "error": { + "type": ["string", "null"] + }, + "mode": { + "$ref": "#/definitions/WindowsSandboxSetupMode" + }, + "success": { + "type": "boolean" + } + }, + "required": ["mode", "success"], + "type": "object" + }, + "WindowsSandboxSetupMode": { + "enum": ["elevated", "unelevated"], + "type": "string" + }, + "WindowsWorldWritableWarningNotification": { + "properties": { + "extraCount": { + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "failedScan": { + "type": "boolean" + }, + "samplePaths": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": ["extraCount", "failedScan", "samplePaths"], + "type": "object" + } + }, + "description": "Notification sent from the server to the client.", + "oneOf": [ + { + "description": "NEW NOTIFICATIONS", + "properties": { + "method": { + "enum": ["error"], + "title": "ErrorNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ErrorNotification" + } + }, + "required": ["method", "params"], + "title": "ErrorNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["thread/started"], + "title": "Thread/startedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadStartedNotification" + } + }, + "required": ["method", "params"], + "title": "Thread/startedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["thread/status/changed"], + "title": "Thread/status/changedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadStatusChangedNotification" + } + }, + "required": ["method", "params"], + "title": "Thread/status/changedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["thread/archived"], + "title": "Thread/archivedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadArchivedNotification" + } + }, + "required": ["method", "params"], + "title": "Thread/archivedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["thread/deleted"], + "title": "Thread/deletedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadDeletedNotification" + } + }, + "required": ["method", "params"], + "title": "Thread/deletedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["thread/unarchived"], + "title": "Thread/unarchivedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadUnarchivedNotification" + } + }, + "required": ["method", "params"], + "title": "Thread/unarchivedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["thread/closed"], + "title": "Thread/closedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadClosedNotification" + } + }, + "required": ["method", "params"], + "title": "Thread/closedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["thread/reverted"], + "title": "Thread/revertedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRevertedNotification" + } + }, + "required": ["method", "params"], + "title": "Thread/revertedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["skills/changed"], + "title": "Skills/changedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/SkillsChangedNotification" + } + }, + "required": ["method", "params"], + "title": "Skills/changedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["thread/name/updated"], + "title": "Thread/name/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadNameUpdatedNotification" + } + }, + "required": ["method", "params"], + "title": "Thread/name/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["thread/goal/updated"], + "title": "Thread/goal/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadGoalUpdatedNotification" + } + }, + "required": ["method", "params"], + "title": "Thread/goal/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["thread/goal/cleared"], + "title": "Thread/goal/clearedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadGoalClearedNotification" + } + }, + "required": ["method", "params"], + "title": "Thread/goal/clearedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["thread/queue/changed"], + "title": "Thread/queue/changedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadQueueChangedNotification" + } + }, + "required": ["method", "params"], + "title": "Thread/queue/changedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["project/changed"], + "title": "Project/changedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ProjectChangedNotification" + } + }, + "required": ["method", "params"], + "title": "Project/changedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["thread/project/updated"], + "title": "Thread/project/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadProjectUpdatedNotification" + } + }, + "required": ["method", "params"], + "title": "Thread/project/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["thread/environment/connected"], + "title": "Thread/environment/connectedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/EnvironmentConnectionNotification" + } + }, + "required": ["method", "params"], + "title": "Thread/environment/connectedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["thread/environment/disconnected"], + "title": "Thread/environment/disconnectedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/EnvironmentConnectionNotification" + } + }, + "required": ["method", "params"], + "title": "Thread/environment/disconnectedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["thread/settings/updated"], + "title": "Thread/settings/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadSettingsUpdatedNotification" + } + }, + "required": ["method", "params"], + "title": "Thread/settings/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["thread/tokenUsage/updated"], + "title": "Thread/tokenUsage/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadTokenUsageUpdatedNotification" + } + }, + "required": ["method", "params"], + "title": "Thread/tokenUsage/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["turn/started"], + "title": "Turn/startedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnStartedNotification" + } + }, + "required": ["method", "params"], + "title": "Turn/startedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["hook/started"], + "title": "Hook/startedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/HookStartedNotification" + } + }, + "required": ["method", "params"], + "title": "Hook/startedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["turn/completed"], + "title": "Turn/completedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnCompletedNotification" + } + }, + "required": ["method", "params"], + "title": "Turn/completedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["hook/completed"], + "title": "Hook/completedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/HookCompletedNotification" + } + }, + "required": ["method", "params"], + "title": "Hook/completedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["turn/diff/updated"], + "title": "Turn/diff/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnDiffUpdatedNotification" + } + }, + "required": ["method", "params"], + "title": "Turn/diff/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["turn/plan/updated"], + "title": "Turn/plan/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnPlanUpdatedNotification" + } + }, + "required": ["method", "params"], + "title": "Turn/plan/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["item/started"], + "title": "Item/startedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ItemStartedNotification" + } + }, + "required": ["method", "params"], + "title": "Item/startedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["item/autoApprovalReview/started"], + "title": "Item/autoApprovalReview/startedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ItemGuardianApprovalReviewStartedNotification" + } + }, + "required": ["method", "params"], + "title": "Item/autoApprovalReview/startedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["item/autoApprovalReview/completed"], + "title": "Item/autoApprovalReview/completedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ItemGuardianApprovalReviewCompletedNotification" + } + }, + "required": ["method", "params"], + "title": "Item/autoApprovalReview/completedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["autoApprovalReview/strictReviewRequired"], + "title": "AutoApprovalReview/strictReviewRequiredNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/StrictReviewRequiredNotification" + } + }, + "required": ["method", "params"], + "title": "AutoApprovalReview/strictReviewRequiredNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["item/completed"], + "title": "Item/completedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ItemCompletedNotification" + } + }, + "required": ["method", "params"], + "title": "Item/completedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["item/agentMessage/delta"], + "title": "Item/agentMessage/deltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AgentMessageDeltaNotification" + } + }, + "required": ["method", "params"], + "title": "Item/agentMessage/deltaNotification", + "type": "object" + }, + { + "description": "EXPERIMENTAL - proposed plan streaming deltas for plan items.", + "properties": { + "method": { + "enum": ["item/plan/delta"], + "title": "Item/plan/deltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PlanDeltaNotification" + } + }, + "required": ["method", "params"], + "title": "Item/plan/deltaNotification", + "type": "object" + }, + { + "description": "Stream base64-encoded stdout/stderr chunks for a running `command/exec` session.", + "properties": { + "method": { + "enum": ["command/exec/outputDelta"], + "title": "Command/exec/outputDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CommandExecOutputDeltaNotification" + } + }, + "required": ["method", "params"], + "title": "Command/exec/outputDeltaNotification", + "type": "object" + }, + { + "description": "Stream base64-encoded stdout/stderr chunks for a running `process/spawn` session.", + "properties": { + "method": { + "enum": ["process/outputDelta"], + "title": "Process/outputDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ProcessOutputDeltaNotification" + } + }, + "required": ["method", "params"], + "title": "Process/outputDeltaNotification", + "type": "object" + }, + { + "description": "Final exit notification for a `process/spawn` session.", + "properties": { + "method": { + "enum": ["process/exited"], + "title": "Process/exitedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ProcessExitedNotification" + } + }, + "required": ["method", "params"], + "title": "Process/exitedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["item/commandExecution/outputDelta"], + "title": "Item/commandExecution/outputDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CommandExecutionOutputDeltaNotification" + } + }, + "required": ["method", "params"], + "title": "Item/commandExecution/outputDeltaNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["item/commandExecution/terminalInteraction"], + "title": "Item/commandExecution/terminalInteractionNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TerminalInteractionNotification" + } + }, + "required": ["method", "params"], + "title": "Item/commandExecution/terminalInteractionNotification", + "type": "object" + }, + { + "description": "Deprecated legacy apply_patch output stream notification.", + "properties": { + "method": { + "enum": ["item/fileChange/outputDelta"], + "title": "Item/fileChange/outputDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FileChangeOutputDeltaNotification" + } + }, + "required": ["method", "params"], + "title": "Item/fileChange/outputDeltaNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["item/fileChange/patchUpdated"], + "title": "Item/fileChange/patchUpdatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FileChangePatchUpdatedNotification" + } + }, + "required": ["method", "params"], + "title": "Item/fileChange/patchUpdatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["serverRequest/resolved"], + "title": "ServerRequest/resolvedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ServerRequestResolvedNotification" + } + }, + "required": ["method", "params"], + "title": "ServerRequest/resolvedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["item/mcpToolCall/progress"], + "title": "Item/mcpToolCall/progressNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/McpToolCallProgressNotification" + } + }, + "required": ["method", "params"], + "title": "Item/mcpToolCall/progressNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["mcpServer/oauthLogin/completed"], + "title": "McpServer/oauthLogin/completedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/McpServerOauthLoginCompletedNotification" + } + }, + "required": ["method", "params"], + "title": "McpServer/oauthLogin/completedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["mcpServer/startupStatus/updated"], + "title": "McpServer/startupStatus/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/McpServerStatusUpdatedNotification" + } + }, + "required": ["method", "params"], + "title": "McpServer/startupStatus/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["mcpServer/event/stream/notification"], + "title": "McpServer/event/stream/notificationNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/McpServerEventStreamNotification" + } + }, + "required": ["method", "params"], + "title": "McpServer/event/stream/notificationNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["account/updated"], + "title": "Account/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AccountUpdatedNotification" + } + }, + "required": ["method", "params"], + "title": "Account/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["account/rateLimits/updated"], + "title": "Account/rateLimits/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AccountRateLimitsUpdatedNotification" + } + }, + "required": ["method", "params"], + "title": "Account/rateLimits/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["app/list/updated"], + "title": "App/list/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AppListUpdatedNotification" + } + }, + "required": ["method", "params"], + "title": "App/list/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["remoteControl/status/changed"], + "title": "RemoteControl/status/changedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/RemoteControlStatusChangedNotification" + } + }, + "required": ["method", "params"], + "title": "RemoteControl/status/changedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["externalAgentConfig/import/progress"], + "title": "ExternalAgentConfig/import/progressNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ExternalAgentConfigImportProgressNotification" + } + }, + "required": ["method", "params"], + "title": "ExternalAgentConfig/import/progressNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["externalAgentConfig/import/completed"], + "title": "ExternalAgentConfig/import/completedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ExternalAgentConfigImportCompletedNotification" + } + }, + "required": ["method", "params"], + "title": "ExternalAgentConfig/import/completedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["fs/changed"], + "title": "Fs/changedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FsChangedNotification" + } + }, + "required": ["method", "params"], + "title": "Fs/changedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["item/reasoning/summaryTextDelta"], + "title": "Item/reasoning/summaryTextDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ReasoningSummaryTextDeltaNotification" + } + }, + "required": ["method", "params"], + "title": "Item/reasoning/summaryTextDeltaNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["item/reasoning/summaryPartAdded"], + "title": "Item/reasoning/summaryPartAddedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ReasoningSummaryPartAddedNotification" + } + }, + "required": ["method", "params"], + "title": "Item/reasoning/summaryPartAddedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["item/reasoning/textDelta"], + "title": "Item/reasoning/textDeltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ReasoningTextDeltaNotification" + } + }, + "required": ["method", "params"], + "title": "Item/reasoning/textDeltaNotification", + "type": "object" + }, + { + "description": "Deprecated: Use `ContextCompaction` item type instead.", + "properties": { + "method": { + "enum": ["thread/compacted"], + "title": "Thread/compactedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ContextCompactedNotification" + } + }, + "required": ["method", "params"], + "title": "Thread/compactedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["model/rerouted"], + "title": "Model/reroutedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ModelReroutedNotification" + } + }, + "required": ["method", "params"], + "title": "Model/reroutedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["model/verification"], + "title": "Model/verificationNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ModelVerificationNotification" + } + }, + "required": ["method", "params"], + "title": "Model/verificationNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["modelProvider/authRecoveryStarted"], + "title": "ModelProvider/authRecoveryStartedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AuthRecoveryNotification" + } + }, + "required": ["method", "params"], + "title": "ModelProvider/authRecoveryStartedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["modelProvider/authRecoveryCompleted"], + "title": "ModelProvider/authRecoveryCompletedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AuthRecoveryNotification" + } + }, + "required": ["method", "params"], + "title": "ModelProvider/authRecoveryCompletedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["turn/moderationMetadata"], + "title": "Turn/moderationMetadataNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/TurnModerationMetadataNotification" + } + }, + "required": ["method", "params"], + "title": "Turn/moderationMetadataNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["model/safetyBuffering/updated"], + "title": "Model/safetyBuffering/updatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ModelSafetyBufferingUpdatedNotification" + } + }, + "required": ["method", "params"], + "title": "Model/safetyBuffering/updatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["warning"], + "title": "WarningNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/WarningNotification" + } + }, + "required": ["method", "params"], + "title": "WarningNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["guardianWarning"], + "title": "GuardianWarningNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/GuardianWarningNotification" + } + }, + "required": ["method", "params"], + "title": "GuardianWarningNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["deprecationNotice"], + "title": "DeprecationNoticeNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/DeprecationNoticeNotification" + } + }, + "required": ["method", "params"], + "title": "DeprecationNoticeNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["configWarning"], + "title": "ConfigWarningNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ConfigWarningNotification" + } + }, + "required": ["method", "params"], + "title": "ConfigWarningNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["fuzzyFileSearch/sessionUpdated"], + "title": "FuzzyFileSearch/sessionUpdatedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FuzzyFileSearchSessionUpdatedNotification" + } + }, + "required": ["method", "params"], + "title": "FuzzyFileSearch/sessionUpdatedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["fuzzyFileSearch/sessionCompleted"], + "title": "FuzzyFileSearch/sessionCompletedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FuzzyFileSearchSessionCompletedNotification" + } + }, + "required": ["method", "params"], + "title": "FuzzyFileSearch/sessionCompletedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["thread/realtime/started"], + "title": "Thread/realtime/startedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRealtimeStartedNotification" + } + }, + "required": ["method", "params"], + "title": "Thread/realtime/startedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["thread/realtime/itemAdded"], + "title": "Thread/realtime/itemAddedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRealtimeItemAddedNotification" + } + }, + "required": ["method", "params"], + "title": "Thread/realtime/itemAddedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["thread/realtime/item/started"], + "title": "Thread/realtime/item/startedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRealtimeItemStartedNotification" + } + }, + "required": ["method", "params"], + "title": "Thread/realtime/item/startedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["thread/realtime/item/transcript/delta"], + "title": "Thread/realtime/item/transcript/deltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRealtimeItemTranscriptDeltaNotification" + } + }, + "required": ["method", "params"], + "title": "Thread/realtime/item/transcript/deltaNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["thread/realtime/item/completed"], + "title": "Thread/realtime/item/completedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRealtimeItemCompletedNotification" + } + }, + "required": ["method", "params"], + "title": "Thread/realtime/item/completedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["thread/realtime/transcript/delta"], + "title": "Thread/realtime/transcript/deltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRealtimeTranscriptDeltaNotification" + } + }, + "required": ["method", "params"], + "title": "Thread/realtime/transcript/deltaNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["thread/realtime/transcript/done"], + "title": "Thread/realtime/transcript/doneNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRealtimeTranscriptDoneNotification" + } + }, + "required": ["method", "params"], + "title": "Thread/realtime/transcript/doneNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["thread/realtime/outputAudio/delta"], + "title": "Thread/realtime/outputAudio/deltaNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRealtimeOutputAudioDeltaNotification" + } + }, + "required": ["method", "params"], + "title": "Thread/realtime/outputAudio/deltaNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["thread/realtime/sdp"], + "title": "Thread/realtime/sdpNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRealtimeSdpNotification" + } + }, + "required": ["method", "params"], + "title": "Thread/realtime/sdpNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["thread/realtime/error"], + "title": "Thread/realtime/errorNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRealtimeErrorNotification" + } + }, + "required": ["method", "params"], + "title": "Thread/realtime/errorNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["thread/realtime/closed"], + "title": "Thread/realtime/closedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ThreadRealtimeClosedNotification" + } + }, + "required": ["method", "params"], + "title": "Thread/realtime/closedNotification", + "type": "object" + }, + { + "description": "Notifies the user of world-writable directories on Windows, which cannot be protected by the sandbox.", + "properties": { + "method": { + "enum": ["windows/worldWritableWarning"], + "title": "Windows/worldWritableWarningNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/WindowsWorldWritableWarningNotification" + } + }, + "required": ["method", "params"], + "title": "Windows/worldWritableWarningNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["windowsSandbox/setupCompleted"], + "title": "WindowsSandbox/setupCompletedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/WindowsSandboxSetupCompletedNotification" + } + }, + "required": ["method", "params"], + "title": "WindowsSandbox/setupCompletedNotification", + "type": "object" + }, + { + "properties": { + "method": { + "enum": ["account/login/completed"], + "title": "Account/login/completedNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AccountLoginCompletedNotification" + } + }, + "required": ["method", "params"], + "title": "Account/login/completedNotification", + "type": "object" + } + ], + "properties": { + "emittedAtMs": { + "description": "Unix timestamp (in milliseconds) when app-server emitted this notification.", + "format": "int64", + "type": "integer" + } + }, + "title": "ServerNotification" +} diff --git a/src/runtimes/openai/generated-json-schema/ServerRequest.json b/src/runtimes/openai/generated-json-schema/ServerRequest.json new file mode 100644 index 0000000..0bf1d21 --- /dev/null +++ b/src/runtimes/openai/generated-json-schema/ServerRequest.json @@ -0,0 +1,1620 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "AdditionalFileSystemPermissions": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/FileSystemSandboxEntry" + }, + "type": ["array", "null"] + }, + "globScanMaxDepth": { + "format": "uint", + "minimum": 1.0, + "type": ["integer", "null"] + }, + "read": { + "description": "This will be removed in favor of `entries`.", + "items": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": ["array", "null"] + }, + "write": { + "description": "This will be removed in favor of `entries`.", + "items": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": ["array", "null"] + } + }, + "type": "object" + }, + "AdditionalNetworkPermissions": { + "properties": { + "enabled": { + "type": ["boolean", "null"] + } + }, + "type": "object" + }, + "AdditionalPermissionProfile": { + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalNetworkPermissions" + }, + { + "type": "null" + } + ], + "description": "Partial overlay used for per-command permission requests." + } + }, + "type": "object" + }, + "ApplyPatchApprovalParams": { + "properties": { + "callId": { + "description": "Use to correlate this with [codex_protocol::protocol::PatchApplyBeginEvent] and [codex_protocol::protocol::PatchApplyEndEvent].", + "type": "string" + }, + "conversationId": { + "$ref": "#/definitions/ThreadId" + }, + "fileChanges": { + "additionalProperties": { + "$ref": "#/definitions/FileChange" + }, + "type": "object" + }, + "grantRoot": { + "description": "When set, the agent is asking the user to allow writes under this root for the remainder of the session (unclear if this is honored today).", + "type": ["string", "null"] + }, + "reason": { + "description": "Optional explanatory reason (e.g. request for extra write access).", + "type": ["string", "null"] + } + }, + "required": ["callId", "conversationId", "fileChanges"], + "type": "object" + }, + "AttestationGenerateParams": { + "type": "object" + }, + "ChatgptAuthTokensRefreshParams": { + "properties": { + "previousAccountId": { + "description": "Workspace/account identifier that Codex was previously using.\n\nClients that manage multiple accounts/workspaces can use this as a hint to refresh the token for the correct workspace.\n\nThis may be `null` when the prior auth state did not include a workspace identifier (`chatgpt_account_id`).", + "type": ["string", "null"] + }, + "reason": { + "$ref": "#/definitions/ChatgptAuthTokensRefreshReason" + } + }, + "required": ["reason"], + "type": "object" + }, + "ChatgptAuthTokensRefreshReason": { + "oneOf": [ + { + "description": "Codex attempted a backend request and received `401 Unauthorized`.", + "enum": ["unauthorized"], + "type": "string" + } + ] + }, + "CommandAction": { + "oneOf": [ + { + "properties": { + "command": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": { + "enum": ["read"], + "title": "ReadCommandActionType", + "type": "string" + } + }, + "required": ["command", "name", "path", "type"], + "title": "ReadCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": ["string", "null"] + }, + "type": { + "enum": ["listFiles"], + "title": "ListFilesCommandActionType", + "type": "string" + } + }, + "required": ["command", "type"], + "title": "ListFilesCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": ["string", "null"] + }, + "query": { + "type": ["string", "null"] + }, + "type": { + "enum": ["search"], + "title": "SearchCommandActionType", + "type": "string" + } + }, + "required": ["command", "type"], + "title": "SearchCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "type": { + "enum": ["unknown"], + "title": "UnknownCommandActionType", + "type": "string" + } + }, + "required": ["command", "type"], + "title": "UnknownCommandAction", + "type": "object" + } + ] + }, + "CommandExecutionApprovalDecision": { + "oneOf": [ + { + "description": "User approved the command.", + "enum": ["accept"], + "type": "string" + }, + { + "description": "User approved the command and future prompts in the same session-scoped approval cache should run without prompting.", + "enum": ["acceptForSession"], + "type": "string" + }, + { + "additionalProperties": false, + "description": "User approved the command, and wants to apply the proposed execpolicy amendment so future matching commands can run without prompting.", + "properties": { + "acceptWithExecpolicyAmendment": { + "properties": { + "execpolicy_amendment": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": ["execpolicy_amendment"], + "type": "object" + } + }, + "required": ["acceptWithExecpolicyAmendment"], + "title": "AcceptWithExecpolicyAmendmentCommandExecutionApprovalDecision", + "type": "object" + }, + { + "additionalProperties": false, + "description": "User chose a persistent network policy rule (allow/deny) for this host.", + "properties": { + "applyNetworkPolicyAmendment": { + "properties": { + "network_policy_amendment": { + "$ref": "#/definitions/NetworkPolicyAmendment" + } + }, + "required": ["network_policy_amendment"], + "type": "object" + } + }, + "required": ["applyNetworkPolicyAmendment"], + "title": "ApplyNetworkPolicyAmendmentCommandExecutionApprovalDecision", + "type": "object" + }, + { + "description": "User denied the command. The agent will continue the turn.", + "enum": ["decline"], + "type": "string" + }, + { + "description": "User denied the command. The turn will also be immediately interrupted.", + "enum": ["cancel"], + "type": "string" + } + ] + }, + "CommandExecutionApprovalKind": { + "description": "Distinguishes a command approval from input sent to an existing terminal.", + "enum": ["command", "writeStdin"], + "type": "string" + }, + "CommandExecutionRequestApprovalParams": { + "properties": { + "additionalPermissions": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalPermissionProfile" + }, + { + "type": "null" + } + ], + "description": "Optional additional permissions requested for this command." + }, + "approvalId": { + "description": "Unique identifier for this specific approval callback.\n\nFor regular shell/unified_exec approvals, this is null.\n\nFor zsh-exec-bridge subcommand approvals, multiple callbacks can belong to one parent `itemId`, so `approvalId` is a distinct opaque callback id (a UUID) used to disambiguate routing. Stdin approvals also use a distinct callback id; inspect `kind` to distinguish them.", + "type": ["string", "null"] + }, + "availableDecisions": { + "description": "Ordered list of decisions the client may present for this prompt.", + "items": { + "$ref": "#/definitions/CommandExecutionApprovalDecision" + }, + "type": ["array", "null"] + }, + "command": { + "description": "The command to be executed.", + "type": ["string", "null"] + }, + "commandActions": { + "description": "Best-effort parsed command actions for friendly display.", + "items": { + "$ref": "#/definitions/CommandAction" + }, + "type": ["array", "null"] + }, + "cwd": { + "anyOf": [ + { + "$ref": "#/definitions/LegacyAppPathString" + }, + { + "type": "null" + } + ], + "description": "The command's working directory." + }, + "environmentId": { + "default": null, + "description": "Environment in which the command will run.", + "type": ["string", "null"] + }, + "itemId": { + "type": "string" + }, + "kind": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionApprovalKind" + } + ], + "default": "command", + "description": "Kind of action under review. Defaults to `command` for older servers." + }, + "networkApprovalContext": { + "anyOf": [ + { + "$ref": "#/definitions/NetworkApprovalContext" + }, + { + "type": "null" + } + ], + "description": "Optional context for a managed-network approval prompt." + }, + "proposedExecpolicyAmendment": { + "description": "Optional proposed execpolicy amendment to allow similar commands without prompting.", + "items": { + "type": "string" + }, + "type": ["array", "null"] + }, + "proposedNetworkPolicyAmendments": { + "description": "Optional proposed network policy amendments (allow/deny host) for future requests.", + "items": { + "$ref": "#/definitions/NetworkPolicyAmendment" + }, + "type": ["array", "null"] + }, + "reason": { + "description": "Optional explanatory reason (e.g. request for network access).", + "type": ["string", "null"] + }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": ["itemId", "startedAtMs", "threadId", "turnId"], + "type": "object" + }, + "CurrentTimeReadParams": { + "properties": { + "threadId": { + "type": "string" + } + }, + "required": ["threadId"], + "type": "object" + }, + "DynamicToolCallParams": { + "properties": { + "arguments": true, + "callId": { + "type": "string" + }, + "namespace": { + "type": ["string", "null"] + }, + "threadId": { + "type": "string" + }, + "tool": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": ["arguments", "callId", "threadId", "tool", "turnId"], + "type": "object" + }, + "ExecCommandApprovalParams": { + "properties": { + "approvalId": { + "description": "Identifier for this specific approval callback.", + "type": ["string", "null"] + }, + "callId": { + "description": "Use to correlate this with [codex_protocol::protocol::ExecCommandBeginEvent] and [codex_protocol::protocol::ExecCommandEndEvent].", + "type": "string" + }, + "command": { + "items": { + "type": "string" + }, + "type": "array" + }, + "conversationId": { + "$ref": "#/definitions/ThreadId" + }, + "cwd": { + "type": "string" + }, + "parsedCmd": { + "items": { + "$ref": "#/definitions/ParsedCommand" + }, + "type": "array" + }, + "reason": { + "type": ["string", "null"] + } + }, + "required": ["callId", "command", "conversationId", "cwd", "parsedCmd"], + "type": "object" + }, + "FileChange": { + "oneOf": [ + { + "properties": { + "content": { + "type": "string" + }, + "type": { + "enum": ["add"], + "title": "AddFileChangeType", + "type": "string" + } + }, + "required": ["content", "type"], + "title": "AddFileChange", + "type": "object" + }, + { + "properties": { + "content": { + "type": "string" + }, + "type": { + "enum": ["delete"], + "title": "DeleteFileChangeType", + "type": "string" + } + }, + "required": ["content", "type"], + "title": "DeleteFileChange", + "type": "object" + }, + { + "properties": { + "move_path": { + "type": ["string", "null"] + }, + "type": { + "enum": ["update"], + "title": "UpdateFileChangeType", + "type": "string" + }, + "unified_diff": { + "type": "string" + } + }, + "required": ["type", "unified_diff"], + "title": "UpdateFileChange", + "type": "object" + } + ] + }, + "FileChangeRequestApprovalParams": { + "properties": { + "grantRoot": { + "description": "[UNSTABLE] When set, the agent is asking the user to allow writes under this root for the remainder of the session (unclear if this is honored today).", + "type": ["string", "null"] + }, + "itemId": { + "type": "string" + }, + "reason": { + "description": "Optional explanatory reason (e.g. request for extra write access).", + "type": ["string", "null"] + }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": ["itemId", "startedAtMs", "threadId", "turnId"], + "type": "object" + }, + "FileSystemAccessMode": { + "enum": ["read", "write", "deny"], + "type": "string" + }, + "FileSystemPath": { + "oneOf": [ + { + "properties": { + "path": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": { + "enum": ["path"], + "title": "PathFileSystemPathType", + "type": "string" + } + }, + "required": ["path", "type"], + "title": "PathFileSystemPath", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": "string" + }, + "type": { + "enum": ["glob_pattern"], + "title": "GlobPatternFileSystemPathType", + "type": "string" + } + }, + "required": ["pattern", "type"], + "title": "GlobPatternFileSystemPath", + "type": "object" + }, + { + "properties": { + "type": { + "enum": ["special"], + "title": "SpecialFileSystemPathType", + "type": "string" + }, + "value": { + "$ref": "#/definitions/FileSystemSpecialPath" + } + }, + "required": ["type", "value"], + "title": "SpecialFileSystemPath", + "type": "object" + } + ] + }, + "FileSystemSandboxEntry": { + "properties": { + "access": { + "$ref": "#/definitions/FileSystemAccessMode" + }, + "path": { + "$ref": "#/definitions/FileSystemPath" + } + }, + "required": ["access", "path"], + "type": "object" + }, + "FileSystemSpecialPath": { + "oneOf": [ + { + "properties": { + "kind": { + "enum": ["root"], + "type": "string" + } + }, + "required": ["kind"], + "title": "RootFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": ["minimal"], + "type": "string" + } + }, + "required": ["kind"], + "title": "MinimalFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": ["project_roots"], + "type": "string" + }, + "subpath": { + "anyOf": [ + { + "$ref": "#/definitions/LegacyAppPathString" + }, + { + "type": "null" + } + ] + } + }, + "required": ["kind"], + "title": "KindFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": ["tmpdir"], + "type": "string" + } + }, + "required": ["kind"], + "title": "TmpdirFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": ["slash_tmp"], + "type": "string" + } + }, + "required": ["kind"], + "title": "SlashTmpFileSystemSpecialPath", + "type": "object" + }, + { + "properties": { + "kind": { + "enum": ["unknown"], + "type": "string" + }, + "path": { + "type": "string" + }, + "subpath": { + "anyOf": [ + { + "$ref": "#/definitions/LegacyAppPathString" + }, + { + "type": "null" + } + ] + } + }, + "required": ["kind", "path"], + "type": "object" + } + ] + }, + "LegacyAppPathString": { + "type": "string" + }, + "McpElicitationArrayType": { + "enum": ["array"], + "type": "string" + }, + "McpElicitationBooleanSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": ["boolean", "null"] + }, + "description": { + "type": ["string", "null"] + }, + "title": { + "type": ["string", "null"] + }, + "type": { + "$ref": "#/definitions/McpElicitationBooleanType" + } + }, + "required": ["type"], + "type": "object" + }, + "McpElicitationBooleanType": { + "enum": ["boolean"], + "type": "string" + }, + "McpElicitationConstOption": { + "additionalProperties": false, + "properties": { + "const": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": ["const", "title"], + "type": "object" + }, + "McpElicitationEnumSchema": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationSingleSelectEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationMultiSelectEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationLegacyTitledEnumSchema" + } + ] + }, + "McpElicitationLegacyTitledEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": ["string", "null"] + }, + "description": { + "type": ["string", "null"] + }, + "enum": { + "items": { + "type": "string" + }, + "type": "array" + }, + "enumNames": { + "items": { + "type": "string" + }, + "type": ["array", "null"] + }, + "title": { + "type": ["string", "null"] + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": ["enum", "type"], + "type": "object" + }, + "McpElicitationMultiSelectEnumSchema": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationUntitledMultiSelectEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationTitledMultiSelectEnumSchema" + } + ] + }, + "McpElicitationNumberSchema": { + "additionalProperties": false, + "properties": { + "default": { + "format": "double", + "type": ["number", "null"] + }, + "description": { + "type": ["string", "null"] + }, + "maximum": { + "format": "double", + "type": ["number", "null"] + }, + "minimum": { + "format": "double", + "type": ["number", "null"] + }, + "title": { + "type": ["string", "null"] + }, + "type": { + "$ref": "#/definitions/McpElicitationNumberType" + } + }, + "required": ["type"], + "type": "object" + }, + "McpElicitationNumberType": { + "enum": ["number", "integer"], + "type": "string" + }, + "McpElicitationObjectType": { + "enum": ["object"], + "type": "string" + }, + "McpElicitationPrimitiveSchema": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationStringSchema" + }, + { + "$ref": "#/definitions/McpElicitationNumberSchema" + }, + { + "$ref": "#/definitions/McpElicitationBooleanSchema" + } + ] + }, + "McpElicitationSchema": { + "additionalProperties": false, + "description": "Typed form schema for MCP `elicitation/create` requests.\n\nThis matches the `requestedSchema` shape from the MCP 2025-11-25 `ElicitRequestFormParams` schema.", + "properties": { + "$schema": { + "type": ["string", "null"] + }, + "properties": { + "additionalProperties": { + "$ref": "#/definitions/McpElicitationPrimitiveSchema" + }, + "type": "object" + }, + "required": { + "items": { + "type": "string" + }, + "type": ["array", "null"] + }, + "type": { + "$ref": "#/definitions/McpElicitationObjectType" + } + }, + "required": ["properties", "type"], + "type": "object" + }, + "McpElicitationSingleSelectEnumSchema": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationUntitledSingleSelectEnumSchema" + }, + { + "$ref": "#/definitions/McpElicitationTitledSingleSelectEnumSchema" + } + ] + }, + "McpElicitationStringFormat": { + "enum": ["email", "uri", "date", "date-time"], + "type": "string" + }, + "McpElicitationStringSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": ["string", "null"] + }, + "description": { + "type": ["string", "null"] + }, + "format": { + "anyOf": [ + { + "$ref": "#/definitions/McpElicitationStringFormat" + }, + { + "type": "null" + } + ] + }, + "maxLength": { + "format": "uint32", + "minimum": 0.0, + "type": ["integer", "null"] + }, + "minLength": { + "format": "uint32", + "minimum": 0.0, + "type": ["integer", "null"] + }, + "title": { + "type": ["string", "null"] + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": ["type"], + "type": "object" + }, + "McpElicitationStringType": { + "enum": ["string"], + "type": "string" + }, + "McpElicitationTitledEnumItems": { + "additionalProperties": false, + "properties": { + "anyOf": { + "items": { + "$ref": "#/definitions/McpElicitationConstOption" + }, + "type": "array" + } + }, + "required": ["anyOf"], + "type": "object" + }, + "McpElicitationTitledMultiSelectEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "items": { + "type": "string" + }, + "type": ["array", "null"] + }, + "description": { + "type": ["string", "null"] + }, + "items": { + "$ref": "#/definitions/McpElicitationTitledEnumItems" + }, + "maxItems": { + "format": "uint64", + "minimum": 0.0, + "type": ["integer", "null"] + }, + "minItems": { + "format": "uint64", + "minimum": 0.0, + "type": ["integer", "null"] + }, + "title": { + "type": ["string", "null"] + }, + "type": { + "$ref": "#/definitions/McpElicitationArrayType" + } + }, + "required": ["items", "type"], + "type": "object" + }, + "McpElicitationTitledSingleSelectEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": ["string", "null"] + }, + "description": { + "type": ["string", "null"] + }, + "oneOf": { + "items": { + "$ref": "#/definitions/McpElicitationConstOption" + }, + "type": "array" + }, + "title": { + "type": ["string", "null"] + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": ["oneOf", "type"], + "type": "object" + }, + "McpElicitationUntitledEnumItems": { + "additionalProperties": false, + "properties": { + "enum": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": ["enum", "type"], + "type": "object" + }, + "McpElicitationUntitledMultiSelectEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "items": { + "type": "string" + }, + "type": ["array", "null"] + }, + "description": { + "type": ["string", "null"] + }, + "items": { + "$ref": "#/definitions/McpElicitationUntitledEnumItems" + }, + "maxItems": { + "format": "uint64", + "minimum": 0.0, + "type": ["integer", "null"] + }, + "minItems": { + "format": "uint64", + "minimum": 0.0, + "type": ["integer", "null"] + }, + "title": { + "type": ["string", "null"] + }, + "type": { + "$ref": "#/definitions/McpElicitationArrayType" + } + }, + "required": ["items", "type"], + "type": "object" + }, + "McpElicitationUntitledSingleSelectEnumSchema": { + "additionalProperties": false, + "properties": { + "default": { + "type": ["string", "null"] + }, + "description": { + "type": ["string", "null"] + }, + "enum": { + "items": { + "type": "string" + }, + "type": "array" + }, + "title": { + "type": ["string", "null"] + }, + "type": { + "$ref": "#/definitions/McpElicitationStringType" + } + }, + "required": ["enum", "type"], + "type": "object" + }, + "McpServerElicitationRequestParams": { + "oneOf": [ + { + "properties": { + "_meta": true, + "message": { + "type": "string" + }, + "mode": { + "enum": ["form"], + "type": "string" + }, + "requestedSchema": { + "$ref": "#/definitions/McpElicitationSchema" + } + }, + "required": ["message", "mode", "requestedSchema"], + "type": "object" + }, + { + "properties": { + "_meta": true, + "message": { + "type": "string" + }, + "mode": { + "enum": ["openai/form"], + "type": "string" + }, + "requestedSchema": true + }, + "required": ["message", "mode", "requestedSchema"], + "type": "object" + }, + { + "properties": { + "_meta": true, + "message": { + "type": "string" + }, + "mode": { + "enum": ["openaiForm"], + "type": "string" + }, + "requestedSchema": true + }, + "required": ["message", "mode", "requestedSchema"], + "type": "object" + }, + { + "properties": { + "_meta": true, + "elicitationId": { + "type": "string" + }, + "message": { + "type": "string" + }, + "mode": { + "enum": ["url"], + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["elicitationId", "message", "mode", "url"], + "type": "object" + } + ], + "properties": { + "serverName": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "description": "Active Codex turn when this elicitation was observed, if app-server could correlate one.\n\nThis is nullable because MCP models elicitation as a standalone server-to-client request identified by the MCP server request id. It may be triggered during a turn, but turn context is app-server correlation rather than part of the protocol identity of the elicitation itself.", + "type": ["string", "null"] + } + }, + "required": ["serverName", "threadId"], + "type": "object" + }, + "NetworkApprovalContext": { + "properties": { + "host": { + "type": "string" + }, + "protocol": { + "$ref": "#/definitions/NetworkApprovalProtocol" + } + }, + "required": ["host", "protocol"], + "type": "object" + }, + "NetworkApprovalProtocol": { + "enum": ["http", "https", "socks5Tcp", "socks5Udp"], + "type": "string" + }, + "NetworkPolicyAmendment": { + "properties": { + "action": { + "$ref": "#/definitions/NetworkPolicyRuleAction" + }, + "host": { + "type": "string" + } + }, + "required": ["action", "host"], + "type": "object" + }, + "NetworkPolicyRuleAction": { + "enum": ["allow", "deny"], + "type": "string" + }, + "ParsedCommand": { + "oneOf": [ + { + "properties": { + "cmd": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "description": "(Best effort) Path to the file being read by the command. When possible, this is an absolute path, though when relative, it should be resolved against the `cwd`` that will be used to run the command to derive the absolute path.", + "type": "string" + }, + "type": { + "enum": ["read"], + "title": "ReadParsedCommandType", + "type": "string" + } + }, + "required": ["cmd", "name", "path", "type"], + "title": "ReadParsedCommand", + "type": "object" + }, + { + "properties": { + "cmd": { + "type": "string" + }, + "path": { + "type": ["string", "null"] + }, + "type": { + "enum": ["list_files"], + "title": "ListFilesParsedCommandType", + "type": "string" + } + }, + "required": ["cmd", "type"], + "title": "ListFilesParsedCommand", + "type": "object" + }, + { + "properties": { + "cmd": { + "type": "string" + }, + "path": { + "type": ["string", "null"] + }, + "query": { + "type": ["string", "null"] + }, + "type": { + "enum": ["search"], + "title": "SearchParsedCommandType", + "type": "string" + } + }, + "required": ["cmd", "type"], + "title": "SearchParsedCommand", + "type": "object" + }, + { + "properties": { + "cmd": { + "type": "string" + }, + "type": { + "enum": ["unknown"], + "title": "UnknownParsedCommandType", + "type": "string" + } + }, + "required": ["cmd", "type"], + "title": "UnknownParsedCommand", + "type": "object" + } + ] + }, + "PermissionsRequestApprovalParams": { + "properties": { + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "environmentId": { + "default": null, + "type": ["string", "null"] + }, + "itemId": { + "type": "string" + }, + "permissions": { + "$ref": "#/definitions/RequestPermissionProfile" + }, + "reason": { + "type": ["string", "null"] + }, + "startedAtMs": { + "description": "Unix timestamp (in milliseconds) when this approval request started.", + "format": "int64", + "type": "integer" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": ["cwd", "itemId", "permissions", "startedAtMs", "threadId", "turnId"], + "type": "object" + }, + "RequestId": { + "anyOf": [ + { + "type": "string" + }, + { + "format": "int64", + "type": "integer" + } + ] + }, + "RequestPermissionProfile": { + "additionalProperties": false, + "properties": { + "fileSystem": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalFileSystemPermissions" + }, + { + "type": "null" + } + ] + }, + "network": { + "anyOf": [ + { + "$ref": "#/definitions/AdditionalNetworkPermissions" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "ThreadId": { + "type": "string" + }, + "ToolRequestUserInputOption": { + "description": "EXPERIMENTAL. Defines a single selectable option for request_user_input.", + "properties": { + "description": { + "type": "string" + }, + "label": { + "type": "string" + } + }, + "required": ["description", "label"], + "type": "object" + }, + "ToolRequestUserInputParams": { + "description": "EXPERIMENTAL. Params sent with a request_user_input event.", + "properties": { + "autoResolutionMs": { + "default": null, + "description": "@deprecated Use `isBlocking` to decide whether the request should block.", + "format": "uint64", + "minimum": 0.0, + "type": ["integer", "null"] + }, + "isBlocking": { + "type": "boolean" + }, + "itemId": { + "type": "string" + }, + "questions": { + "items": { + "$ref": "#/definitions/ToolRequestUserInputQuestion" + }, + "type": "array" + }, + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + } + }, + "required": ["isBlocking", "itemId", "questions", "threadId", "turnId"], + "type": "object" + }, + "ToolRequestUserInputQuestion": { + "description": "EXPERIMENTAL. Represents one request_user_input question and its required options.", + "properties": { + "header": { + "type": "string" + }, + "id": { + "type": "string" + }, + "isOther": { + "default": false, + "type": "boolean" + }, + "isSecret": { + "default": false, + "type": "boolean" + }, + "options": { + "items": { + "$ref": "#/definitions/ToolRequestUserInputOption" + }, + "type": ["array", "null"] + }, + "question": { + "type": "string" + } + }, + "required": ["header", "id", "question"], + "type": "object" + } + }, + "description": "Request initiated from the server and sent to the client.", + "oneOf": [ + { + "description": "NEW APIs Sent when approval is requested for a specific command execution. This request is used for Turns started via turn/start.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": ["item/commandExecution/requestApproval"], + "title": "Item/commandExecution/requestApprovalRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CommandExecutionRequestApprovalParams" + } + }, + "required": ["id", "method", "params"], + "title": "Item/commandExecution/requestApprovalRequest", + "type": "object" + }, + { + "description": "Sent when approval is requested for a specific file change. This request is used for Turns started via turn/start.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": ["item/fileChange/requestApproval"], + "title": "Item/fileChange/requestApprovalRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/FileChangeRequestApprovalParams" + } + }, + "required": ["id", "method", "params"], + "title": "Item/fileChange/requestApprovalRequest", + "type": "object" + }, + { + "description": "EXPERIMENTAL - Request input from the user for a tool call.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": ["item/tool/requestUserInput"], + "title": "Item/tool/requestUserInputRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ToolRequestUserInputParams" + } + }, + "required": ["id", "method", "params"], + "title": "Item/tool/requestUserInputRequest", + "type": "object" + }, + { + "description": "Request input for an MCP server elicitation.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": ["mcpServer/elicitation/request"], + "title": "McpServer/elicitation/requestRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/McpServerElicitationRequestParams" + } + }, + "required": ["id", "method", "params"], + "title": "McpServer/elicitation/requestRequest", + "type": "object" + }, + { + "description": "Request approval for additional permissions from the user.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": ["item/permissions/requestApproval"], + "title": "Item/permissions/requestApprovalRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/PermissionsRequestApprovalParams" + } + }, + "required": ["id", "method", "params"], + "title": "Item/permissions/requestApprovalRequest", + "type": "object" + }, + { + "description": "Execute a dynamic tool call on the client.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": ["item/tool/call"], + "title": "Item/tool/callRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/DynamicToolCallParams" + } + }, + "required": ["id", "method", "params"], + "title": "Item/tool/callRequest", + "type": "object" + }, + { + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": ["account/chatgptAuthTokens/refresh"], + "title": "Account/chatgptAuthTokens/refreshRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ChatgptAuthTokensRefreshParams" + } + }, + "required": ["id", "method", "params"], + "title": "Account/chatgptAuthTokens/refreshRequest", + "type": "object" + }, + { + "description": "Generate a fresh upstream attestation result on demand.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": ["attestation/generate"], + "title": "Attestation/generateRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/AttestationGenerateParams" + } + }, + "required": ["id", "method", "params"], + "title": "Attestation/generateRequest", + "type": "object" + }, + { + "description": "Read the current time from an external clock owned by the client.", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": ["currentTime/read"], + "title": "CurrentTime/readRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/CurrentTimeReadParams" + } + }, + "required": ["id", "method", "params"], + "title": "CurrentTime/readRequest", + "type": "object" + }, + { + "description": "DEPRECATED APIs below Request to approve a patch. This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage).", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": ["applyPatchApproval"], + "title": "ApplyPatchApprovalRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ApplyPatchApprovalParams" + } + }, + "required": ["id", "method", "params"], + "title": "ApplyPatchApprovalRequest", + "type": "object" + }, + { + "description": "Request to exec a command. This request is used for Turns started via the legacy APIs (i.e. SendUserTurn, SendUserMessage).", + "properties": { + "id": { + "$ref": "#/definitions/RequestId" + }, + "method": { + "enum": ["execCommandApproval"], + "title": "ExecCommandApprovalRequestMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ExecCommandApprovalParams" + } + }, + "required": ["id", "method", "params"], + "title": "ExecCommandApprovalRequest", + "type": "object" + } + ], + "title": "ServerRequest" +} diff --git a/src/runtimes/openai/generated-json-schema/ThreadBackgroundTerminalsCleanResponse.json b/src/runtimes/openai/generated-json-schema/ThreadBackgroundTerminalsCleanResponse.json new file mode 100644 index 0000000..cc1ed20 --- /dev/null +++ b/src/runtimes/openai/generated-json-schema/ThreadBackgroundTerminalsCleanResponse.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadBackgroundTerminalsCleanResponse", + "type": "object" +} diff --git a/src/runtimes/openai/generated-json-schema/ThreadInjectItemsResponse.json b/src/runtimes/openai/generated-json-schema/ThreadInjectItemsResponse.json new file mode 100644 index 0000000..f2835b9 --- /dev/null +++ b/src/runtimes/openai/generated-json-schema/ThreadInjectItemsResponse.json @@ -0,0 +1,5 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "ThreadInjectItemsResponse", + "type": "object" +} diff --git a/src/runtimes/openai/generated-json-schema/ThreadResumeResponse.json b/src/runtimes/openai/generated-json-schema/ThreadResumeResponse.json new file mode 100644 index 0000000..89ca206 --- /dev/null +++ b/src/runtimes/openai/generated-json-schema/ThreadResumeResponse.json @@ -0,0 +1,2367 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier from the selected permissions profile's `extends` setting, when present.", + "type": ["string", "null"] + }, + "id": { + "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + } + }, + "required": ["id"], + "type": "object" + }, + "AgentMessageDelivery": { + "enum": ["async"], + "type": "string" + }, + "AgentPath": { + "type": "string" + }, + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + "enum": ["user", "auto_review", "guardian_subagent"], + "type": "string" + }, + "AskForApproval": { + "oneOf": [ + { + "enum": ["untrusted", "on-request", "never"], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "granular": { + "properties": { + "mcp_elicitations": { + "type": "boolean" + }, + "request_permissions": { + "default": false, + "type": "boolean" + }, + "rules": { + "type": "boolean" + }, + "sandbox_approval": { + "type": "boolean" + }, + "skill_approval": { + "default": false, + "type": "boolean" + } + }, + "required": ["mcp_elicitations", "rules", "sandbox_approval"], + "type": "object" + } + }, + "required": ["granular"], + "title": "GranularAskForApproval", + "type": "object" + } + ] + }, + "ByteRange": { + "properties": { + "end": { + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "start": { + "format": "uint", + "minimum": 0.0, + "type": "integer" + } + }, + "required": ["end", "start"], + "type": "object" + }, + "CodexErrorInfo": { + "description": "This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.", + "oneOf": [ + { + "enum": [ + "contextWindowExceeded", + "sessionBudgetExceeded", + "usageLimitExceeded", + "rateLimitExceeded", + "serverOverloaded", + "cyberPolicy", + "misalignmentPolicyViolation", + "internalServerError", + "unauthorized", + "badRequest", + "threadRollbackFailed", + "sandboxError", + "other" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": ["integer", "null"] + } + }, + "type": "object" + } + }, + "required": ["httpConnectionFailed"], + "title": "HttpConnectionFailedCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Failed to connect to the response SSE stream.", + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": ["integer", "null"] + } + }, + "type": "object" + } + }, + "required": ["responseStreamConnectionFailed"], + "title": "ResponseStreamConnectionFailedCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "The response SSE stream disconnected in the middle of a turn before completion.", + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": ["integer", "null"] + } + }, + "type": "object" + } + }, + "required": ["responseStreamDisconnected"], + "title": "ResponseStreamDisconnectedCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Reached the retry limit for responses.", + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": ["integer", "null"] + } + }, + "type": "object" + } + }, + "required": ["responseTooManyFailedAttempts"], + "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": ["turnKind"], + "type": "object" + } + }, + "required": ["activeTurnNotSteerable"], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" + } + ] + }, + "CollabAgentState": { + "properties": { + "message": { + "type": ["string", "null"] + }, + "status": { + "$ref": "#/definitions/CollabAgentStatus" + } + }, + "required": ["status"], + "type": "object" + }, + "CollabAgentStatus": { + "enum": [ + "pendingInit", + "running", + "interrupted", + "completed", + "errored", + "shutdown", + "notFound" + ], + "type": "string" + }, + "CollabAgentTool": { + "enum": [ + "spawnAgent", + "sendInput", + "resumeAgent", + "wait", + "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents" + ], + "type": "string" + }, + "CollabAgentToolCallStatus": { + "enum": ["inProgress", "completed", "failed", "interrupted"], + "type": "string" + }, + "CommandAction": { + "oneOf": [ + { + "properties": { + "command": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": { + "enum": ["read"], + "title": "ReadCommandActionType", + "type": "string" + } + }, + "required": ["command", "name", "path", "type"], + "title": "ReadCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": ["string", "null"] + }, + "type": { + "enum": ["listFiles"], + "title": "ListFilesCommandActionType", + "type": "string" + } + }, + "required": ["command", "type"], + "title": "ListFilesCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": ["string", "null"] + }, + "query": { + "type": ["string", "null"] + }, + "type": { + "enum": ["search"], + "title": "SearchCommandActionType", + "type": "string" + } + }, + "required": ["command", "type"], + "title": "SearchCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "type": { + "enum": ["unknown"], + "title": "UnknownCommandActionType", + "type": "string" + } + }, + "required": ["command", "type"], + "title": "UnknownCommandAction", + "type": "object" + } + ] + }, + "CommandExecutionSource": { + "enum": ["agent", "userShell", "unifiedExecStartup", "unifiedExecInteraction"], + "type": "string" + }, + "CommandExecutionStatus": { + "enum": ["inProgress", "completed", "failed", "declined"], + "type": "string" + }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": ["inputText"], + "title": "InputTextDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": ["text", "type"], + "title": "InputTextDynamicToolCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "enum": ["inputImage"], + "title": "InputImageDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": ["imageUrl", "type"], + "title": "InputImageDynamicToolCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "audioUrl": { + "type": "string" + }, + "type": { + "enum": ["inputAudio"], + "title": "InputAudioDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": ["audioUrl", "type"], + "title": "InputAudioDynamicToolCallOutputContentItem", + "type": "object" + } + ] + }, + "DynamicToolCallStatus": { + "enum": ["inProgress", "completed", "failed"], + "type": "string" + }, + "FileUpdateChange": { + "properties": { + "diff": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/PatchChangeKind" + }, + "path": { + "type": "string" + } + }, + "required": ["diff", "kind", "path"], + "type": "object" + }, + "FunctionCallOutputBody": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "$ref": "#/definitions/FunctionCallOutputContentItem" + }, + "type": "array" + } + ] + }, + "FunctionCallOutputContentItem": { + "description": "Responses API compatible content items that can be returned by a tool call. This is a subset of ContentItem with the types we support as function call outputs.", + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": ["input_text"], + "title": "InputTextFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": ["text", "type"], + "title": "InputTextFunctionCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, + "image_url": { + "type": "string" + }, + "type": { + "enum": ["input_image"], + "title": "InputImageFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": ["image_url", "type"], + "title": "InputImageFunctionCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "audio_url": { + "type": "string" + }, + "type": { + "enum": ["input_audio"], + "title": "InputAudioFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": ["audio_url", "type"], + "title": "InputAudioFunctionCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": ["encrypted_content"], + "title": "EncryptedContentFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": ["encrypted_content", "type"], + "title": "EncryptedContentFunctionCallOutputContentItem", + "type": "object" + } + ] + }, + "GitInfo": { + "properties": { + "branch": { + "type": ["string", "null"] + }, + "originUrl": { + "type": ["string", "null"] + }, + "sha": { + "type": ["string", "null"] + } + }, + "type": "object" + }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["hookRunId", "text"], + "type": "object" + }, + "ImageDetail": { + "enum": ["auto", "low", "high", "original"], + "type": "string" + }, + "ImageGenerationFailure": { + "oneOf": [ + { + "properties": { + "limitId": { + "type": "string" + }, + "resetsAt": { + "format": "int64", + "type": ["integer", "null"] + }, + "type": { + "enum": ["usageLimitExceeded"], + "title": "UsageLimitExceededImageGenerationFailureType", + "type": "string" + } + }, + "required": ["limitId", "type"], + "title": "UsageLimitExceededImageGenerationFailure", + "type": "object" + } + ] + }, + "LegacyAppPathString": { + "type": "string" + }, + "McpToolCallAppContext": { + "properties": { + "actionName": { + "type": ["string", "null"] + }, + "appName": { + "type": ["string", "null"] + }, + "connectorId": { + "type": "string" + }, + "linkId": { + "type": ["string", "null"] + }, + "resourceUri": { + "type": ["string", "null"] + } + }, + "required": ["connectorId"], + "type": "object" + }, + "McpToolCallError": { + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "type": "object" + }, + "McpToolCallResult": { + "properties": { + "_meta": true, + "content": { + "items": true, + "type": "array" + }, + "structuredContent": true + }, + "required": ["content"], + "type": "object" + }, + "McpToolCallStatus": { + "enum": ["inProgress", "completed", "failed"], + "type": "string" + }, + "MemoryCitation": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/MemoryCitationEntry" + }, + "type": "array" + }, + "threadIds": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": ["entries", "threadIds"], + "type": "object" + }, + "MemoryCitationEntry": { + "properties": { + "lineEnd": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "lineStart": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "note": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": ["lineEnd", "lineStart", "note", "path"], + "type": "object" + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": ["commentary"], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": ["final_answer"], + "type": "string" + } + ] + }, + "MisalignmentErrorDetails": { + "properties": { + "detailedExplanation": { + "description": "A substantive localized explanation is required before offering continuation.", + "type": ["string", "null"] + }, + "errorType": { + "description": "Open-ended classification; clients must accept categories added by Responses.", + "type": ["string", "null"] + }, + "steer": { + "anyOf": [ + { + "$ref": "#/definitions/MisalignmentSteer" + }, + { + "type": "null" + } + ], + "description": "Instruction to submit as the next turn's user input if continuation is confirmed." + } + }, + "type": "object" + }, + "MisalignmentSteer": { + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "type": "object" + }, + "MultiAgentMode": { + "description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.", + "oneOf": [ + { + "enum": ["explicitRequestOnly", "proactive"], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": ["custom"], + "title": "CustomMultiAgentMode", + "type": "object" + } + ] + }, + "NetworkAccess": { + "enum": ["restricted", "enabled"], + "type": "string" + }, + "NonSteerableTurnKind": { + "enum": ["review", "compact"], + "type": "string" + }, + "PatchApplyStatus": { + "enum": ["inProgress", "completed", "failed", "declined"], + "type": "string" + }, + "PatchChangeKind": { + "oneOf": [ + { + "properties": { + "type": { + "enum": ["add"], + "title": "AddPatchChangeKindType", + "type": "string" + } + }, + "required": ["type"], + "title": "AddPatchChangeKind", + "type": "object" + }, + { + "properties": { + "type": { + "enum": ["delete"], + "title": "DeletePatchChangeKindType", + "type": "string" + } + }, + "required": ["type"], + "title": "DeletePatchChangeKind", + "type": "object" + }, + { + "properties": { + "move_path": { + "type": ["string", "null"] + }, + "type": { + "enum": ["update"], + "title": "UpdatePatchChangeKindType", + "type": "string" + } + }, + "required": ["type"], + "title": "UpdatePatchChangeKind", + "type": "object" + } + ] + }, + "ReasoningEffort": { + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, + "type": "string" + }, + "SandboxPolicy": { + "oneOf": [ + { + "properties": { + "type": { + "enum": ["dangerFullAccess"], + "title": "DangerFullAccessSandboxPolicyType", + "type": "string" + } + }, + "required": ["type"], + "title": "DangerFullAccessSandboxPolicy", + "type": "object" + }, + { + "properties": { + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "enum": ["readOnly"], + "title": "ReadOnlySandboxPolicyType", + "type": "string" + } + }, + "required": ["type"], + "title": "ReadOnlySandboxPolicy", + "type": "object" + }, + { + "properties": { + "networkAccess": { + "allOf": [ + { + "$ref": "#/definitions/NetworkAccess" + } + ], + "default": "restricted" + }, + "type": { + "enum": ["externalSandbox"], + "title": "ExternalSandboxSandboxPolicyType", + "type": "string" + } + }, + "required": ["type"], + "title": "ExternalSandboxSandboxPolicy", + "type": "object" + }, + { + "properties": { + "excludeSlashTmp": { + "default": false, + "type": "boolean" + }, + "excludeTmpdirEnvVar": { + "default": false, + "type": "boolean" + }, + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "enum": ["workspaceWrite"], + "title": "WorkspaceWriteSandboxPolicyType", + "type": "string" + }, + "writableRoots": { + "default": [], + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + } + }, + "required": ["type"], + "title": "WorkspaceWriteSandboxPolicy", + "type": "object" + } + ] + }, + "SessionSource": { + "oneOf": [ + { + "enum": ["cli", "vscode", "exec", "appServer", "unknown"], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": ["custom"], + "title": "CustomSessionSource", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "subAgent": { + "$ref": "#/definitions/SubAgentSource" + } + }, + "required": ["subAgent"], + "title": "SubAgentSessionSource", + "type": "object" + } + ] + }, + "SubAgentActivityKind": { + "enum": ["started", "interacted", "interrupted", "completed"], + "type": "string" + }, + "SubAgentSource": { + "oneOf": [ + { + "enum": ["review", "compact", "memory_consolidation"], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "thread_spawn": { + "properties": { + "agent_nickname": { + "default": null, + "type": ["string", "null"] + }, + "agent_path": { + "anyOf": [ + { + "$ref": "#/definitions/AgentPath" + }, + { + "type": "null" + } + ], + "default": null + }, + "agent_role": { + "default": null, + "type": ["string", "null"] + }, + "depth": { + "format": "int32", + "type": "integer" + }, + "parent_thread_id": { + "$ref": "#/definitions/ThreadId" + } + }, + "required": ["depth", "parent_thread_id"], + "type": "object" + } + }, + "required": ["thread_spawn"], + "title": "ThreadSpawnSubAgentSource", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "other": { + "type": "string" + } + }, + "required": ["other"], + "title": "OtherSubAgentSource", + "type": "object" + } + ] + }, + "TextElement": { + "properties": { + "byteRange": { + "allOf": [ + { + "$ref": "#/definitions/ByteRange" + } + ], + "description": "Byte range in the parent `text` buffer that this element occupies." + }, + "placeholder": { + "description": "Optional human-readable placeholder for the element, displayed in the UI.", + "type": ["string", "null"] + } + }, + "required": ["byteRange"], + "type": "object" + }, + "Thread": { + "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": ["string", "null"] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": ["string", "null"] + }, + "canAcceptDirectInput": { + "description": "Whether the app server accepts direct turn input for this loaded thread. `None` means the capability is unavailable, such as for an unloaded stored thread.", + "type": ["boolean", "null"] + }, + "cliVersion": { + "description": "Version of the CLI that created the thread.", + "type": "string" + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the thread was created.", + "format": "int64", + "type": "integer" + }, + "cwd": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Working directory captured for the thread." + }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, + "extra": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadExtra" + }, + { + "type": "null" + } + ], + "description": "Optional implementation-specific thread data." + }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": ["string", "null"] + }, + "gitInfo": { + "anyOf": [ + { + "$ref": "#/definitions/GitInfo" + }, + { + "type": "null" + } + ], + "description": "Optional Git metadata captured when the thread was created." + }, + "historyMode": { + "allOf": [ + { + "$ref": "#/definitions/ThreadHistoryMode" + } + ], + "default": "legacy", + "description": "Persisted thread history contract selected when this thread was created." + }, + "id": { + "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", + "type": "string" + }, + "modelProvider": { + "description": "Model provider used for this thread (for example, 'openai').", + "type": "string" + }, + "name": { + "description": "Optional user-facing thread title.", + "type": ["string", "null"] + }, + "parentThreadId": { + "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", + "type": ["string", "null"] + }, + "path": { + "description": "[UNSTABLE] Path to the thread on disk.", + "type": ["string", "null"] + }, + "preview": { + "description": "Usually the first user message in the thread, if available.", + "type": "string" + }, + "projectId": { + "description": "Canonical project assignment owned by app-server, if any.", + "type": ["string", "null"] + }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": ["integer", "null"] + }, + "section": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSection" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The independently persisted section selected for this thread, if any." + }, + "sectionEnteredAt": { + "default": null, + "description": "Unix timestamp in seconds when the thread entered its current section.", + "format": "int64", + "type": ["integer", "null"] + }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/SessionSource" + } + ], + "description": "Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.)." + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/ThreadStatus" + } + ], + "description": "Current runtime status for the thread." + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, + "turns": { + "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", + "items": { + "$ref": "#/definitions/Turn" + }, + "type": "array" + }, + "updatedAt": { + "description": "Unix timestamp (in seconds) when the thread was last updated.", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "cliVersion", + "createdAt", + "cwd", + "ephemeral", + "id", + "modelProvider", + "preview", + "projectId", + "sessionId", + "source", + "status", + "turns", + "updatedAt" + ], + "type": "object" + }, + "ThreadActiveFlag": { + "enum": ["waitingOnApproval", "waitingOnUserInput"], + "type": "string" + }, + "ThreadExtra": { + "description": "Extra app-server data for a thread.", + "type": "object" + }, + "ThreadHistoryMode": { + "enum": ["legacy", "paginated"], + "type": "string" + }, + "ThreadId": { + "type": "string" + }, + "ThreadItem": { + "oneOf": [ + { + "properties": { + "clientId": { + "type": ["string", "null"] + }, + "content": { + "items": { + "$ref": "#/definitions/UserInput" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": ["userMessage"], + "title": "UserMessageThreadItemType", + "type": "string" + } + }, + "required": ["content", "id", "type"], + "title": "UserMessageThreadItem", + "type": "object" + }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": ["hookPrompt"], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": ["fragments", "id", "type"], + "title": "HookPromptThreadItem", + "type": "object" + }, + { + "properties": { + "delivery": { + "anyOf": [ + { + "$ref": "#/definitions/AgentMessageDelivery" + }, + { + "type": "null" + } + ], + "default": null + }, + "id": { + "type": "string" + }, + "memoryCitation": { + "anyOf": [ + { + "$ref": "#/definitions/MemoryCitation" + }, + { + "type": "null" + } + ], + "default": null + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, + "text": { + "type": "string" + }, + "type": { + "enum": ["agentMessage"], + "title": "AgentMessageThreadItemType", + "type": "string" + } + }, + "required": ["id", "text", "type"], + "title": "AgentMessageThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "namespace": { + "type": ["string", "null"] + }, + "output": { + "$ref": "#/definitions/FunctionCallOutputBody" + }, + "type": { + "enum": ["functionCallOutput"], + "title": "FunctionCallOutputThreadItemType", + "type": "string" + } + }, + "required": ["id", "name", "output", "type"], + "title": "FunctionCallOutputThreadItem", + "type": "object" + }, + { + "description": "EXPERIMENTAL - proposed plan item content. The completed plan item is authoritative and may not match the concatenation of `PlanDelta` text.", + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "type": { + "enum": ["plan"], + "title": "PlanThreadItemType", + "type": "string" + } + }, + "required": ["id", "text", "type"], + "title": "PlanThreadItem", + "type": "object" + }, + { + "properties": { + "content": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "summary": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "enum": ["reasoning"], + "title": "ReasoningThreadItemType", + "type": "string" + } + }, + "required": ["id", "type"], + "title": "ReasoningThreadItem", + "type": "object" + }, + { + "properties": { + "aggregatedOutput": { + "description": "The command's output, aggregated from stdout and stderr.", + "type": ["string", "null"] + }, + "command": { + "description": "The command to be executed.", + "type": "string" + }, + "commandActions": { + "description": "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", + "items": { + "$ref": "#/definitions/CommandAction" + }, + "type": "array" + }, + "cwd": { + "allOf": [ + { + "$ref": "#/definitions/LegacyAppPathString" + } + ], + "description": "The command's working directory." + }, + "durationMs": { + "description": "The duration of the command execution in milliseconds.", + "format": "int64", + "type": ["integer", "null"] + }, + "exitCode": { + "description": "The command's exit code.", + "format": "int32", + "type": ["integer", "null"] + }, + "id": { + "type": "string" + }, + "pluginId": { + "default": null, + "description": "Trusted first-party plugin id when this command resolves to one plugin script.", + "type": ["string", "null"] + }, + "processId": { + "description": "Identifier for the underlying PTY process (when available).", + "type": ["string", "null"] + }, + "scriptPath": { + "default": null, + "description": "Safe plugin-relative path when this command resolves to one plugin script.", + "type": ["string", "null"] + }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, + "status": { + "$ref": "#/definitions/CommandExecutionStatus" + }, + "type": { + "enum": ["commandExecution"], + "title": "CommandExecutionThreadItemType", + "type": "string" + } + }, + "required": ["command", "commandActions", "cwd", "id", "status", "type"], + "title": "CommandExecutionThreadItem", + "type": "object" + }, + { + "properties": { + "changes": { + "items": { + "$ref": "#/definitions/FileUpdateChange" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/PatchApplyStatus" + }, + "type": { + "enum": ["fileChange"], + "title": "FileChangeThreadItemType", + "type": "string" + } + }, + "required": ["changes", "id", "status", "type"], + "title": "FileChangeThreadItem", + "type": "object" + }, + { + "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, + "arguments": true, + "durationMs": { + "description": "The duration of the MCP tool call in milliseconds.", + "format": "int64", + "type": ["integer", "null"] + }, + "error": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallError" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", + "type": ["string", "null"] + }, + "pluginId": { + "type": ["string", "null"] + }, + "readOnlyHint": { + "type": ["boolean", "null"] + }, + "result": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallResult" + }, + { + "type": "null" + } + ] + }, + "server": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/McpToolCallStatus" + }, + "tool": { + "type": "string" + }, + "type": { + "enum": ["mcpToolCall"], + "title": "McpToolCallThreadItemType", + "type": "string" + } + }, + "required": ["arguments", "id", "server", "status", "tool", "type"], + "title": "McpToolCallThreadItem", + "type": "object" + }, + { + "properties": { + "arguments": true, + "contentItems": { + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + }, + "type": ["array", "null"] + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "format": "int64", + "type": ["integer", "null"] + }, + "id": { + "type": "string" + }, + "namespace": { + "type": ["string", "null"] + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": ["boolean", "null"] + }, + "tool": { + "type": "string" + }, + "type": { + "enum": ["dynamicToolCall"], + "title": "DynamicToolCallThreadItemType", + "type": "string" + } + }, + "required": ["arguments", "id", "status", "tool", "type"], + "title": "DynamicToolCallThreadItem", + "type": "object" + }, + { + "properties": { + "agentsStates": { + "additionalProperties": { + "$ref": "#/definitions/CollabAgentState" + }, + "description": "Last known status of the target agents, when available.", + "type": "object" + }, + "id": { + "description": "Unique identifier for this collab tool call.", + "type": "string" + }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": ["string", "null"] + }, + "prompt": { + "description": "Prompt text sent as part of the collab tool call, when available.", + "type": ["string", "null"] + }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ], + "description": "Reasoning effort requested for the spawned agent, when applicable." + }, + "receiverThreadIds": { + "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", + "items": { + "type": "string" + }, + "type": "array" + }, + "senderThreadId": { + "description": "Thread ID of the agent issuing the collab request.", + "type": "string" + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/CollabAgentToolCallStatus" + } + ], + "description": "Current status of the collab tool call." + }, + "tool": { + "allOf": [ + { + "$ref": "#/definitions/CollabAgentTool" + } + ], + "description": "Name of the collab tool that was invoked." + }, + "type": { + "enum": ["collabAgentToolCall"], + "title": "CollabAgentToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "agentsStates", + "id", + "receiverThreadIds", + "senderThreadId", + "status", + "tool", + "type" + ], + "title": "CollabAgentToolCallThreadItem", + "type": "object" + }, + { + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "enum": ["subAgentActivity"], + "title": "SubAgentActivityThreadItemType", + "type": "string" + } + }, + "required": ["agentPath", "agentThreadId", "id", "kind", "type"], + "title": "SubAgentActivityThreadItem", + "type": "object" + }, + { + "properties": { + "action": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchAction" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "query": { + "type": "string" + }, + "results": { + "default": null, + "description": "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.", + "items": true, + "type": ["array", "null"] + }, + "type": { + "enum": ["webSearch"], + "title": "WebSearchThreadItemType", + "type": "string" + } + }, + "required": ["id", "query", "type"], + "title": "WebSearchThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": { + "enum": ["imageView"], + "title": "ImageViewThreadItemType", + "type": "string" + } + }, + "required": ["id", "path", "type"], + "title": "ImageViewThreadItem", + "type": "object" + }, + { + "description": "Display item emitted by the interruptible `clock.sleep` tool.", + "properties": { + "durationMs": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "id": { + "type": "string" + }, + "type": { + "enum": ["sleep"], + "title": "SleepThreadItemType", + "type": "string" + } + }, + "required": ["durationMs", "id", "type"], + "title": "SleepThreadItem", + "type": "object" + }, + { + "properties": { + "failure": { + "anyOf": [ + { + "$ref": "#/definitions/ImageGenerationFailure" + }, + { + "type": "null" + } + ], + "default": null + }, + "id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "revisedPrompt": { + "type": ["string", "null"] + }, + "savedPath": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string" + }, + "transparentBackground": { + "default": null, + "type": ["boolean", "null"] + }, + "type": { + "enum": ["imageGeneration"], + "title": "ImageGenerationThreadItemType", + "type": "string" + } + }, + "required": ["id", "result", "status", "type"], + "title": "ImageGenerationThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "enum": ["enteredReviewMode"], + "title": "EnteredReviewModeThreadItemType", + "type": "string" + } + }, + "required": ["id", "review", "type"], + "title": "EnteredReviewModeThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "enum": ["exitedReviewMode"], + "title": "ExitedReviewModeThreadItemType", + "type": "string" + } + }, + "required": ["id", "review", "type"], + "title": "ExitedReviewModeThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "type": { + "enum": ["contextCompaction"], + "title": "ContextCompactionThreadItemType", + "type": "string" + } + }, + "required": ["id", "type"], + "title": "ContextCompactionThreadItem", + "type": "object" + } + ] + }, + "ThreadSection": { + "description": "An independently persisted, user-visible thread section.", + "properties": { + "appearance": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSectionAppearance" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional appearance synchronized across clients." + }, + "id": { + "description": "Opaque UUIDv7 identity that remains stable when the section is renamed.", + "type": "string" + }, + "name": { + "description": "The current user-visible section name.", + "type": "string" + } + }, + "required": ["id", "name"], + "type": "object" + }, + "ThreadSectionAppearance": { + "description": "Extensible visual presentation for a custom thread section.", + "properties": { + "color": { + "type": ["string", "null"] + }, + "icon": { + "type": ["string", "null"] + } + }, + "type": "object" + }, + "ThreadSource": { + "type": "string" + }, + "ThreadStatus": { + "oneOf": [ + { + "properties": { + "type": { + "enum": ["notLoaded"], + "title": "NotLoadedThreadStatusType", + "type": "string" + } + }, + "required": ["type"], + "title": "NotLoadedThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": ["idle"], + "title": "IdleThreadStatusType", + "type": "string" + } + }, + "required": ["type"], + "title": "IdleThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": ["systemError"], + "title": "SystemErrorThreadStatusType", + "type": "string" + } + }, + "required": ["type"], + "title": "SystemErrorThreadStatus", + "type": "object" + }, + { + "properties": { + "activeFlags": { + "items": { + "$ref": "#/definitions/ThreadActiveFlag" + }, + "type": "array" + }, + "type": { + "enum": ["active"], + "title": "ActiveThreadStatusType", + "type": "string" + } + }, + "required": ["activeFlags", "type"], + "title": "ActiveThreadStatus", + "type": "object" + } + ] + }, + "Turn": { + "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": ["integer", "null"] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": ["integer", "null"] + }, + "error": { + "anyOf": [ + { + "$ref": "#/definitions/TurnError" + }, + { + "type": "null" + } + ], + "description": "Only populated when the Turn's status is failed." + }, + "id": { + "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", + "type": "string" + }, + "items": { + "description": "Thread items currently included in this turn payload.", + "items": { + "$ref": "#/definitions/ThreadItem" + }, + "type": "array" + }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": ["integer", "null"] + }, + "status": { + "$ref": "#/definitions/TurnStatus" + } + }, + "required": ["id", "items", "status"], + "type": "object" + }, + "TurnError": { + "properties": { + "additionalDetails": { + "default": null, + "type": ["string", "null"] + }, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/definitions/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + }, + "misalignment": { + "anyOf": [ + { + "$ref": "#/definitions/MisalignmentErrorDetails" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional public explanation and continuation instruction for a misalignment block." + } + }, + "required": ["message"], + "type": "object" + }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": ["notLoaded"], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": ["summary"], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": ["full"], + "type": "string" + } + ] + }, + "TurnStatus": { + "enum": ["completed", "interrupted", "failed", "inProgress"], + "type": "string" + }, + "TurnsPage": { + "properties": { + "backwardsCursor": { + "type": ["string", "null"] + }, + "data": { + "items": { + "$ref": "#/definitions/Turn" + }, + "type": "array" + }, + "nextCursor": { + "type": ["string", "null"] + } + }, + "required": ["data"], + "type": "object" + }, + "UserInput": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "text_elements": { + "default": [], + "description": "UI-defined spans within `text` used to render or persist special elements.", + "items": { + "$ref": "#/definitions/TextElement" + }, + "type": "array" + }, + "type": { + "enum": ["text"], + "title": "TextUserInputType", + "type": "string" + } + }, + "required": ["text", "type"], + "title": "TextUserInput", + "type": "object" + }, + { + "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, + "type": { + "enum": ["image"], + "title": "ImageUserInputType", + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["type", "url"], + "title": "ImageUserInput", + "type": "object" + }, + { + "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, + "path": { + "type": "string" + }, + "type": { + "enum": ["localImage"], + "title": "LocalImageUserInputType", + "type": "string" + } + }, + "required": ["path", "type"], + "title": "LocalImageUserInput", + "type": "object" + }, + { + "properties": { + "type": { + "enum": ["audio"], + "title": "AudioUserInputType", + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["type", "url"], + "title": "AudioUserInput", + "type": "object" + }, + { + "properties": { + "path": { + "type": "string" + }, + "type": { + "enum": ["localAudio"], + "title": "LocalAudioUserInputType", + "type": "string" + } + }, + "required": ["path", "type"], + "title": "LocalAudioUserInput", + "type": "object" + }, + { + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": ["skill"], + "title": "SkillUserInputType", + "type": "string" + } + }, + "required": ["name", "path", "type"], + "title": "SkillUserInput", + "type": "object" + }, + { + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": ["mention"], + "title": "MentionUserInputType", + "type": "string" + } + }, + "required": ["name", "path", "type"], + "title": "MentionUserInput", + "type": "object" + } + ] + }, + "WebSearchAction": { + "oneOf": [ + { + "properties": { + "queries": { + "items": { + "type": "string" + }, + "type": ["array", "null"] + }, + "query": { + "type": ["string", "null"] + }, + "type": { + "enum": ["search"], + "title": "SearchWebSearchActionType", + "type": "string" + } + }, + "required": ["type"], + "title": "SearchWebSearchAction", + "type": "object" + }, + { + "properties": { + "type": { + "enum": ["openPage"], + "title": "OpenPageWebSearchActionType", + "type": "string" + }, + "url": { + "type": ["string", "null"] + } + }, + "required": ["type"], + "title": "OpenPageWebSearchAction", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": ["string", "null"] + }, + "type": { + "enum": ["findInPage"], + "title": "FindInPageWebSearchActionType", + "type": "string" + }, + "url": { + "type": ["string", "null"] + } + }, + "required": ["type"], + "title": "FindInPageWebSearchAction", + "type": "object" + }, + { + "properties": { + "type": { + "enum": ["other"], + "title": "OtherWebSearchActionType", + "type": "string" + } + }, + "required": ["type"], + "title": "OtherWebSearchAction", + "type": "object" + } + ] + } + }, + "properties": { + "activePermissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/ActivePermissionProfile" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Named or implicit built-in profile that produced the active permissions, when known." + }, + "approvalPolicy": { + "$ref": "#/definitions/AskForApproval" + }, + "approvalsReviewer": { + "allOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + } + ], + "description": "Reviewer currently used for approval requests on this thread." + }, + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "initialTurnsPage": { + "anyOf": [ + { + "$ref": "#/definitions/TurnsPage" + }, + { + "type": "null" + } + ], + "default": null, + "description": "`thread/turns/list` page returned when requested by `initialTurnsPage`." + }, + "instructionSources": { + "default": [], + "description": "Environment-native paths to instruction source files currently loaded for this thread.", + "items": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": "array" + }, + "itemsBackwardsCursor": { + "default": null, + "description": "Opaque cursor for hydrating paginated items backwards.\n\nPass this as `cursor` to `thread/items/list` with `sortDirection: \"desc\"`. The first page includes the item identified by the cursor.", + "type": ["string", "null"] + }, + "model": { + "type": "string" + }, + "modelProvider": { + "type": "string" + }, + "multiAgentMode": { + "allOf": [ + { + "$ref": "#/definitions/MultiAgentMode" + } + ], + "default": "explicitRequestOnly", + "description": "@deprecated Always `explicitRequestOnly`. Use `reasoningEffort` for Ultra behavior." + }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "runtimeWorkspaceRoots": { + "default": [], + "description": "Thread-scoped runtime workspace roots used to materialize `:workspace_roots`.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "sandbox": { + "allOf": [ + { + "$ref": "#/definitions/SandboxPolicy" + } + ], + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance." + }, + "serviceTier": { + "type": ["string", "null"] + }, + "thread": { + "$ref": "#/definitions/Thread" + }, + "turnsBackwardsCursor": { + "default": null, + "description": "Opaque cursor for hydrating paginated turns backwards.\n\nPass this as `cursor` to `thread/turns/list` with `sortDirection: \"desc\"`. The first page includes the turn identified by the cursor.", + "type": ["string", "null"] + } + }, + "required": [ + "approvalPolicy", + "approvalsReviewer", + "cwd", + "model", + "modelProvider", + "sandbox", + "thread" + ], + "title": "ThreadResumeResponse", + "type": "object" +} diff --git a/src/runtimes/openai/generated-json-schema/ThreadStartResponse.json b/src/runtimes/openai/generated-json-schema/ThreadStartResponse.json new file mode 100644 index 0000000..fa54a3d --- /dev/null +++ b/src/runtimes/openai/generated-json-schema/ThreadStartResponse.json @@ -0,0 +1,2327 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "ActivePermissionProfile": { + "properties": { + "extends": { + "default": null, + "description": "Parent profile identifier from the selected permissions profile's `extends` setting, when present.", + "type": ["string", "null"] + }, + "id": { + "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", + "type": "string" + } + }, + "required": ["id"], + "type": "object" + }, + "AgentMessageDelivery": { + "enum": ["async"], + "type": "string" + }, + "AgentPath": { + "type": "string" + }, + "ApprovalsReviewer": { + "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", + "enum": ["user", "auto_review", "guardian_subagent"], + "type": "string" + }, + "AskForApproval": { + "oneOf": [ + { + "enum": ["untrusted", "on-request", "never"], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "granular": { + "properties": { + "mcp_elicitations": { + "type": "boolean" + }, + "request_permissions": { + "default": false, + "type": "boolean" + }, + "rules": { + "type": "boolean" + }, + "sandbox_approval": { + "type": "boolean" + }, + "skill_approval": { + "default": false, + "type": "boolean" + } + }, + "required": ["mcp_elicitations", "rules", "sandbox_approval"], + "type": "object" + } + }, + "required": ["granular"], + "title": "GranularAskForApproval", + "type": "object" + } + ] + }, + "ByteRange": { + "properties": { + "end": { + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "start": { + "format": "uint", + "minimum": 0.0, + "type": "integer" + } + }, + "required": ["end", "start"], + "type": "object" + }, + "CodexErrorInfo": { + "description": "This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.", + "oneOf": [ + { + "enum": [ + "contextWindowExceeded", + "sessionBudgetExceeded", + "usageLimitExceeded", + "rateLimitExceeded", + "serverOverloaded", + "cyberPolicy", + "misalignmentPolicyViolation", + "internalServerError", + "unauthorized", + "badRequest", + "threadRollbackFailed", + "sandboxError", + "other" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": ["integer", "null"] + } + }, + "type": "object" + } + }, + "required": ["httpConnectionFailed"], + "title": "HttpConnectionFailedCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Failed to connect to the response SSE stream.", + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": ["integer", "null"] + } + }, + "type": "object" + } + }, + "required": ["responseStreamConnectionFailed"], + "title": "ResponseStreamConnectionFailedCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "The response SSE stream disconnected in the middle of a turn before completion.", + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": ["integer", "null"] + } + }, + "type": "object" + } + }, + "required": ["responseStreamDisconnected"], + "title": "ResponseStreamDisconnectedCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Reached the retry limit for responses.", + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": ["integer", "null"] + } + }, + "type": "object" + } + }, + "required": ["responseTooManyFailedAttempts"], + "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": ["turnKind"], + "type": "object" + } + }, + "required": ["activeTurnNotSteerable"], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" + } + ] + }, + "CollabAgentState": { + "properties": { + "message": { + "type": ["string", "null"] + }, + "status": { + "$ref": "#/definitions/CollabAgentStatus" + } + }, + "required": ["status"], + "type": "object" + }, + "CollabAgentStatus": { + "enum": [ + "pendingInit", + "running", + "interrupted", + "completed", + "errored", + "shutdown", + "notFound" + ], + "type": "string" + }, + "CollabAgentTool": { + "enum": [ + "spawnAgent", + "sendInput", + "resumeAgent", + "wait", + "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents" + ], + "type": "string" + }, + "CollabAgentToolCallStatus": { + "enum": ["inProgress", "completed", "failed", "interrupted"], + "type": "string" + }, + "CommandAction": { + "oneOf": [ + { + "properties": { + "command": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": { + "enum": ["read"], + "title": "ReadCommandActionType", + "type": "string" + } + }, + "required": ["command", "name", "path", "type"], + "title": "ReadCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": ["string", "null"] + }, + "type": { + "enum": ["listFiles"], + "title": "ListFilesCommandActionType", + "type": "string" + } + }, + "required": ["command", "type"], + "title": "ListFilesCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": ["string", "null"] + }, + "query": { + "type": ["string", "null"] + }, + "type": { + "enum": ["search"], + "title": "SearchCommandActionType", + "type": "string" + } + }, + "required": ["command", "type"], + "title": "SearchCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "type": { + "enum": ["unknown"], + "title": "UnknownCommandActionType", + "type": "string" + } + }, + "required": ["command", "type"], + "title": "UnknownCommandAction", + "type": "object" + } + ] + }, + "CommandExecutionSource": { + "enum": ["agent", "userShell", "unifiedExecStartup", "unifiedExecInteraction"], + "type": "string" + }, + "CommandExecutionStatus": { + "enum": ["inProgress", "completed", "failed", "declined"], + "type": "string" + }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": ["inputText"], + "title": "InputTextDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": ["text", "type"], + "title": "InputTextDynamicToolCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "enum": ["inputImage"], + "title": "InputImageDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": ["imageUrl", "type"], + "title": "InputImageDynamicToolCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "audioUrl": { + "type": "string" + }, + "type": { + "enum": ["inputAudio"], + "title": "InputAudioDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": ["audioUrl", "type"], + "title": "InputAudioDynamicToolCallOutputContentItem", + "type": "object" + } + ] + }, + "DynamicToolCallStatus": { + "enum": ["inProgress", "completed", "failed"], + "type": "string" + }, + "FileUpdateChange": { + "properties": { + "diff": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/PatchChangeKind" + }, + "path": { + "type": "string" + } + }, + "required": ["diff", "kind", "path"], + "type": "object" + }, + "FunctionCallOutputBody": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "$ref": "#/definitions/FunctionCallOutputContentItem" + }, + "type": "array" + } + ] + }, + "FunctionCallOutputContentItem": { + "description": "Responses API compatible content items that can be returned by a tool call. This is a subset of ContentItem with the types we support as function call outputs.", + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": ["input_text"], + "title": "InputTextFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": ["text", "type"], + "title": "InputTextFunctionCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, + "image_url": { + "type": "string" + }, + "type": { + "enum": ["input_image"], + "title": "InputImageFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": ["image_url", "type"], + "title": "InputImageFunctionCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "audio_url": { + "type": "string" + }, + "type": { + "enum": ["input_audio"], + "title": "InputAudioFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": ["audio_url", "type"], + "title": "InputAudioFunctionCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": ["encrypted_content"], + "title": "EncryptedContentFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": ["encrypted_content", "type"], + "title": "EncryptedContentFunctionCallOutputContentItem", + "type": "object" + } + ] + }, + "GitInfo": { + "properties": { + "branch": { + "type": ["string", "null"] + }, + "originUrl": { + "type": ["string", "null"] + }, + "sha": { + "type": ["string", "null"] + } + }, + "type": "object" + }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["hookRunId", "text"], + "type": "object" + }, + "ImageDetail": { + "enum": ["auto", "low", "high", "original"], + "type": "string" + }, + "ImageGenerationFailure": { + "oneOf": [ + { + "properties": { + "limitId": { + "type": "string" + }, + "resetsAt": { + "format": "int64", + "type": ["integer", "null"] + }, + "type": { + "enum": ["usageLimitExceeded"], + "title": "UsageLimitExceededImageGenerationFailureType", + "type": "string" + } + }, + "required": ["limitId", "type"], + "title": "UsageLimitExceededImageGenerationFailure", + "type": "object" + } + ] + }, + "LegacyAppPathString": { + "type": "string" + }, + "McpToolCallAppContext": { + "properties": { + "actionName": { + "type": ["string", "null"] + }, + "appName": { + "type": ["string", "null"] + }, + "connectorId": { + "type": "string" + }, + "linkId": { + "type": ["string", "null"] + }, + "resourceUri": { + "type": ["string", "null"] + } + }, + "required": ["connectorId"], + "type": "object" + }, + "McpToolCallError": { + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "type": "object" + }, + "McpToolCallResult": { + "properties": { + "_meta": true, + "content": { + "items": true, + "type": "array" + }, + "structuredContent": true + }, + "required": ["content"], + "type": "object" + }, + "McpToolCallStatus": { + "enum": ["inProgress", "completed", "failed"], + "type": "string" + }, + "MemoryCitation": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/MemoryCitationEntry" + }, + "type": "array" + }, + "threadIds": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": ["entries", "threadIds"], + "type": "object" + }, + "MemoryCitationEntry": { + "properties": { + "lineEnd": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "lineStart": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "note": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": ["lineEnd", "lineStart", "note", "path"], + "type": "object" + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": ["commentary"], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": ["final_answer"], + "type": "string" + } + ] + }, + "MisalignmentErrorDetails": { + "properties": { + "detailedExplanation": { + "description": "A substantive localized explanation is required before offering continuation.", + "type": ["string", "null"] + }, + "errorType": { + "description": "Open-ended classification; clients must accept categories added by Responses.", + "type": ["string", "null"] + }, + "steer": { + "anyOf": [ + { + "$ref": "#/definitions/MisalignmentSteer" + }, + { + "type": "null" + } + ], + "description": "Instruction to submit as the next turn's user input if continuation is confirmed." + } + }, + "type": "object" + }, + "MisalignmentSteer": { + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "type": "object" + }, + "MultiAgentMode": { + "description": "Controls the effective multi-agent delegation instructions for a turn. `custom` means the configured mode hint defines the policy instead of a built-in policy.", + "oneOf": [ + { + "enum": ["explicitRequestOnly", "proactive"], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": ["custom"], + "title": "CustomMultiAgentMode", + "type": "object" + } + ] + }, + "NetworkAccess": { + "enum": ["restricted", "enabled"], + "type": "string" + }, + "NonSteerableTurnKind": { + "enum": ["review", "compact"], + "type": "string" + }, + "PatchApplyStatus": { + "enum": ["inProgress", "completed", "failed", "declined"], + "type": "string" + }, + "PatchChangeKind": { + "oneOf": [ + { + "properties": { + "type": { + "enum": ["add"], + "title": "AddPatchChangeKindType", + "type": "string" + } + }, + "required": ["type"], + "title": "AddPatchChangeKind", + "type": "object" + }, + { + "properties": { + "type": { + "enum": ["delete"], + "title": "DeletePatchChangeKindType", + "type": "string" + } + }, + "required": ["type"], + "title": "DeletePatchChangeKind", + "type": "object" + }, + { + "properties": { + "move_path": { + "type": ["string", "null"] + }, + "type": { + "enum": ["update"], + "title": "UpdatePatchChangeKindType", + "type": "string" + } + }, + "required": ["type"], + "title": "UpdatePatchChangeKind", + "type": "object" + } + ] + }, + "ReasoningEffort": { + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, + "type": "string" + }, + "SandboxPolicy": { + "oneOf": [ + { + "properties": { + "type": { + "enum": ["dangerFullAccess"], + "title": "DangerFullAccessSandboxPolicyType", + "type": "string" + } + }, + "required": ["type"], + "title": "DangerFullAccessSandboxPolicy", + "type": "object" + }, + { + "properties": { + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "enum": ["readOnly"], + "title": "ReadOnlySandboxPolicyType", + "type": "string" + } + }, + "required": ["type"], + "title": "ReadOnlySandboxPolicy", + "type": "object" + }, + { + "properties": { + "networkAccess": { + "allOf": [ + { + "$ref": "#/definitions/NetworkAccess" + } + ], + "default": "restricted" + }, + "type": { + "enum": ["externalSandbox"], + "title": "ExternalSandboxSandboxPolicyType", + "type": "string" + } + }, + "required": ["type"], + "title": "ExternalSandboxSandboxPolicy", + "type": "object" + }, + { + "properties": { + "excludeSlashTmp": { + "default": false, + "type": "boolean" + }, + "excludeTmpdirEnvVar": { + "default": false, + "type": "boolean" + }, + "networkAccess": { + "default": false, + "type": "boolean" + }, + "type": { + "enum": ["workspaceWrite"], + "title": "WorkspaceWriteSandboxPolicyType", + "type": "string" + }, + "writableRoots": { + "default": [], + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + } + }, + "required": ["type"], + "title": "WorkspaceWriteSandboxPolicy", + "type": "object" + } + ] + }, + "SessionSource": { + "oneOf": [ + { + "enum": ["cli", "vscode", "exec", "appServer", "unknown"], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "custom": { + "type": "string" + } + }, + "required": ["custom"], + "title": "CustomSessionSource", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "subAgent": { + "$ref": "#/definitions/SubAgentSource" + } + }, + "required": ["subAgent"], + "title": "SubAgentSessionSource", + "type": "object" + } + ] + }, + "SubAgentActivityKind": { + "enum": ["started", "interacted", "interrupted", "completed"], + "type": "string" + }, + "SubAgentSource": { + "oneOf": [ + { + "enum": ["review", "compact", "memory_consolidation"], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "thread_spawn": { + "properties": { + "agent_nickname": { + "default": null, + "type": ["string", "null"] + }, + "agent_path": { + "anyOf": [ + { + "$ref": "#/definitions/AgentPath" + }, + { + "type": "null" + } + ], + "default": null + }, + "agent_role": { + "default": null, + "type": ["string", "null"] + }, + "depth": { + "format": "int32", + "type": "integer" + }, + "parent_thread_id": { + "$ref": "#/definitions/ThreadId" + } + }, + "required": ["depth", "parent_thread_id"], + "type": "object" + } + }, + "required": ["thread_spawn"], + "title": "ThreadSpawnSubAgentSource", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "other": { + "type": "string" + } + }, + "required": ["other"], + "title": "OtherSubAgentSource", + "type": "object" + } + ] + }, + "TextElement": { + "properties": { + "byteRange": { + "allOf": [ + { + "$ref": "#/definitions/ByteRange" + } + ], + "description": "Byte range in the parent `text` buffer that this element occupies." + }, + "placeholder": { + "description": "Optional human-readable placeholder for the element, displayed in the UI.", + "type": ["string", "null"] + } + }, + "required": ["byteRange"], + "type": "object" + }, + "Thread": { + "properties": { + "agentNickname": { + "description": "Optional random unique nickname assigned to an AgentControl-spawned sub-agent.", + "type": ["string", "null"] + }, + "agentRole": { + "description": "Optional role (agent_role) assigned to an AgentControl-spawned sub-agent.", + "type": ["string", "null"] + }, + "canAcceptDirectInput": { + "description": "Whether the app server accepts direct turn input for this loaded thread. `None` means the capability is unavailable, such as for an unloaded stored thread.", + "type": ["boolean", "null"] + }, + "cliVersion": { + "description": "Version of the CLI that created the thread.", + "type": "string" + }, + "createdAt": { + "description": "Unix timestamp (in seconds) when the thread was created.", + "format": "int64", + "type": "integer" + }, + "cwd": { + "allOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + } + ], + "description": "Working directory captured for the thread." + }, + "ephemeral": { + "description": "Whether the thread is ephemeral and should not be materialized on disk.", + "type": "boolean" + }, + "extra": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadExtra" + }, + { + "type": "null" + } + ], + "description": "Optional implementation-specific thread data." + }, + "forkedFromId": { + "description": "Source thread id when this thread was created by forking another thread.", + "type": ["string", "null"] + }, + "gitInfo": { + "anyOf": [ + { + "$ref": "#/definitions/GitInfo" + }, + { + "type": "null" + } + ], + "description": "Optional Git metadata captured when the thread was created." + }, + "historyMode": { + "allOf": [ + { + "$ref": "#/definitions/ThreadHistoryMode" + } + ], + "default": "legacy", + "description": "Persisted thread history contract selected when this thread was created." + }, + "id": { + "description": "Identifier for this thread. Codex-generated thread IDs are UUIDv7.", + "type": "string" + }, + "modelProvider": { + "description": "Model provider used for this thread (for example, 'openai').", + "type": "string" + }, + "name": { + "description": "Optional user-facing thread title.", + "type": ["string", "null"] + }, + "parentThreadId": { + "description": "The ID of the parent thread. This will only be set if this thread is a subagent.", + "type": ["string", "null"] + }, + "path": { + "description": "[UNSTABLE] Path to the thread on disk.", + "type": ["string", "null"] + }, + "preview": { + "description": "Usually the first user message in the thread, if available.", + "type": "string" + }, + "projectId": { + "description": "Canonical project assignment owned by app-server, if any.", + "type": ["string", "null"] + }, + "recencyAt": { + "description": "Unix timestamp (in seconds) used for thread recency ordering.", + "format": "int64", + "type": ["integer", "null"] + }, + "section": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSection" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The independently persisted section selected for this thread, if any." + }, + "sectionEnteredAt": { + "default": null, + "description": "Unix timestamp in seconds when the thread entered its current section.", + "format": "int64", + "type": ["integer", "null"] + }, + "sessionId": { + "description": "Session id shared by threads that belong to the same session tree.", + "type": "string" + }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/SessionSource" + } + ], + "description": "Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.)." + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/ThreadStatus" + } + ], + "description": "Current runtime status for the thread." + }, + "threadSource": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSource" + }, + { + "type": "null" + } + ], + "description": "Optional analytics source classification for this thread." + }, + "turns": { + "description": "Only populated on `thread/resume`, `thread/rollback`, `thread/fork`, and `thread/read` (when `includeTurns` is true) responses. For all other responses and notifications returning a Thread, the turns field will be an empty list.", + "items": { + "$ref": "#/definitions/Turn" + }, + "type": "array" + }, + "updatedAt": { + "description": "Unix timestamp (in seconds) when the thread was last updated.", + "format": "int64", + "type": "integer" + } + }, + "required": [ + "cliVersion", + "createdAt", + "cwd", + "ephemeral", + "id", + "modelProvider", + "preview", + "projectId", + "sessionId", + "source", + "status", + "turns", + "updatedAt" + ], + "type": "object" + }, + "ThreadActiveFlag": { + "enum": ["waitingOnApproval", "waitingOnUserInput"], + "type": "string" + }, + "ThreadExtra": { + "description": "Extra app-server data for a thread.", + "type": "object" + }, + "ThreadHistoryMode": { + "enum": ["legacy", "paginated"], + "type": "string" + }, + "ThreadId": { + "type": "string" + }, + "ThreadItem": { + "oneOf": [ + { + "properties": { + "clientId": { + "type": ["string", "null"] + }, + "content": { + "items": { + "$ref": "#/definitions/UserInput" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": ["userMessage"], + "title": "UserMessageThreadItemType", + "type": "string" + } + }, + "required": ["content", "id", "type"], + "title": "UserMessageThreadItem", + "type": "object" + }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": ["hookPrompt"], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": ["fragments", "id", "type"], + "title": "HookPromptThreadItem", + "type": "object" + }, + { + "properties": { + "delivery": { + "anyOf": [ + { + "$ref": "#/definitions/AgentMessageDelivery" + }, + { + "type": "null" + } + ], + "default": null + }, + "id": { + "type": "string" + }, + "memoryCitation": { + "anyOf": [ + { + "$ref": "#/definitions/MemoryCitation" + }, + { + "type": "null" + } + ], + "default": null + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, + "text": { + "type": "string" + }, + "type": { + "enum": ["agentMessage"], + "title": "AgentMessageThreadItemType", + "type": "string" + } + }, + "required": ["id", "text", "type"], + "title": "AgentMessageThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "namespace": { + "type": ["string", "null"] + }, + "output": { + "$ref": "#/definitions/FunctionCallOutputBody" + }, + "type": { + "enum": ["functionCallOutput"], + "title": "FunctionCallOutputThreadItemType", + "type": "string" + } + }, + "required": ["id", "name", "output", "type"], + "title": "FunctionCallOutputThreadItem", + "type": "object" + }, + { + "description": "EXPERIMENTAL - proposed plan item content. The completed plan item is authoritative and may not match the concatenation of `PlanDelta` text.", + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "type": { + "enum": ["plan"], + "title": "PlanThreadItemType", + "type": "string" + } + }, + "required": ["id", "text", "type"], + "title": "PlanThreadItem", + "type": "object" + }, + { + "properties": { + "content": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "summary": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "enum": ["reasoning"], + "title": "ReasoningThreadItemType", + "type": "string" + } + }, + "required": ["id", "type"], + "title": "ReasoningThreadItem", + "type": "object" + }, + { + "properties": { + "aggregatedOutput": { + "description": "The command's output, aggregated from stdout and stderr.", + "type": ["string", "null"] + }, + "command": { + "description": "The command to be executed.", + "type": "string" + }, + "commandActions": { + "description": "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", + "items": { + "$ref": "#/definitions/CommandAction" + }, + "type": "array" + }, + "cwd": { + "allOf": [ + { + "$ref": "#/definitions/LegacyAppPathString" + } + ], + "description": "The command's working directory." + }, + "durationMs": { + "description": "The duration of the command execution in milliseconds.", + "format": "int64", + "type": ["integer", "null"] + }, + "exitCode": { + "description": "The command's exit code.", + "format": "int32", + "type": ["integer", "null"] + }, + "id": { + "type": "string" + }, + "pluginId": { + "default": null, + "description": "Trusted first-party plugin id when this command resolves to one plugin script.", + "type": ["string", "null"] + }, + "processId": { + "description": "Identifier for the underlying PTY process (when available).", + "type": ["string", "null"] + }, + "scriptPath": { + "default": null, + "description": "Safe plugin-relative path when this command resolves to one plugin script.", + "type": ["string", "null"] + }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, + "status": { + "$ref": "#/definitions/CommandExecutionStatus" + }, + "type": { + "enum": ["commandExecution"], + "title": "CommandExecutionThreadItemType", + "type": "string" + } + }, + "required": ["command", "commandActions", "cwd", "id", "status", "type"], + "title": "CommandExecutionThreadItem", + "type": "object" + }, + { + "properties": { + "changes": { + "items": { + "$ref": "#/definitions/FileUpdateChange" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/PatchApplyStatus" + }, + "type": { + "enum": ["fileChange"], + "title": "FileChangeThreadItemType", + "type": "string" + } + }, + "required": ["changes", "id", "status", "type"], + "title": "FileChangeThreadItem", + "type": "object" + }, + { + "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, + "arguments": true, + "durationMs": { + "description": "The duration of the MCP tool call in milliseconds.", + "format": "int64", + "type": ["integer", "null"] + }, + "error": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallError" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", + "type": ["string", "null"] + }, + "pluginId": { + "type": ["string", "null"] + }, + "readOnlyHint": { + "type": ["boolean", "null"] + }, + "result": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallResult" + }, + { + "type": "null" + } + ] + }, + "server": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/McpToolCallStatus" + }, + "tool": { + "type": "string" + }, + "type": { + "enum": ["mcpToolCall"], + "title": "McpToolCallThreadItemType", + "type": "string" + } + }, + "required": ["arguments", "id", "server", "status", "tool", "type"], + "title": "McpToolCallThreadItem", + "type": "object" + }, + { + "properties": { + "arguments": true, + "contentItems": { + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + }, + "type": ["array", "null"] + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "format": "int64", + "type": ["integer", "null"] + }, + "id": { + "type": "string" + }, + "namespace": { + "type": ["string", "null"] + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": ["boolean", "null"] + }, + "tool": { + "type": "string" + }, + "type": { + "enum": ["dynamicToolCall"], + "title": "DynamicToolCallThreadItemType", + "type": "string" + } + }, + "required": ["arguments", "id", "status", "tool", "type"], + "title": "DynamicToolCallThreadItem", + "type": "object" + }, + { + "properties": { + "agentsStates": { + "additionalProperties": { + "$ref": "#/definitions/CollabAgentState" + }, + "description": "Last known status of the target agents, when available.", + "type": "object" + }, + "id": { + "description": "Unique identifier for this collab tool call.", + "type": "string" + }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": ["string", "null"] + }, + "prompt": { + "description": "Prompt text sent as part of the collab tool call, when available.", + "type": ["string", "null"] + }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ], + "description": "Reasoning effort requested for the spawned agent, when applicable." + }, + "receiverThreadIds": { + "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", + "items": { + "type": "string" + }, + "type": "array" + }, + "senderThreadId": { + "description": "Thread ID of the agent issuing the collab request.", + "type": "string" + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/CollabAgentToolCallStatus" + } + ], + "description": "Current status of the collab tool call." + }, + "tool": { + "allOf": [ + { + "$ref": "#/definitions/CollabAgentTool" + } + ], + "description": "Name of the collab tool that was invoked." + }, + "type": { + "enum": ["collabAgentToolCall"], + "title": "CollabAgentToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "agentsStates", + "id", + "receiverThreadIds", + "senderThreadId", + "status", + "tool", + "type" + ], + "title": "CollabAgentToolCallThreadItem", + "type": "object" + }, + { + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "enum": ["subAgentActivity"], + "title": "SubAgentActivityThreadItemType", + "type": "string" + } + }, + "required": ["agentPath", "agentThreadId", "id", "kind", "type"], + "title": "SubAgentActivityThreadItem", + "type": "object" + }, + { + "properties": { + "action": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchAction" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "query": { + "type": "string" + }, + "results": { + "default": null, + "description": "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.", + "items": true, + "type": ["array", "null"] + }, + "type": { + "enum": ["webSearch"], + "title": "WebSearchThreadItemType", + "type": "string" + } + }, + "required": ["id", "query", "type"], + "title": "WebSearchThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": { + "enum": ["imageView"], + "title": "ImageViewThreadItemType", + "type": "string" + } + }, + "required": ["id", "path", "type"], + "title": "ImageViewThreadItem", + "type": "object" + }, + { + "description": "Display item emitted by the interruptible `clock.sleep` tool.", + "properties": { + "durationMs": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "id": { + "type": "string" + }, + "type": { + "enum": ["sleep"], + "title": "SleepThreadItemType", + "type": "string" + } + }, + "required": ["durationMs", "id", "type"], + "title": "SleepThreadItem", + "type": "object" + }, + { + "properties": { + "failure": { + "anyOf": [ + { + "$ref": "#/definitions/ImageGenerationFailure" + }, + { + "type": "null" + } + ], + "default": null + }, + "id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "revisedPrompt": { + "type": ["string", "null"] + }, + "savedPath": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string" + }, + "transparentBackground": { + "default": null, + "type": ["boolean", "null"] + }, + "type": { + "enum": ["imageGeneration"], + "title": "ImageGenerationThreadItemType", + "type": "string" + } + }, + "required": ["id", "result", "status", "type"], + "title": "ImageGenerationThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "enum": ["enteredReviewMode"], + "title": "EnteredReviewModeThreadItemType", + "type": "string" + } + }, + "required": ["id", "review", "type"], + "title": "EnteredReviewModeThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "enum": ["exitedReviewMode"], + "title": "ExitedReviewModeThreadItemType", + "type": "string" + } + }, + "required": ["id", "review", "type"], + "title": "ExitedReviewModeThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "type": { + "enum": ["contextCompaction"], + "title": "ContextCompactionThreadItemType", + "type": "string" + } + }, + "required": ["id", "type"], + "title": "ContextCompactionThreadItem", + "type": "object" + } + ] + }, + "ThreadSection": { + "description": "An independently persisted, user-visible thread section.", + "properties": { + "appearance": { + "anyOf": [ + { + "$ref": "#/definitions/ThreadSectionAppearance" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional appearance synchronized across clients." + }, + "id": { + "description": "Opaque UUIDv7 identity that remains stable when the section is renamed.", + "type": "string" + }, + "name": { + "description": "The current user-visible section name.", + "type": "string" + } + }, + "required": ["id", "name"], + "type": "object" + }, + "ThreadSectionAppearance": { + "description": "Extensible visual presentation for a custom thread section.", + "properties": { + "color": { + "type": ["string", "null"] + }, + "icon": { + "type": ["string", "null"] + } + }, + "type": "object" + }, + "ThreadSource": { + "type": "string" + }, + "ThreadStatus": { + "oneOf": [ + { + "properties": { + "type": { + "enum": ["notLoaded"], + "title": "NotLoadedThreadStatusType", + "type": "string" + } + }, + "required": ["type"], + "title": "NotLoadedThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": ["idle"], + "title": "IdleThreadStatusType", + "type": "string" + } + }, + "required": ["type"], + "title": "IdleThreadStatus", + "type": "object" + }, + { + "properties": { + "type": { + "enum": ["systemError"], + "title": "SystemErrorThreadStatusType", + "type": "string" + } + }, + "required": ["type"], + "title": "SystemErrorThreadStatus", + "type": "object" + }, + { + "properties": { + "activeFlags": { + "items": { + "$ref": "#/definitions/ThreadActiveFlag" + }, + "type": "array" + }, + "type": { + "enum": ["active"], + "title": "ActiveThreadStatusType", + "type": "string" + } + }, + "required": ["activeFlags", "type"], + "title": "ActiveThreadStatus", + "type": "object" + } + ] + }, + "Turn": { + "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": ["integer", "null"] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": ["integer", "null"] + }, + "error": { + "anyOf": [ + { + "$ref": "#/definitions/TurnError" + }, + { + "type": "null" + } + ], + "description": "Only populated when the Turn's status is failed." + }, + "id": { + "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", + "type": "string" + }, + "items": { + "description": "Thread items currently included in this turn payload.", + "items": { + "$ref": "#/definitions/ThreadItem" + }, + "type": "array" + }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": ["integer", "null"] + }, + "status": { + "$ref": "#/definitions/TurnStatus" + } + }, + "required": ["id", "items", "status"], + "type": "object" + }, + "TurnError": { + "properties": { + "additionalDetails": { + "default": null, + "type": ["string", "null"] + }, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/definitions/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + }, + "misalignment": { + "anyOf": [ + { + "$ref": "#/definitions/MisalignmentErrorDetails" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional public explanation and continuation instruction for a misalignment block." + } + }, + "required": ["message"], + "type": "object" + }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": ["notLoaded"], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": ["summary"], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": ["full"], + "type": "string" + } + ] + }, + "TurnStatus": { + "enum": ["completed", "interrupted", "failed", "inProgress"], + "type": "string" + }, + "UserInput": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "text_elements": { + "default": [], + "description": "UI-defined spans within `text` used to render or persist special elements.", + "items": { + "$ref": "#/definitions/TextElement" + }, + "type": "array" + }, + "type": { + "enum": ["text"], + "title": "TextUserInputType", + "type": "string" + } + }, + "required": ["text", "type"], + "title": "TextUserInput", + "type": "object" + }, + { + "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, + "type": { + "enum": ["image"], + "title": "ImageUserInputType", + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["type", "url"], + "title": "ImageUserInput", + "type": "object" + }, + { + "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, + "path": { + "type": "string" + }, + "type": { + "enum": ["localImage"], + "title": "LocalImageUserInputType", + "type": "string" + } + }, + "required": ["path", "type"], + "title": "LocalImageUserInput", + "type": "object" + }, + { + "properties": { + "type": { + "enum": ["audio"], + "title": "AudioUserInputType", + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["type", "url"], + "title": "AudioUserInput", + "type": "object" + }, + { + "properties": { + "path": { + "type": "string" + }, + "type": { + "enum": ["localAudio"], + "title": "LocalAudioUserInputType", + "type": "string" + } + }, + "required": ["path", "type"], + "title": "LocalAudioUserInput", + "type": "object" + }, + { + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": ["skill"], + "title": "SkillUserInputType", + "type": "string" + } + }, + "required": ["name", "path", "type"], + "title": "SkillUserInput", + "type": "object" + }, + { + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": ["mention"], + "title": "MentionUserInputType", + "type": "string" + } + }, + "required": ["name", "path", "type"], + "title": "MentionUserInput", + "type": "object" + } + ] + }, + "WebSearchAction": { + "oneOf": [ + { + "properties": { + "queries": { + "items": { + "type": "string" + }, + "type": ["array", "null"] + }, + "query": { + "type": ["string", "null"] + }, + "type": { + "enum": ["search"], + "title": "SearchWebSearchActionType", + "type": "string" + } + }, + "required": ["type"], + "title": "SearchWebSearchAction", + "type": "object" + }, + { + "properties": { + "type": { + "enum": ["openPage"], + "title": "OpenPageWebSearchActionType", + "type": "string" + }, + "url": { + "type": ["string", "null"] + } + }, + "required": ["type"], + "title": "OpenPageWebSearchAction", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": ["string", "null"] + }, + "type": { + "enum": ["findInPage"], + "title": "FindInPageWebSearchActionType", + "type": "string" + }, + "url": { + "type": ["string", "null"] + } + }, + "required": ["type"], + "title": "FindInPageWebSearchAction", + "type": "object" + }, + { + "properties": { + "type": { + "enum": ["other"], + "title": "OtherWebSearchActionType", + "type": "string" + } + }, + "required": ["type"], + "title": "OtherWebSearchAction", + "type": "object" + } + ] + } + }, + "properties": { + "activePermissionProfile": { + "anyOf": [ + { + "$ref": "#/definitions/ActivePermissionProfile" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Named or implicit built-in profile that produced the active permissions, when known." + }, + "approvalPolicy": { + "$ref": "#/definitions/AskForApproval" + }, + "approvalsReviewer": { + "allOf": [ + { + "$ref": "#/definitions/ApprovalsReviewer" + } + ], + "description": "Reviewer currently used for approval requests on this thread." + }, + "cwd": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "instructionSources": { + "default": [], + "description": "Environment-native paths to instruction source files currently loaded for this thread.", + "items": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": "array" + }, + "model": { + "type": "string" + }, + "modelProvider": { + "type": "string" + }, + "multiAgentMode": { + "allOf": [ + { + "$ref": "#/definitions/MultiAgentMode" + } + ], + "default": "explicitRequestOnly", + "description": "@deprecated Always `explicitRequestOnly`. Use `reasoningEffort` for Ultra behavior." + }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ] + }, + "runtimeWorkspaceRoots": { + "default": [], + "description": "Thread-scoped runtime workspace roots used to materialize `:workspace_roots`.", + "items": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "type": "array" + }, + "sandbox": { + "allOf": [ + { + "$ref": "#/definitions/SandboxPolicy" + } + ], + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` for profile provenance." + }, + "serviceTier": { + "type": ["string", "null"] + }, + "thread": { + "$ref": "#/definitions/Thread" + } + }, + "required": [ + "approvalPolicy", + "approvalsReviewer", + "cwd", + "model", + "modelProvider", + "sandbox", + "thread" + ], + "title": "ThreadStartResponse", + "type": "object" +} diff --git a/src/runtimes/openai/generated-json-schema/TurnStartResponse.json b/src/runtimes/openai/generated-json-schema/TurnStartResponse.json new file mode 100644 index 0000000..06bb99b --- /dev/null +++ b/src/runtimes/openai/generated-json-schema/TurnStartResponse.json @@ -0,0 +1,1672 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "AbsolutePathBuf": { + "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", + "type": "string" + }, + "AgentMessageDelivery": { + "enum": ["async"], + "type": "string" + }, + "ByteRange": { + "properties": { + "end": { + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, + "start": { + "format": "uint", + "minimum": 0.0, + "type": "integer" + } + }, + "required": ["end", "start"], + "type": "object" + }, + "CodexErrorInfo": { + "description": "This translation layer make sure that we expose codex error code in camel case.\n\nWhen an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant.", + "oneOf": [ + { + "enum": [ + "contextWindowExceeded", + "sessionBudgetExceeded", + "usageLimitExceeded", + "rateLimitExceeded", + "serverOverloaded", + "cyberPolicy", + "misalignmentPolicyViolation", + "internalServerError", + "unauthorized", + "badRequest", + "threadRollbackFailed", + "sandboxError", + "other" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "httpConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": ["integer", "null"] + } + }, + "type": "object" + } + }, + "required": ["httpConnectionFailed"], + "title": "HttpConnectionFailedCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Failed to connect to the response SSE stream.", + "properties": { + "responseStreamConnectionFailed": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": ["integer", "null"] + } + }, + "type": "object" + } + }, + "required": ["responseStreamConnectionFailed"], + "title": "ResponseStreamConnectionFailedCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "The response SSE stream disconnected in the middle of a turn before completion.", + "properties": { + "responseStreamDisconnected": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": ["integer", "null"] + } + }, + "type": "object" + } + }, + "required": ["responseStreamDisconnected"], + "title": "ResponseStreamDisconnectedCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Reached the retry limit for responses.", + "properties": { + "responseTooManyFailedAttempts": { + "properties": { + "httpStatusCode": { + "format": "uint16", + "minimum": 0.0, + "type": ["integer", "null"] + } + }, + "type": "object" + } + }, + "required": ["responseTooManyFailedAttempts"], + "title": "ResponseTooManyFailedAttemptsCodexErrorInfo", + "type": "object" + }, + { + "additionalProperties": false, + "description": "Returned when `turn/start` or `turn/steer` is submitted while the current active turn cannot accept same-turn steering, for example `/review` or manual `/compact`.", + "properties": { + "activeTurnNotSteerable": { + "properties": { + "turnKind": { + "$ref": "#/definitions/NonSteerableTurnKind" + } + }, + "required": ["turnKind"], + "type": "object" + } + }, + "required": ["activeTurnNotSteerable"], + "title": "ActiveTurnNotSteerableCodexErrorInfo", + "type": "object" + } + ] + }, + "CollabAgentState": { + "properties": { + "message": { + "type": ["string", "null"] + }, + "status": { + "$ref": "#/definitions/CollabAgentStatus" + } + }, + "required": ["status"], + "type": "object" + }, + "CollabAgentStatus": { + "enum": [ + "pendingInit", + "running", + "interrupted", + "completed", + "errored", + "shutdown", + "notFound" + ], + "type": "string" + }, + "CollabAgentTool": { + "enum": [ + "spawnAgent", + "sendInput", + "resumeAgent", + "wait", + "closeAgent", + "sendMessage", + "followupTask", + "interruptAgent", + "listAgents" + ], + "type": "string" + }, + "CollabAgentToolCallStatus": { + "enum": ["inProgress", "completed", "failed", "interrupted"], + "type": "string" + }, + "CommandAction": { + "oneOf": [ + { + "properties": { + "command": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": { + "enum": ["read"], + "title": "ReadCommandActionType", + "type": "string" + } + }, + "required": ["command", "name", "path", "type"], + "title": "ReadCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": ["string", "null"] + }, + "type": { + "enum": ["listFiles"], + "title": "ListFilesCommandActionType", + "type": "string" + } + }, + "required": ["command", "type"], + "title": "ListFilesCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "path": { + "type": ["string", "null"] + }, + "query": { + "type": ["string", "null"] + }, + "type": { + "enum": ["search"], + "title": "SearchCommandActionType", + "type": "string" + } + }, + "required": ["command", "type"], + "title": "SearchCommandAction", + "type": "object" + }, + { + "properties": { + "command": { + "type": "string" + }, + "type": { + "enum": ["unknown"], + "title": "UnknownCommandActionType", + "type": "string" + } + }, + "required": ["command", "type"], + "title": "UnknownCommandAction", + "type": "object" + } + ] + }, + "CommandExecutionSource": { + "enum": ["agent", "userShell", "unifiedExecStartup", "unifiedExecInteraction"], + "type": "string" + }, + "CommandExecutionStatus": { + "enum": ["inProgress", "completed", "failed", "declined"], + "type": "string" + }, + "DynamicToolCallOutputContentItem": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": ["inputText"], + "title": "InputTextDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": ["text", "type"], + "title": "InputTextDynamicToolCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "imageUrl": { + "type": "string" + }, + "type": { + "enum": ["inputImage"], + "title": "InputImageDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": ["imageUrl", "type"], + "title": "InputImageDynamicToolCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "audioUrl": { + "type": "string" + }, + "type": { + "enum": ["inputAudio"], + "title": "InputAudioDynamicToolCallOutputContentItemType", + "type": "string" + } + }, + "required": ["audioUrl", "type"], + "title": "InputAudioDynamicToolCallOutputContentItem", + "type": "object" + } + ] + }, + "DynamicToolCallStatus": { + "enum": ["inProgress", "completed", "failed"], + "type": "string" + }, + "FileUpdateChange": { + "properties": { + "diff": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/PatchChangeKind" + }, + "path": { + "type": "string" + } + }, + "required": ["diff", "kind", "path"], + "type": "object" + }, + "FunctionCallOutputBody": { + "anyOf": [ + { + "type": "string" + }, + { + "items": { + "$ref": "#/definitions/FunctionCallOutputContentItem" + }, + "type": "array" + } + ] + }, + "FunctionCallOutputContentItem": { + "description": "Responses API compatible content items that can be returned by a tool call. This is a subset of ContentItem with the types we support as function call outputs.", + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "type": { + "enum": ["input_text"], + "title": "InputTextFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": ["text", "type"], + "title": "InputTextFunctionCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ] + }, + "image_url": { + "type": "string" + }, + "type": { + "enum": ["input_image"], + "title": "InputImageFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": ["image_url", "type"], + "title": "InputImageFunctionCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "audio_url": { + "type": "string" + }, + "type": { + "enum": ["input_audio"], + "title": "InputAudioFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": ["audio_url", "type"], + "title": "InputAudioFunctionCallOutputContentItem", + "type": "object" + }, + { + "properties": { + "encrypted_content": { + "type": "string" + }, + "type": { + "enum": ["encrypted_content"], + "title": "EncryptedContentFunctionCallOutputContentItemType", + "type": "string" + } + }, + "required": ["encrypted_content", "type"], + "title": "EncryptedContentFunctionCallOutputContentItem", + "type": "object" + } + ] + }, + "HookPromptFragment": { + "properties": { + "hookRunId": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": ["hookRunId", "text"], + "type": "object" + }, + "ImageDetail": { + "enum": ["auto", "low", "high", "original"], + "type": "string" + }, + "ImageGenerationFailure": { + "oneOf": [ + { + "properties": { + "limitId": { + "type": "string" + }, + "resetsAt": { + "format": "int64", + "type": ["integer", "null"] + }, + "type": { + "enum": ["usageLimitExceeded"], + "title": "UsageLimitExceededImageGenerationFailureType", + "type": "string" + } + }, + "required": ["limitId", "type"], + "title": "UsageLimitExceededImageGenerationFailure", + "type": "object" + } + ] + }, + "LegacyAppPathString": { + "type": "string" + }, + "McpToolCallAppContext": { + "properties": { + "actionName": { + "type": ["string", "null"] + }, + "appName": { + "type": ["string", "null"] + }, + "connectorId": { + "type": "string" + }, + "linkId": { + "type": ["string", "null"] + }, + "resourceUri": { + "type": ["string", "null"] + } + }, + "required": ["connectorId"], + "type": "object" + }, + "McpToolCallError": { + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "type": "object" + }, + "McpToolCallResult": { + "properties": { + "_meta": true, + "content": { + "items": true, + "type": "array" + }, + "structuredContent": true + }, + "required": ["content"], + "type": "object" + }, + "McpToolCallStatus": { + "enum": ["inProgress", "completed", "failed"], + "type": "string" + }, + "MemoryCitation": { + "properties": { + "entries": { + "items": { + "$ref": "#/definitions/MemoryCitationEntry" + }, + "type": "array" + }, + "threadIds": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": ["entries", "threadIds"], + "type": "object" + }, + "MemoryCitationEntry": { + "properties": { + "lineEnd": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "lineStart": { + "format": "uint32", + "minimum": 0.0, + "type": "integer" + }, + "note": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": ["lineEnd", "lineStart", "note", "path"], + "type": "object" + }, + "MessagePhase": { + "description": "Classifies an assistant message as interim commentary or final answer text.\n\nProviders do not emit this consistently, so callers must treat `None` as \"phase unknown\" and keep compatibility behavior for legacy models.", + "oneOf": [ + { + "description": "Mid-turn assistant text (for example preamble/progress narration).\n\nAdditional tool calls or assistant output may follow before turn completion.", + "enum": ["commentary"], + "type": "string" + }, + { + "description": "The assistant's terminal answer text for the current turn.", + "enum": ["final_answer"], + "type": "string" + } + ] + }, + "MisalignmentErrorDetails": { + "properties": { + "detailedExplanation": { + "description": "A substantive localized explanation is required before offering continuation.", + "type": ["string", "null"] + }, + "errorType": { + "description": "Open-ended classification; clients must accept categories added by Responses.", + "type": ["string", "null"] + }, + "steer": { + "anyOf": [ + { + "$ref": "#/definitions/MisalignmentSteer" + }, + { + "type": "null" + } + ], + "description": "Instruction to submit as the next turn's user input if continuation is confirmed." + } + }, + "type": "object" + }, + "MisalignmentSteer": { + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "type": "object" + }, + "NonSteerableTurnKind": { + "enum": ["review", "compact"], + "type": "string" + }, + "PatchApplyStatus": { + "enum": ["inProgress", "completed", "failed", "declined"], + "type": "string" + }, + "PatchChangeKind": { + "oneOf": [ + { + "properties": { + "type": { + "enum": ["add"], + "title": "AddPatchChangeKindType", + "type": "string" + } + }, + "required": ["type"], + "title": "AddPatchChangeKind", + "type": "object" + }, + { + "properties": { + "type": { + "enum": ["delete"], + "title": "DeletePatchChangeKindType", + "type": "string" + } + }, + "required": ["type"], + "title": "DeletePatchChangeKind", + "type": "object" + }, + { + "properties": { + "move_path": { + "type": ["string", "null"] + }, + "type": { + "enum": ["update"], + "title": "UpdatePatchChangeKindType", + "type": "string" + } + }, + "required": ["type"], + "title": "UpdatePatchChangeKind", + "type": "object" + } + ] + }, + "ReasoningEffort": { + "description": "A non-empty reasoning effort value advertised by the model.", + "minLength": 1, + "type": "string" + }, + "SubAgentActivityKind": { + "enum": ["started", "interacted", "interrupted", "completed"], + "type": "string" + }, + "TextElement": { + "properties": { + "byteRange": { + "allOf": [ + { + "$ref": "#/definitions/ByteRange" + } + ], + "description": "Byte range in the parent `text` buffer that this element occupies." + }, + "placeholder": { + "description": "Optional human-readable placeholder for the element, displayed in the UI.", + "type": ["string", "null"] + } + }, + "required": ["byteRange"], + "type": "object" + }, + "ThreadItem": { + "oneOf": [ + { + "properties": { + "clientId": { + "type": ["string", "null"] + }, + "content": { + "items": { + "$ref": "#/definitions/UserInput" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": ["userMessage"], + "title": "UserMessageThreadItemType", + "type": "string" + } + }, + "required": ["content", "id", "type"], + "title": "UserMessageThreadItem", + "type": "object" + }, + { + "properties": { + "fragments": { + "items": { + "$ref": "#/definitions/HookPromptFragment" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "type": { + "enum": ["hookPrompt"], + "title": "HookPromptThreadItemType", + "type": "string" + } + }, + "required": ["fragments", "id", "type"], + "title": "HookPromptThreadItem", + "type": "object" + }, + { + "properties": { + "delivery": { + "anyOf": [ + { + "$ref": "#/definitions/AgentMessageDelivery" + }, + { + "type": "null" + } + ], + "default": null + }, + "id": { + "type": "string" + }, + "memoryCitation": { + "anyOf": [ + { + "$ref": "#/definitions/MemoryCitation" + }, + { + "type": "null" + } + ], + "default": null + }, + "phase": { + "anyOf": [ + { + "$ref": "#/definitions/MessagePhase" + }, + { + "type": "null" + } + ], + "default": null + }, + "text": { + "type": "string" + }, + "type": { + "enum": ["agentMessage"], + "title": "AgentMessageThreadItemType", + "type": "string" + } + }, + "required": ["id", "text", "type"], + "title": "AgentMessageThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "namespace": { + "type": ["string", "null"] + }, + "output": { + "$ref": "#/definitions/FunctionCallOutputBody" + }, + "type": { + "enum": ["functionCallOutput"], + "title": "FunctionCallOutputThreadItemType", + "type": "string" + } + }, + "required": ["id", "name", "output", "type"], + "title": "FunctionCallOutputThreadItem", + "type": "object" + }, + { + "description": "EXPERIMENTAL - proposed plan item content. The completed plan item is authoritative and may not match the concatenation of `PlanDelta` text.", + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "type": { + "enum": ["plan"], + "title": "PlanThreadItemType", + "type": "string" + } + }, + "required": ["id", "text", "type"], + "title": "PlanThreadItem", + "type": "object" + }, + { + "properties": { + "content": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "summary": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "type": { + "enum": ["reasoning"], + "title": "ReasoningThreadItemType", + "type": "string" + } + }, + "required": ["id", "type"], + "title": "ReasoningThreadItem", + "type": "object" + }, + { + "properties": { + "aggregatedOutput": { + "description": "The command's output, aggregated from stdout and stderr.", + "type": ["string", "null"] + }, + "command": { + "description": "The command to be executed.", + "type": "string" + }, + "commandActions": { + "description": "A best-effort parsing of the command to understand the action(s) it will perform. This returns a list of CommandAction objects because a single shell command may be composed of many commands piped together.", + "items": { + "$ref": "#/definitions/CommandAction" + }, + "type": "array" + }, + "cwd": { + "allOf": [ + { + "$ref": "#/definitions/LegacyAppPathString" + } + ], + "description": "The command's working directory." + }, + "durationMs": { + "description": "The duration of the command execution in milliseconds.", + "format": "int64", + "type": ["integer", "null"] + }, + "exitCode": { + "description": "The command's exit code.", + "format": "int32", + "type": ["integer", "null"] + }, + "id": { + "type": "string" + }, + "pluginId": { + "default": null, + "description": "Trusted first-party plugin id when this command resolves to one plugin script.", + "type": ["string", "null"] + }, + "processId": { + "description": "Identifier for the underlying PTY process (when available).", + "type": ["string", "null"] + }, + "scriptPath": { + "default": null, + "description": "Safe plugin-relative path when this command resolves to one plugin script.", + "type": ["string", "null"] + }, + "source": { + "allOf": [ + { + "$ref": "#/definitions/CommandExecutionSource" + } + ], + "default": "agent" + }, + "status": { + "$ref": "#/definitions/CommandExecutionStatus" + }, + "type": { + "enum": ["commandExecution"], + "title": "CommandExecutionThreadItemType", + "type": "string" + } + }, + "required": ["command", "commandActions", "cwd", "id", "status", "type"], + "title": "CommandExecutionThreadItem", + "type": "object" + }, + { + "properties": { + "changes": { + "items": { + "$ref": "#/definitions/FileUpdateChange" + }, + "type": "array" + }, + "id": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/PatchApplyStatus" + }, + "type": { + "enum": ["fileChange"], + "title": "FileChangeThreadItemType", + "type": "string" + } + }, + "required": ["changes", "id", "status", "type"], + "title": "FileChangeThreadItem", + "type": "object" + }, + { + "properties": { + "appContext": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallAppContext" + }, + { + "type": "null" + } + ] + }, + "arguments": true, + "durationMs": { + "description": "The duration of the MCP tool call in milliseconds.", + "format": "int64", + "type": ["integer", "null"] + }, + "error": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallError" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "mcpAppResourceUri": { + "description": "Deprecated: use `appContext.resourceUri` instead.", + "type": ["string", "null"] + }, + "pluginId": { + "type": ["string", "null"] + }, + "readOnlyHint": { + "type": ["boolean", "null"] + }, + "result": { + "anyOf": [ + { + "$ref": "#/definitions/McpToolCallResult" + }, + { + "type": "null" + } + ] + }, + "server": { + "type": "string" + }, + "status": { + "$ref": "#/definitions/McpToolCallStatus" + }, + "tool": { + "type": "string" + }, + "type": { + "enum": ["mcpToolCall"], + "title": "McpToolCallThreadItemType", + "type": "string" + } + }, + "required": ["arguments", "id", "server", "status", "tool", "type"], + "title": "McpToolCallThreadItem", + "type": "object" + }, + { + "properties": { + "arguments": true, + "contentItems": { + "items": { + "$ref": "#/definitions/DynamicToolCallOutputContentItem" + }, + "type": ["array", "null"] + }, + "durationMs": { + "description": "The duration of the dynamic tool call in milliseconds.", + "format": "int64", + "type": ["integer", "null"] + }, + "id": { + "type": "string" + }, + "namespace": { + "type": ["string", "null"] + }, + "status": { + "$ref": "#/definitions/DynamicToolCallStatus" + }, + "success": { + "type": ["boolean", "null"] + }, + "tool": { + "type": "string" + }, + "type": { + "enum": ["dynamicToolCall"], + "title": "DynamicToolCallThreadItemType", + "type": "string" + } + }, + "required": ["arguments", "id", "status", "tool", "type"], + "title": "DynamicToolCallThreadItem", + "type": "object" + }, + { + "properties": { + "agentsStates": { + "additionalProperties": { + "$ref": "#/definitions/CollabAgentState" + }, + "description": "Last known status of the target agents, when available.", + "type": "object" + }, + "id": { + "description": "Unique identifier for this collab tool call.", + "type": "string" + }, + "model": { + "description": "Model requested for the spawned agent, when applicable.", + "type": ["string", "null"] + }, + "prompt": { + "description": "Prompt text sent as part of the collab tool call, when available.", + "type": ["string", "null"] + }, + "reasoningEffort": { + "anyOf": [ + { + "$ref": "#/definitions/ReasoningEffort" + }, + { + "type": "null" + } + ], + "description": "Reasoning effort requested for the spawned agent, when applicable." + }, + "receiverThreadIds": { + "description": "Thread ID of the receiving agent, when applicable. In case of spawn operation, this corresponds to the newly spawned agent.", + "items": { + "type": "string" + }, + "type": "array" + }, + "senderThreadId": { + "description": "Thread ID of the agent issuing the collab request.", + "type": "string" + }, + "status": { + "allOf": [ + { + "$ref": "#/definitions/CollabAgentToolCallStatus" + } + ], + "description": "Current status of the collab tool call." + }, + "tool": { + "allOf": [ + { + "$ref": "#/definitions/CollabAgentTool" + } + ], + "description": "Name of the collab tool that was invoked." + }, + "type": { + "enum": ["collabAgentToolCall"], + "title": "CollabAgentToolCallThreadItemType", + "type": "string" + } + }, + "required": [ + "agentsStates", + "id", + "receiverThreadIds", + "senderThreadId", + "status", + "tool", + "type" + ], + "title": "CollabAgentToolCallThreadItem", + "type": "object" + }, + { + "properties": { + "agentPath": { + "type": "string" + }, + "agentThreadId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "kind": { + "$ref": "#/definitions/SubAgentActivityKind" + }, + "type": { + "enum": ["subAgentActivity"], + "title": "SubAgentActivityThreadItemType", + "type": "string" + } + }, + "required": ["agentPath", "agentThreadId", "id", "kind", "type"], + "title": "SubAgentActivityThreadItem", + "type": "object" + }, + { + "properties": { + "action": { + "anyOf": [ + { + "$ref": "#/definitions/WebSearchAction" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "query": { + "type": "string" + }, + "results": { + "default": null, + "description": "Structured search results returned out-of-band by standalone web search.\n\nThese stay as opaque JSON at the extension/app-server boundary so new result fields and result types can pass through without a Codex release.", + "items": true, + "type": ["array", "null"] + }, + "type": { + "enum": ["webSearch"], + "title": "WebSearchThreadItemType", + "type": "string" + } + }, + "required": ["id", "query", "type"], + "title": "WebSearchThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "path": { + "$ref": "#/definitions/LegacyAppPathString" + }, + "type": { + "enum": ["imageView"], + "title": "ImageViewThreadItemType", + "type": "string" + } + }, + "required": ["id", "path", "type"], + "title": "ImageViewThreadItem", + "type": "object" + }, + { + "description": "Display item emitted by the interruptible `clock.sleep` tool.", + "properties": { + "durationMs": { + "format": "uint64", + "minimum": 0.0, + "type": "integer" + }, + "id": { + "type": "string" + }, + "type": { + "enum": ["sleep"], + "title": "SleepThreadItemType", + "type": "string" + } + }, + "required": ["durationMs", "id", "type"], + "title": "SleepThreadItem", + "type": "object" + }, + { + "properties": { + "failure": { + "anyOf": [ + { + "$ref": "#/definitions/ImageGenerationFailure" + }, + { + "type": "null" + } + ], + "default": null + }, + "id": { + "type": "string" + }, + "result": { + "type": "string" + }, + "revisedPrompt": { + "type": ["string", "null"] + }, + "savedPath": { + "anyOf": [ + { + "$ref": "#/definitions/AbsolutePathBuf" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string" + }, + "transparentBackground": { + "default": null, + "type": ["boolean", "null"] + }, + "type": { + "enum": ["imageGeneration"], + "title": "ImageGenerationThreadItemType", + "type": "string" + } + }, + "required": ["id", "result", "status", "type"], + "title": "ImageGenerationThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "enum": ["enteredReviewMode"], + "title": "EnteredReviewModeThreadItemType", + "type": "string" + } + }, + "required": ["id", "review", "type"], + "title": "EnteredReviewModeThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "review": { + "type": "string" + }, + "type": { + "enum": ["exitedReviewMode"], + "title": "ExitedReviewModeThreadItemType", + "type": "string" + } + }, + "required": ["id", "review", "type"], + "title": "ExitedReviewModeThreadItem", + "type": "object" + }, + { + "properties": { + "id": { + "type": "string" + }, + "type": { + "enum": ["contextCompaction"], + "title": "ContextCompactionThreadItemType", + "type": "string" + } + }, + "required": ["id", "type"], + "title": "ContextCompactionThreadItem", + "type": "object" + } + ] + }, + "Turn": { + "properties": { + "completedAt": { + "description": "Unix timestamp (in seconds) when the turn completed.", + "format": "int64", + "type": ["integer", "null"] + }, + "durationMs": { + "description": "Duration between turn start and completion in milliseconds, if known.", + "format": "int64", + "type": ["integer", "null"] + }, + "error": { + "anyOf": [ + { + "$ref": "#/definitions/TurnError" + }, + { + "type": "null" + } + ], + "description": "Only populated when the Turn's status is failed." + }, + "id": { + "description": "Identifier for this turn. Codex-generated turn IDs are UUIDv7.", + "type": "string" + }, + "items": { + "description": "Thread items currently included in this turn payload.", + "items": { + "$ref": "#/definitions/ThreadItem" + }, + "type": "array" + }, + "itemsView": { + "allOf": [ + { + "$ref": "#/definitions/TurnItemsView" + } + ], + "default": "full", + "description": "Describes how much of `items` has been loaded for this turn." + }, + "startedAt": { + "description": "Unix timestamp (in seconds) when the turn started.", + "format": "int64", + "type": ["integer", "null"] + }, + "status": { + "$ref": "#/definitions/TurnStatus" + } + }, + "required": ["id", "items", "status"], + "type": "object" + }, + "TurnError": { + "properties": { + "additionalDetails": { + "default": null, + "type": ["string", "null"] + }, + "codexErrorInfo": { + "anyOf": [ + { + "$ref": "#/definitions/CodexErrorInfo" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + }, + "misalignment": { + "anyOf": [ + { + "$ref": "#/definitions/MisalignmentErrorDetails" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional public explanation and continuation instruction for a misalignment block." + } + }, + "required": ["message"], + "type": "object" + }, + "TurnItemsView": { + "oneOf": [ + { + "description": "`items` was not loaded for this turn. The field is intentionally empty.", + "enum": ["notLoaded"], + "type": "string" + }, + { + "description": "`items` contains only a display summary for this turn.", + "enum": ["summary"], + "type": "string" + }, + { + "description": "`items` contains every ThreadItem available from persisted app-server history for this turn.", + "enum": ["full"], + "type": "string" + } + ] + }, + "TurnStatus": { + "enum": ["completed", "interrupted", "failed", "inProgress"], + "type": "string" + }, + "UserInput": { + "oneOf": [ + { + "properties": { + "text": { + "type": "string" + }, + "text_elements": { + "default": [], + "description": "UI-defined spans within `text` used to render or persist special elements.", + "items": { + "$ref": "#/definitions/TextElement" + }, + "type": "array" + }, + "type": { + "enum": ["text"], + "title": "TextUserInputType", + "type": "string" + } + }, + "required": ["text", "type"], + "title": "TextUserInput", + "type": "object" + }, + { + "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, + "type": { + "enum": ["image"], + "title": "ImageUserInputType", + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["type", "url"], + "title": "ImageUserInput", + "type": "object" + }, + { + "properties": { + "detail": { + "anyOf": [ + { + "$ref": "#/definitions/ImageDetail" + }, + { + "type": "null" + } + ], + "default": null + }, + "path": { + "type": "string" + }, + "type": { + "enum": ["localImage"], + "title": "LocalImageUserInputType", + "type": "string" + } + }, + "required": ["path", "type"], + "title": "LocalImageUserInput", + "type": "object" + }, + { + "properties": { + "type": { + "enum": ["audio"], + "title": "AudioUserInputType", + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": ["type", "url"], + "title": "AudioUserInput", + "type": "object" + }, + { + "properties": { + "path": { + "type": "string" + }, + "type": { + "enum": ["localAudio"], + "title": "LocalAudioUserInputType", + "type": "string" + } + }, + "required": ["path", "type"], + "title": "LocalAudioUserInput", + "type": "object" + }, + { + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": ["skill"], + "title": "SkillUserInputType", + "type": "string" + } + }, + "required": ["name", "path", "type"], + "title": "SkillUserInput", + "type": "object" + }, + { + "properties": { + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "type": { + "enum": ["mention"], + "title": "MentionUserInputType", + "type": "string" + } + }, + "required": ["name", "path", "type"], + "title": "MentionUserInput", + "type": "object" + } + ] + }, + "WebSearchAction": { + "oneOf": [ + { + "properties": { + "queries": { + "items": { + "type": "string" + }, + "type": ["array", "null"] + }, + "query": { + "type": ["string", "null"] + }, + "type": { + "enum": ["search"], + "title": "SearchWebSearchActionType", + "type": "string" + } + }, + "required": ["type"], + "title": "SearchWebSearchAction", + "type": "object" + }, + { + "properties": { + "type": { + "enum": ["openPage"], + "title": "OpenPageWebSearchActionType", + "type": "string" + }, + "url": { + "type": ["string", "null"] + } + }, + "required": ["type"], + "title": "OpenPageWebSearchAction", + "type": "object" + }, + { + "properties": { + "pattern": { + "type": ["string", "null"] + }, + "type": { + "enum": ["findInPage"], + "title": "FindInPageWebSearchActionType", + "type": "string" + }, + "url": { + "type": ["string", "null"] + } + }, + "required": ["type"], + "title": "FindInPageWebSearchAction", + "type": "object" + }, + { + "properties": { + "type": { + "enum": ["other"], + "title": "OtherWebSearchActionType", + "type": "string" + } + }, + "required": ["type"], + "title": "OtherWebSearchAction", + "type": "object" + } + ] + } + }, + "properties": { + "turn": { + "$ref": "#/definitions/Turn" + } + }, + "required": ["turn"], + "title": "TurnStartResponse", + "type": "object" +} diff --git a/src/runtimes/openai/generated/AbsolutePathBuf.ts b/src/runtimes/openai/generated/AbsolutePathBuf.ts new file mode 100644 index 0000000..dc1cde1 --- /dev/null +++ b/src/runtimes/openai/generated/AbsolutePathBuf.ts @@ -0,0 +1,14 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A path that is guaranteed to be absolute and normalized (though it is not + * guaranteed to be canonicalized or exist on the filesystem). + * + * IMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set + * using [AbsolutePathBufGuard::new]. If no base path is set, the + * deserialization will fail unless the path being deserialized is already + * absolute. + */ +export type AbsolutePathBuf = string; diff --git a/src/runtimes/openai/generated/AgentMessageInputContent.ts b/src/runtimes/openai/generated/AgentMessageInputContent.ts new file mode 100644 index 0000000..400783e --- /dev/null +++ b/src/runtimes/openai/generated/AgentMessageInputContent.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AgentMessageInputContent = + | { type: "input_text"; text: string } + | { type: "encrypted_content"; encrypted_content: string }; diff --git a/src/runtimes/openai/generated/ClientInfo.ts b/src/runtimes/openai/generated/ClientInfo.ts new file mode 100644 index 0000000..b3871d6 --- /dev/null +++ b/src/runtimes/openai/generated/ClientInfo.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ClientInfo = { name: string; title: string | null; version: string }; diff --git a/src/runtimes/openai/generated/CollaborationMode.ts b/src/runtimes/openai/generated/CollaborationMode.ts new file mode 100644 index 0000000..c021ade --- /dev/null +++ b/src/runtimes/openai/generated/CollaborationMode.ts @@ -0,0 +1,10 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ModeKind } from "./ModeKind"; +import type { Settings } from "./Settings"; + +/** + * Collaboration mode for a Codex session. + */ +export type CollaborationMode = { mode: ModeKind; settings: Settings }; diff --git a/src/runtimes/openai/generated/ContentItem.ts b/src/runtimes/openai/generated/ContentItem.ts new file mode 100644 index 0000000..03b45e9 --- /dev/null +++ b/src/runtimes/openai/generated/ContentItem.ts @@ -0,0 +1,10 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ImageDetail } from "./ImageDetail"; + +export type ContentItem = + | { type: "input_text"; text: string } + | { type: "input_image"; image_url: string; detail?: ImageDetail } + | { type: "input_audio"; audio_url: string } + | { type: "output_text"; text: string }; diff --git a/src/runtimes/openai/generated/FunctionCallOutputBody.ts b/src/runtimes/openai/generated/FunctionCallOutputBody.ts new file mode 100644 index 0000000..6bcb7e2 --- /dev/null +++ b/src/runtimes/openai/generated/FunctionCallOutputBody.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FunctionCallOutputContentItem } from "./FunctionCallOutputContentItem"; + +export type FunctionCallOutputBody = string | Array; diff --git a/src/runtimes/openai/generated/FunctionCallOutputContentItem.ts b/src/runtimes/openai/generated/FunctionCallOutputContentItem.ts new file mode 100644 index 0000000..fc3a7ef --- /dev/null +++ b/src/runtimes/openai/generated/FunctionCallOutputContentItem.ts @@ -0,0 +1,14 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ImageDetail } from "./ImageDetail"; + +/** + * Responses API compatible content items that can be returned by a tool call. + * This is a subset of ContentItem with the types we support as function call outputs. + */ +export type FunctionCallOutputContentItem = + | { type: "input_text"; text: string } + | { type: "input_image"; image_url: string; detail?: ImageDetail } + | { type: "input_audio"; audio_url: string } + | { type: "encrypted_content"; encrypted_content: string }; diff --git a/src/runtimes/openai/generated/ImageDetail.ts b/src/runtimes/openai/generated/ImageDetail.ts new file mode 100644 index 0000000..a48f07c --- /dev/null +++ b/src/runtimes/openai/generated/ImageDetail.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ImageDetail = "auto" | "low" | "high" | "original"; diff --git a/src/runtimes/openai/generated/InitializeCapabilities.ts b/src/runtimes/openai/generated/InitializeCapabilities.ts new file mode 100644 index 0000000..8639419 --- /dev/null +++ b/src/runtimes/openai/generated/InitializeCapabilities.ts @@ -0,0 +1,33 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "./serde_json/JsonValue"; + +/** + * Client-declared capabilities negotiated during initialize. + */ +export type InitializeCapabilities = { + /** + * Opt into receiving experimental API methods and fields. + */ + experimentalApi: boolean; + /** + * Opt into `attestation/generate` requests for upstream `x-oai-attestation`. + */ + requestAttestation: boolean; + /** + * Legacy opt-in for the `openai/form` MCP extension. + * + * New clients should declare `openai/form` in [`Self::extensions`]. + */ + mcpServerOpenaiFormElicitation?: boolean; + /** + * Exact notification method names that should be suppressed for this + * connection (for example `thread/started`). + */ + optOutNotificationMethods?: Array | null; + /** + * MCP extension settings declared by the app-server client. + */ + extensions?: { [key in string]?: JsonValue } | null; +}; diff --git a/src/runtimes/openai/generated/InitializeParams.ts b/src/runtimes/openai/generated/InitializeParams.ts new file mode 100644 index 0000000..6502fa4 --- /dev/null +++ b/src/runtimes/openai/generated/InitializeParams.ts @@ -0,0 +1,10 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ClientInfo } from "./ClientInfo"; +import type { InitializeCapabilities } from "./InitializeCapabilities"; + +export type InitializeParams = { + clientInfo: ClientInfo; + capabilities: InitializeCapabilities | null; +}; diff --git a/src/runtimes/openai/generated/InternalChatMessageMetadataPassthrough.ts b/src/runtimes/openai/generated/InternalChatMessageMetadataPassthrough.ts new file mode 100644 index 0000000..dff2550 --- /dev/null +++ b/src/runtimes/openai/generated/InternalChatMessageMetadataPassthrough.ts @@ -0,0 +1,11 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Internal Responses API passthrough metadata copied into underlying chat messages. + * + * Responses API strongly types this payload. Do not modify it without first getting API + * approval and making the corresponding Responses API change. + */ +export type InternalChatMessageMetadataPassthrough = { turn_id?: string }; diff --git a/src/runtimes/openai/generated/LegacyAppPathString.ts b/src/runtimes/openai/generated/LegacyAppPathString.ts new file mode 100644 index 0000000..5c0a1b1 --- /dev/null +++ b/src/runtimes/openai/generated/LegacyAppPathString.ts @@ -0,0 +1,27 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A UTF-8 path for preserving raw path compatibility at the app-server API + * boundary while Codex migrates to [`PathUri`]. + * + * Supports storing arbitrary strings read from the API and converting to and + * from [`PathUri`] using an explicitly selected native path convention. + * + * When converting from [`PathUri`], "native" refers to the supplied + * [`PathConvention`], which may be foreign to the operating system running + * this process. The inner string is private so path-producing code must use a + * path conversion method instead of bypassing the intended conversion + * boundary. Non-UTF-8 paths are converted to UTF-8 lossily because this API + * value is serialized as a JSON string. + * + * Deserialization and [`Self::from_string`] accept any UTF-8 string without + * interpreting or validating it. Use [`Self::from_string`] when a caller + * already owns legacy app-server path text and needs to preserve its wire + * spelling; use [`Self::from_path`], [`Self::from_abs_path`], or + * [`Self::from_path_uri`] when converting an actual path value. Relative + * path text remains valid until an operation such as [`Self::to_path_uri`] + * requires an absolute path. + */ +export type LegacyAppPathString = string; diff --git a/src/runtimes/openai/generated/LocalShellAction.ts b/src/runtimes/openai/generated/LocalShellAction.ts new file mode 100644 index 0000000..271c098 --- /dev/null +++ b/src/runtimes/openai/generated/LocalShellAction.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { LocalShellExecAction } from "./LocalShellExecAction"; + +export type LocalShellAction = { type: "exec" } & LocalShellExecAction; diff --git a/src/runtimes/openai/generated/LocalShellExecAction.ts b/src/runtimes/openai/generated/LocalShellExecAction.ts new file mode 100644 index 0000000..5faf962 --- /dev/null +++ b/src/runtimes/openai/generated/LocalShellExecAction.ts @@ -0,0 +1,11 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type LocalShellExecAction = { + command: Array; + timeout_ms: bigint | null; + working_directory: string | null; + env: { [key in string]?: string } | null; + user: string | null; +}; diff --git a/src/runtimes/openai/generated/LocalShellStatus.ts b/src/runtimes/openai/generated/LocalShellStatus.ts new file mode 100644 index 0000000..00db484 --- /dev/null +++ b/src/runtimes/openai/generated/LocalShellStatus.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type LocalShellStatus = "completed" | "in_progress" | "incomplete"; diff --git a/src/runtimes/openai/generated/MessagePhase.ts b/src/runtimes/openai/generated/MessagePhase.ts new file mode 100644 index 0000000..9e16021 --- /dev/null +++ b/src/runtimes/openai/generated/MessagePhase.ts @@ -0,0 +1,11 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Classifies an assistant message as interim commentary or final answer text. + * + * Providers do not emit this consistently, so callers must treat `None` as + * "phase unknown" and keep compatibility behavior for legacy models. + */ +export type MessagePhase = "commentary" | "final_answer"; diff --git a/src/runtimes/openai/generated/ModeKind.ts b/src/runtimes/openai/generated/ModeKind.ts new file mode 100644 index 0000000..7d2324a --- /dev/null +++ b/src/runtimes/openai/generated/ModeKind.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Initial collaboration mode to use when the TUI starts. + */ +export type ModeKind = "plan" | "default"; diff --git a/src/runtimes/openai/generated/MultiAgentMode.ts b/src/runtimes/openai/generated/MultiAgentMode.ts new file mode 100644 index 0000000..2c67e5e --- /dev/null +++ b/src/runtimes/openai/generated/MultiAgentMode.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Controls the effective multi-agent delegation instructions for a turn. `custom` means the + * configured mode hint defines the policy instead of a built-in policy. + */ +export type MultiAgentMode = { custom: string } | "explicitRequestOnly" | "proactive"; diff --git a/src/runtimes/openai/generated/Personality.ts b/src/runtimes/openai/generated/Personality.ts new file mode 100644 index 0000000..45165f4 --- /dev/null +++ b/src/runtimes/openai/generated/Personality.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type Personality = "none" | "friendly" | "pragmatic"; diff --git a/src/runtimes/openai/generated/ProtocolMethods.ts b/src/runtimes/openai/generated/ProtocolMethods.ts new file mode 100644 index 0000000..e38094e --- /dev/null +++ b/src/runtimes/openai/generated/ProtocolMethods.ts @@ -0,0 +1,98 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// Derived from the matching runtime JSON Schemas by scripts/sync-openai-generated.mjs. +export type ServerNotificationMethod = + | "error" + | "thread/started" + | "thread/status/changed" + | "thread/archived" + | "thread/deleted" + | "thread/unarchived" + | "thread/closed" + | "thread/reverted" + | "skills/changed" + | "thread/name/updated" + | "thread/goal/updated" + | "thread/goal/cleared" + | "thread/queue/changed" + | "project/changed" + | "thread/project/updated" + | "thread/environment/connected" + | "thread/environment/disconnected" + | "thread/settings/updated" + | "thread/tokenUsage/updated" + | "turn/started" + | "hook/started" + | "turn/completed" + | "hook/completed" + | "turn/diff/updated" + | "turn/plan/updated" + | "item/started" + | "item/autoApprovalReview/started" + | "item/autoApprovalReview/completed" + | "autoApprovalReview/strictReviewRequired" + | "item/completed" + | "item/agentMessage/delta" + | "item/plan/delta" + | "command/exec/outputDelta" + | "process/outputDelta" + | "process/exited" + | "item/commandExecution/outputDelta" + | "item/commandExecution/terminalInteraction" + | "item/fileChange/outputDelta" + | "item/fileChange/patchUpdated" + | "serverRequest/resolved" + | "item/mcpToolCall/progress" + | "mcpServer/oauthLogin/completed" + | "mcpServer/startupStatus/updated" + | "mcpServer/event/stream/notification" + | "account/updated" + | "account/rateLimits/updated" + | "app/list/updated" + | "remoteControl/status/changed" + | "externalAgentConfig/import/progress" + | "externalAgentConfig/import/completed" + | "fs/changed" + | "item/reasoning/summaryTextDelta" + | "item/reasoning/summaryPartAdded" + | "item/reasoning/textDelta" + | "thread/compacted" + | "model/rerouted" + | "model/verification" + | "modelProvider/authRecoveryStarted" + | "modelProvider/authRecoveryCompleted" + | "turn/moderationMetadata" + | "model/safetyBuffering/updated" + | "warning" + | "guardianWarning" + | "deprecationNotice" + | "configWarning" + | "fuzzyFileSearch/sessionUpdated" + | "fuzzyFileSearch/sessionCompleted" + | "thread/realtime/started" + | "thread/realtime/itemAdded" + | "thread/realtime/item/started" + | "thread/realtime/item/transcript/delta" + | "thread/realtime/item/completed" + | "thread/realtime/transcript/delta" + | "thread/realtime/transcript/done" + | "thread/realtime/outputAudio/delta" + | "thread/realtime/sdp" + | "thread/realtime/error" + | "thread/realtime/closed" + | "windows/worldWritableWarning" + | "windowsSandbox/setupCompleted" + | "account/login/completed"; + +export type ServerRequestMethod = + | "item/commandExecution/requestApproval" + | "item/fileChange/requestApproval" + | "item/tool/requestUserInput" + | "mcpServer/elicitation/request" + | "item/permissions/requestApproval" + | "item/tool/call" + | "account/chatgptAuthTokens/refresh" + | "attestation/generate" + | "currentTime/read" + | "applyPatchApproval" + | "execCommandApproval"; diff --git a/src/runtimes/openai/generated/README.md b/src/runtimes/openai/generated/README.md index 2e84430..5a6bab2 100644 --- a/src/runtimes/openai/generated/README.md +++ b/src/runtimes/openai/generated/README.md @@ -1,13 +1,17 @@ -# OpenAI app-server protocol +# OpenAI app-server protocol types -The TypeScript files in this directory are generated as one atomic schema set. +This directory contains the transitive closure of the OpenAI app-server types imported by the driver. -They correspond to OpenAI app-server runtime version `0.144.5`, which is also pinned by the SDK dependency and container image. +They correspond to OpenAI app-server runtime version `0.152.0`, which is also pinned by the CLI dependency and container image. -Regenerate them with the matching runtime: +Runtime validation and the complete supported server method sets come from the adjacent JSON Schemas. + +Regenerate the selected schemas and reachable TypeScript files with the matching runtime: ```sh -codex app-server generate-ts --out src/runtimes/openai/generated +bun scripts/sync-openai-generated.mjs ``` -Do not split or edit individual generated TypeScript files by hand. +The synchronization script follows every direct generated-type import from `app-server-protocol-types.ts`, so upstream fields and their complete dependency closure are retained automatically. + +Do not edit individual generated TypeScript files by hand. diff --git a/src/runtimes/openai/generated/ReasoningEffort.ts b/src/runtimes/openai/generated/ReasoningEffort.ts new file mode 100644 index 0000000..d40f5bd --- /dev/null +++ b/src/runtimes/openai/generated/ReasoningEffort.ts @@ -0,0 +1,8 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning + */ +export type ReasoningEffort = string; diff --git a/src/runtimes/openai/generated/ReasoningItemContent.ts b/src/runtimes/openai/generated/ReasoningItemContent.ts new file mode 100644 index 0000000..1583fa4 --- /dev/null +++ b/src/runtimes/openai/generated/ReasoningItemContent.ts @@ -0,0 +1,7 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ReasoningItemContent = + | { type: "reasoning_text"; text: string } + | { type: "text"; text: string }; diff --git a/src/runtimes/openai/generated/ReasoningItemReasoningSummary.ts b/src/runtimes/openai/generated/ReasoningItemReasoningSummary.ts new file mode 100644 index 0000000..cd7cf0a --- /dev/null +++ b/src/runtimes/openai/generated/ReasoningItemReasoningSummary.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ReasoningItemReasoningSummary = { type: "summary_text"; text: string }; diff --git a/src/runtimes/openai/generated/ReasoningSummary.ts b/src/runtimes/openai/generated/ReasoningSummary.ts new file mode 100644 index 0000000..d246ac1 --- /dev/null +++ b/src/runtimes/openai/generated/ReasoningSummary.ts @@ -0,0 +1,10 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A summary of the reasoning performed by the model. This can be useful for + * debugging and understanding the model's reasoning process. + * See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#reasoning-summaries + */ +export type ReasoningSummary = "auto" | "concise" | "detailed" | "none"; diff --git a/src/runtimes/openai/generated/RequestId.ts b/src/runtimes/openai/generated/RequestId.ts new file mode 100644 index 0000000..8a771bd --- /dev/null +++ b/src/runtimes/openai/generated/RequestId.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type RequestId = string | number; diff --git a/src/runtimes/openai/generated/ResponseItem.ts b/src/runtimes/openai/generated/ResponseItem.ts new file mode 100644 index 0000000..b7c211b --- /dev/null +++ b/src/runtimes/openai/generated/ResponseItem.ts @@ -0,0 +1,138 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AgentMessageInputContent } from "./AgentMessageInputContent"; +import type { ContentItem } from "./ContentItem"; +import type { FunctionCallOutputBody } from "./FunctionCallOutputBody"; +import type { InternalChatMessageMetadataPassthrough } from "./InternalChatMessageMetadataPassthrough"; +import type { LocalShellAction } from "./LocalShellAction"; +import type { LocalShellStatus } from "./LocalShellStatus"; +import type { MessagePhase } from "./MessagePhase"; +import type { ReasoningItemContent } from "./ReasoningItemContent"; +import type { ReasoningItemReasoningSummary } from "./ReasoningItemReasoningSummary"; +import type { ResponseItemId } from "./ResponseItemId"; +import type { WebSearchAction } from "./WebSearchAction"; + +export type ResponseItem = + | { + type: "message"; + id?: ResponseItemId; + role: string; + content: Array; + phase?: MessagePhase; + internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough; + } + | { + type: "agent_message"; + id?: ResponseItemId; + author: string; + recipient: string; + content: Array; + internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough; + } + | { + type: "reasoning"; + id?: ResponseItemId; + summary: Array; + content?: Array; + encrypted_content: string | null; + internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough; + } + | { + type: "local_shell_call"; + /** + * Legacy id field retained for compatibility with older payloads. + */ + id?: ResponseItemId; + /** + * Set when using the Responses API. + */ + call_id: string | null; + status: LocalShellStatus; + action: LocalShellAction; + internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough; + } + | { + type: "function_call"; + id?: ResponseItemId; + name: string; + namespace?: string; + arguments: string; + encrypted_function_args?: Array; + call_id: string; + internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough; + } + | { + type: "tool_search_call"; + id?: ResponseItemId; + call_id: string | null; + status?: string; + execution: string; + arguments: unknown; + internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough; + } + | { + type: "function_call_output"; + id?: ResponseItemId; + call_id?: string; + name?: string; + namespace?: string; + output: FunctionCallOutputBody; + internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough; + } + | { + type: "custom_tool_call"; + id?: ResponseItemId; + status?: string; + call_id: string; + name: string; + namespace?: string; + input: string; + internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough; + } + | { + type: "custom_tool_call_output"; + id?: ResponseItemId; + call_id: string; + name?: string; + output: FunctionCallOutputBody; + internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough; + } + | { + type: "tool_search_output"; + id?: ResponseItemId; + call_id: string | null; + status: string; + execution: string; + tools: unknown[]; + internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough; + } + | { + type: "web_search_call"; + id?: ResponseItemId; + status?: string; + action?: WebSearchAction; + internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough; + } + | { + type: "image_generation_call"; + id?: ResponseItemId; + status: string; + revised_prompt?: string; + result: string; + internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough; + } + | { + type: "compaction"; + id?: ResponseItemId; + encrypted_content: string; + internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough; + } + | { type: "compaction_trigger" } + | { + type: "context_compaction"; + id?: ResponseItemId; + encrypted_content?: string; + internal_chat_message_metadata_passthrough?: InternalChatMessageMetadataPassthrough; + } + | { type: "other" }; diff --git a/src/runtimes/openai/generated/ResponseItemId.ts b/src/runtimes/openai/generated/ResponseItemId.ts new file mode 100644 index 0000000..c4f17ec --- /dev/null +++ b/src/runtimes/openai/generated/ResponseItemId.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * A Responses API item ID. New IDs require an explicit prefix; deserialization + * remains permissive so legacy rollouts can still be read. + */ +export type ResponseItemId = string; diff --git a/src/runtimes/openai/generated/Settings.ts b/src/runtimes/openai/generated/Settings.ts new file mode 100644 index 0000000..098baf0 --- /dev/null +++ b/src/runtimes/openai/generated/Settings.ts @@ -0,0 +1,13 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ReasoningEffort } from "./ReasoningEffort"; + +/** + * Settings for a collaboration mode. + */ +export type Settings = { + model: string; + reasoning_effort: ReasoningEffort | null; + developer_instructions: string | null; +}; diff --git a/src/runtimes/openai/generated/WebSearchAction.ts b/src/runtimes/openai/generated/WebSearchAction.ts new file mode 100644 index 0000000..3cae0b5 --- /dev/null +++ b/src/runtimes/openai/generated/WebSearchAction.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type WebSearchAction = + | { type: "search"; query?: string; queries?: Array } + | { type: "open_page"; url?: string } + | { type: "find_in_page"; url?: string; pattern?: string } + | { type: "other" }; diff --git a/src/runtimes/openai/generated/app-server-protocol-client.ts b/src/runtimes/openai/generated/app-server-protocol-client.ts deleted file mode 100644 index 07e998c..0000000 --- a/src/runtimes/openai/generated/app-server-protocol-client.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { expectRecord, parseThread, parseTurn, readString } from "./app-server-protocol-common"; -import type { - ClientRequestMethod, - ClientRequestResult, - InitializeResponse, - ThreadInjectItemsResponse, - ThreadResumeResponse, - ThreadStartResponse, - TurnInterruptResponse, - TurnStartResponse, -} from "./app-server-protocol-types"; - -function parseInitializeResponse(value: unknown): InitializeResponse { - const record = expectRecord(value ?? {}, "initialize result"); - const protocolVersion = readString(record, "protocolVersion"); - - return protocolVersion === null ? {} : { protocolVersion }; -} - -function parseThreadResponse(value: unknown, method: string): ThreadStartResponse { - const record = expectRecord(value, `${method} result`); - - return { - thread: parseThread(record["thread"], `${method} result.thread`), - }; -} - -function parseTurnStartResponse(value: unknown): TurnStartResponse { - const record = expectRecord(value, "turn/start result"); - const turn = expectRecord(record["turn"], "turn/start result.turn"); - - for (const field of [ - "completedAt", - "durationMs", - "error", - "items", - "itemsView", - "startedAt", - "status", - ]) { - if (!Object.hasOwn(turn, field)) { - throw new Error(`turn/start result.turn.${field} is required.`); - } - } - - const parsed = parseTurn(turn, "turn/start result.turn"); - - return { - turn: { - ...parsed, - error: parsed.error ?? null, - } as Required, - }; -} - -function parseTurnInterruptResponse(value: unknown): TurnInterruptResponse { - if (value === undefined || value === null) { - return {}; - } - - const record = expectRecord(value, "turn/interrupt result"); - const turn = record["turn"]; - - return turn === undefined ? {} : { turn: parseTurn(turn, "turn/interrupt result.turn") }; -} - -function parseEmptyResponse(value: unknown, method: string): Record { - expectRecord(value ?? {}, `${method} result`); - return {}; -} - -export const CLIENT_REQUEST_RESULT_PARSERS: { - [Method in ClientRequestMethod]: (value: unknown) => ClientRequestResult[Method]; -} = { - initialize: parseInitializeResponse, - "thread/inject_items": (value: unknown): ThreadInjectItemsResponse => - parseEmptyResponse(value, "thread/inject_items"), - "thread/resume": (value: unknown): ThreadResumeResponse => - parseThreadResponse(value, "thread/resume"), - "thread/start": (value: unknown): ThreadStartResponse => - parseThreadResponse(value, "thread/start"), - "turn/interrupt": parseTurnInterruptResponse, - "turn/start": parseTurnStartResponse, -}; - -export function parseClientRequestResult(method: "initialize", value: unknown): InitializeResponse; -export function parseClientRequestResult( - method: "thread/inject_items", - value: unknown, -): ThreadInjectItemsResponse; -export function parseClientRequestResult( - method: "thread/resume", - value: unknown, -): ThreadResumeResponse; -export function parseClientRequestResult( - method: "thread/start", - value: unknown, -): ThreadStartResponse; -export function parseClientRequestResult( - method: "turn/interrupt", - value: unknown, -): TurnInterruptResponse; -export function parseClientRequestResult(method: "turn/start", value: unknown): TurnStartResponse; -export function parseClientRequestResult( - method: ClientRequestMethod, - value: unknown, -): ClientRequestResult[ClientRequestMethod] { - return CLIENT_REQUEST_RESULT_PARSERS[method](value); -} diff --git a/src/runtimes/openai/generated/app-server-protocol-common.ts b/src/runtimes/openai/generated/app-server-protocol-common.ts deleted file mode 100644 index 6bd2f37..0000000 --- a/src/runtimes/openai/generated/app-server-protocol-common.ts +++ /dev/null @@ -1,480 +0,0 @@ -import type { - FileUpdateChange, - JsonObject, - PatchChangeKind, - TextPosition, - TextRange, - Thread, - ThreadActiveFlag, - ThreadItem, - ThreadStatus, - ThreadTokenUsage, - TokenUsageBreakdown, - Turn, - TurnError, - TurnItemsView, - TurnPlanStep, - TurnPlanStepStatus, - TurnStatus, -} from "./app-server-protocol-types"; - -export function isRecord(value: unknown): value is JsonObject { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -export function expectRecord(value: unknown, label: string): JsonObject { - if (!isRecord(value)) { - throw new Error(`${label} must be an object.`); - } - - return value; -} - -export function readString(value: JsonObject, key: string): string | null { - const entry = value[key]; - return typeof entry === "string" ? entry : null; -} - -export function readRequiredString(value: JsonObject, key: string, label: string): string { - const entry = readString(value, key); - - if (entry === null) { - throw new Error(`${label}.${key} must be a string.`); - } - - return entry; -} - -export function readRequiredBoolean(value: JsonObject, key: string, label: string): boolean { - const entry = value[key]; - - if (typeof entry !== "boolean") { - throw new Error(`${label}.${key} must be a boolean.`); - } - - return entry; -} - -export function readRequiredNumber(value: JsonObject, key: string, label: string): number { - const entry = value[key]; - - if (typeof entry !== "number" || !Number.isFinite(entry)) { - throw new Error(`${label}.${key} must be a finite number.`); - } - - return entry; -} - -function readOptionalString(value: JsonObject, key: string, label: string): string | undefined { - const entry = value[key]; - - if (entry === undefined) { - return undefined; - } - - if (typeof entry === "string") { - return entry; - } - - throw new Error(`${label}.${key} must be a string.`); -} - -function readOptionalNumber(value: JsonObject, key: string, label: string): number | undefined { - const entry = value[key]; - - if (entry === undefined) { - return undefined; - } - - if (typeof entry === "number" && Number.isFinite(entry)) { - return entry; - } - - throw new Error(`${label}.${key} must be a finite number.`); -} - -function readOptionalNullableNumber( - value: JsonObject, - key: string, - label: string, -): number | null | undefined { - const entry = value[key]; - - if (entry === undefined) { - return undefined; - } - - if (entry === null) { - return null; - } - - if (typeof entry === "number" && Number.isFinite(entry)) { - return entry; - } - - throw new Error(`${label}.${key} must be a finite number or null.`); -} - -export function readOptionalNullableString( - value: JsonObject, - key: string, - label: string, -): string | null | undefined { - const entry = value[key]; - - if (entry === undefined) { - return undefined; - } - - if (entry === null || typeof entry === "string") { - return entry; - } - - throw new Error(`${label}.${key} must be a string or null.`); -} - -function parseUint(value: unknown, label: string): number { - if (typeof value !== "number" || !Number.isInteger(value) || value < 0) { - throw new Error(`${label} must be a non-negative integer.`); - } - - return value; -} - -function parseOptionalJsonObject(value: unknown, label: string): JsonObject | undefined { - if (value === undefined) { - return undefined; - } - - return expectRecord(value, label); -} - -function parseOptionalJsonValue(value: unknown, label: string): unknown { - if ( - value === undefined || - value === null || - typeof value === "boolean" || - typeof value === "number" || - typeof value === "string" - ) { - return value; - } - - if (Array.isArray(value)) { - return value.map((entry, index) => parseOptionalJsonValue(entry, `${label}.${String(index)}`)); - } - - if (isRecord(value)) { - return Object.fromEntries( - Object.entries(value).map(([key, entry]) => [key, parseOptionalJsonValue(entry, key)]), - ); - } - - throw new Error(`${label} must be JSON-compatible.`); -} - -function parseTextPosition(value: unknown, label: string): TextPosition { - const record = expectRecord(value, label); - - return { - column: parseUint(record["column"], `${label}.column`), - line: parseUint(record["line"], `${label}.line`), - }; -} - -export function parseOptionalTextRange( - value: JsonObject, - key: string, - label: string, -): TextRange | null | undefined { - const entry = value[key]; - - if (entry === undefined) { - return undefined; - } - - if (entry === null) { - return null; - } - - const record = expectRecord(entry, `${label}.${key}`); - - return { - end: parseTextPosition(record["end"], `${label}.${key}.end`), - start: parseTextPosition(record["start"], `${label}.${key}.start`), - }; -} - -function parsePatchChangeKind(value: unknown, label: string): PatchChangeKind { - const record = expectRecord(value, label); - const type = readRequiredString(record, "type", label); - - if (type === "add" || type === "delete") { - return { type }; - } - - if (type === "update") { - return { - move_path: readOptionalNullableString(record, "move_path", label) ?? null, - type, - }; - } - - throw new Error(`${label}.type is unsupported.`); -} - -function parseFileUpdateChange(value: unknown, label: string): FileUpdateChange { - const record = expectRecord(value, label); - - return { - diff: readRequiredString(record, "diff", label), - kind: parsePatchChangeKind(record["kind"], `${label}.kind`), - path: readRequiredString(record, "path", label), - }; -} - -export function parseFileUpdateChanges(value: unknown, label: string): FileUpdateChange[] { - if (!Array.isArray(value)) { - throw new Error(`${label} must be an array.`); - } - - return value.map((change, index) => parseFileUpdateChange(change, `${label}.${String(index)}`)); -} - -function parseTurnPlanStepStatus(value: unknown, label: string): TurnPlanStepStatus { - if (value === "pending" || value === "inProgress" || value === "completed") { - return value; - } - - throw new Error(`${label} is unsupported.`); -} - -function parseTurnPlanStep(value: unknown, label: string): TurnPlanStep { - const record = expectRecord(value, label); - - return { - status: parseTurnPlanStepStatus(record["status"], `${label}.status`), - step: readRequiredString(record, "step", label), - }; -} - -export function parseTurnPlan(value: unknown, label: string): TurnPlanStep[] { - if (!Array.isArray(value)) { - throw new Error(`${label} must be an array.`); - } - - return value.map((step, index) => parseTurnPlanStep(step, `${label}.${String(index)}`)); -} - -export function parseThreadTurnIds( - value: JsonObject, - label: string, -): { threadId: string; turnId: string } { - return { - threadId: readRequiredString(value, "threadId", label), - turnId: readRequiredString(value, "turnId", label), - }; -} - -export function parseThread(value: unknown, label: string): Thread { - const record = expectRecord(value, label); - const id = readString(record, "id"); - - if (id === null || id.length === 0) { - throw new Error(`${label}.id must be a non-empty string.`); - } - - return { - id, - ...(record["status"] === undefined - ? {} - : { status: parseThreadStatus(record["status"], `${label}.status`) }), - }; -} - -function parseThreadActiveFlag(value: unknown, label: string): ThreadActiveFlag { - if (value === "waitingOnApproval" || value === "waitingOnUserInput") { - return value; - } - - throw new Error(`${label} is unsupported.`); -} - -export function parseThreadStatus(value: unknown, label: string): ThreadStatus { - const record = expectRecord(value, label); - const statusType = readString(record, "type"); - - if (statusType === "notLoaded" || statusType === "idle" || statusType === "systemError") { - return { type: statusType }; - } - - if (statusType === "active") { - const activeFlags = record["activeFlags"]; - - if (!Array.isArray(activeFlags)) { - throw new Error(`${label}.activeFlags must be an array.`); - } - - return { - activeFlags: activeFlags.map((flag, index) => - parseThreadActiveFlag(flag, `${label}.activeFlags.${String(index)}`), - ), - type: "active", - }; - } - - throw new Error(`${label}.type is unsupported.`); -} - -export function parseThreadItem(value: unknown, label: string): ThreadItem { - const record = expectRecord(value, label); - const type = readRequiredString(record, "type", label); - const id = readOptionalString(record, "id", label); - const { id: _id, type: _type, ...rest } = record; - const passthrough = Object.fromEntries( - Object.entries(rest).map(([key, entry]) => [key, parseOptionalJsonValue(entry, key)]), - ); - - return { - ...passthrough, - ...(id === undefined ? {} : { id }), - type, - }; -} - -function parseOptionalThreadItems(value: unknown, label: string): ThreadItem[] | undefined { - if (value === undefined) { - return undefined; - } - - if (!Array.isArray(value)) { - throw new Error(`${label} must be an array.`); - } - - return value.map((item, index) => parseThreadItem(item, `${label}.${String(index)}`)); -} - -function parseTurnStatus(value: unknown, label: string): TurnStatus | undefined { - if (value === undefined) { - return undefined; - } - - if ( - value === "completed" || - value === "interrupted" || - value === "failed" || - value === "inProgress" - ) { - return value; - } - - throw new Error(`${label} is unsupported.`); -} - -function parseTurnItemsView(value: unknown, label: string): TurnItemsView | undefined { - if (value === undefined) { - return undefined; - } - - if (value === "notLoaded" || value === "summary" || value === "full") { - return value; - } - - throw new Error(`${label} is unsupported.`); -} - -export function parseTurn(value: unknown, label: string): Turn { - const record = expectRecord(value, label); - const id = readString(record, "id"); - - if (id === null || id.length === 0) { - throw new Error(`${label}.id must be a non-empty string.`); - } - - const error = - record["error"] === undefined || record["error"] === null - ? null - : parseTurnError(record["error"], `${label}.error`); - const items = parseOptionalThreadItems(record["items"], `${label}.items`); - const itemsView = parseTurnItemsView(record["itemsView"], `${label}.itemsView`); - const startedAt = readOptionalNullableNumber(record, "startedAt", label); - const completedAt = readOptionalNullableNumber(record, "completedAt", label); - const durationMs = readOptionalNullableNumber(record, "durationMs", label); - const status = parseTurnStatus(record["status"], `${label}.status`); - - return { - id, - ...(completedAt === undefined ? {} : { completedAt }), - ...(durationMs === undefined ? {} : { durationMs }), - ...(error === null ? {} : { error }), - ...(items === undefined ? {} : { items }), - ...(itemsView === undefined ? {} : { itemsView }), - ...(startedAt === undefined ? {} : { startedAt }), - ...(status === undefined ? {} : { status }), - }; -} - -export function parseTurnError(value: unknown, label: string): TurnError { - const record = expectRecord(value, label); - - return { - additionalDetails: readOptionalNullableString(record, "additionalDetails", label) ?? null, - message: readRequiredString(record, "message", label), - }; -} - -function parseTokenUsageBreakdown(value: unknown, label: string): TokenUsageBreakdown { - const record = expectRecord(value, label); - - return { - cachedInputTokens: parseUint(record["cachedInputTokens"], `${label}.cachedInputTokens`), - inputTokens: parseUint(record["inputTokens"], `${label}.inputTokens`), - outputTokens: parseUint(record["outputTokens"], `${label}.outputTokens`), - reasoningOutputTokens: parseUint( - record["reasoningOutputTokens"], - `${label}.reasoningOutputTokens`, - ), - totalTokens: parseUint(record["totalTokens"], `${label}.totalTokens`), - }; -} - -export function parseThreadTokenUsage(value: unknown, label: string): ThreadTokenUsage { - const record = expectRecord(value, label); - const modelContextWindow = - record["modelContextWindow"] === null - ? null - : parseUint(record["modelContextWindow"], `${label}.modelContextWindow`); - - return { - last: parseTokenUsageBreakdown(record["last"], `${label}.last`), - modelContextWindow, - total: parseTokenUsageBreakdown(record["total"], `${label}.total`), - }; -} - -export function parseOptionalNotificationString( - value: JsonObject, - key: string, - label: string, -): { readonly [field: string]: string } { - const entry = readOptionalString(value, key, label); - return entry === undefined ? {} : { [key]: entry }; -} - -export function parseOptionalNotificationNumber( - value: JsonObject, - key: string, - label: string, -): { readonly [field: string]: number } { - const entry = readOptionalNumber(value, key, label); - return entry === undefined ? {} : { [key]: entry }; -} - -export function parseOptionalNotificationRecord( - value: JsonObject, - key: string, - label: string, -): { readonly [field: string]: JsonObject } { - const entry = parseOptionalJsonObject(value[key], `${label}.${key}`); - return entry === undefined ? {} : { [key]: entry }; -} diff --git a/src/runtimes/openai/generated/app-server-protocol-server.ts b/src/runtimes/openai/generated/app-server-protocol-server.ts deleted file mode 100644 index 9300070..0000000 --- a/src/runtimes/openai/generated/app-server-protocol-server.ts +++ /dev/null @@ -1,368 +0,0 @@ -import { - expectRecord, - parseFileUpdateChanges, - parseOptionalNotificationNumber, - parseOptionalNotificationString, - parseOptionalTextRange, - parseThread, - parseThreadItem, - parseThreadStatus, - parseThreadTokenUsage, - parseThreadTurnIds, - parseTurn, - parseTurnError, - parseTurnPlan, - readOptionalNullableString, - readRequiredBoolean, - readRequiredNumber, - readRequiredString, -} from "./app-server-protocol-common"; -import type { - AgentMessageDeltaNotification, - ConfigWarningNotification, - ErrorNotification, - FileChangePatchUpdatedNotification, - ItemNotificationBase, - JsonObject, - McpToolCallProgressNotification, - PlanDeltaNotification, - ReasoningSummaryPartAddedNotification, - ReasoningSummaryTextDeltaNotification, - ReasoningTextDeltaNotification, - RemoteControlConnectionStatus, - RemoteControlStatusChangedNotification, - ServerRequestResolvedNotification, - ServerNotificationMethod, - ServerNotificationParams, - ThreadSettingsUpdatedNotification, - TurnDiffUpdatedNotification, - TurnPlanUpdatedNotification, - WarningNotification, -} from "./app-server-protocol-types"; - -function readParams(value: unknown, method: string): JsonObject { - return expectRecord(value ?? {}, `${method} params`); -} - -function parseConfigWarningNotification(value: unknown): ConfigWarningNotification { - const label = "configWarning params"; - const record = readParams(value, "configWarning"); - const details = readOptionalNullableString(record, "details", label); - const path = readOptionalNullableString(record, "path", label); - const range = parseOptionalTextRange(record, "range", label); - - return { - ...(details === undefined ? {} : { details }), - ...(path === undefined ? {} : { path }), - ...(range === undefined ? {} : { range }), - summary: readRequiredString(record, "summary", label), - }; -} - -function parseWarningNotification(value: unknown): WarningNotification { - const label = "warning params"; - const record = readParams(value, "warning"); - - return { - message: readRequiredString(record, "message", label), - threadId: readOptionalNullableString(record, "threadId", label) ?? null, - }; -} - -function parseErrorNotification(value: unknown): ErrorNotification { - const label = "error params"; - const record = readParams(value, "error"); - - return { - error: parseTurnError(record["error"], `${label}.error`), - threadId: readRequiredString(record, "threadId", label), - turnId: readRequiredString(record, "turnId", label), - willRetry: readRequiredBoolean(record, "willRetry", label), - }; -} - -function parseRemoteControlConnectionStatus(value: unknown): RemoteControlConnectionStatus { - if ( - value === "disabled" || - value === "connecting" || - value === "connected" || - value === "errored" - ) { - return value; - } - - throw new Error("remoteControl/status/changed params.status is unsupported."); -} - -function parseRemoteControlStatusChangedNotification( - value: unknown, -): RemoteControlStatusChangedNotification { - const label = "remoteControl/status/changed params"; - const record = readParams(value, "remoteControl/status/changed"); - const environmentId = readOptionalNullableString(record, "environmentId", label); - - return { - ...(environmentId === undefined ? {} : { environmentId }), - installationId: readRequiredString(record, "installationId", label), - serverName: readRequiredString(record, "serverName", label), - status: parseRemoteControlConnectionStatus(record["status"]), - }; -} - -function parseThreadSettingsUpdatedNotification(value: unknown): ThreadSettingsUpdatedNotification { - const label = "thread/settings/updated params"; - const record = readParams(value, "thread/settings/updated"); - - return { - threadId: readRequiredString(record, "threadId", label), - threadSettings: expectRecord(record["threadSettings"], `${label}.threadSettings`), - }; -} - -function parseAgentMessageDeltaNotification(value: unknown): AgentMessageDeltaNotification { - const label = "item/agentMessage/delta params"; - const record = readParams(value, "item/agentMessage/delta"); - - return { - delta: readRequiredString(record, "delta", label), - itemId: readRequiredString(record, "itemId", label), - threadId: readRequiredString(record, "threadId", label), - turnId: readRequiredString(record, "turnId", label), - }; -} - -function parseOptionalToolDeltaNotification( - value: unknown, - method: string, -): { delta?: string; itemId?: string; threadId?: string; turnId?: string } { - const record = readParams(value, method); - - return { - ...parseOptionalNotificationString(record, "delta", `${method} params`), - ...parseOptionalNotificationString(record, "itemId", `${method} params`), - ...parseOptionalNotificationString(record, "threadId", `${method} params`), - ...parseOptionalNotificationString(record, "turnId", `${method} params`), - }; -} - -function parseItemNotificationBase(value: unknown, method: string): ItemNotificationBase { - const record = readParams(value, method); - - return { - ...parseOptionalNotificationNumber(record, "completedAtMs", `${method} params`), - ...(record["item"] === undefined - ? {} - : { item: parseThreadItem(record["item"], `${method} params.item`) }), - ...parseOptionalNotificationString(record, "itemId", `${method} params`), - ...parseOptionalNotificationNumber(record, "startedAtMs", `${method} params`), - ...parseOptionalNotificationString(record, "threadId", `${method} params`), - ...parseOptionalNotificationString(record, "turnId", `${method} params`), - }; -} - -function parsePlanDeltaNotification(value: unknown): PlanDeltaNotification { - const label = "item/plan/delta params"; - const record = readParams(value, "item/plan/delta"); - - return { - delta: readRequiredString(record, "delta", label), - itemId: readRequiredString(record, "itemId", label), - threadId: readRequiredString(record, "threadId", label), - turnId: readRequiredString(record, "turnId", label), - }; -} - -function parseReasoningTextDeltaNotification(value: unknown): ReasoningTextDeltaNotification { - const label = "item/reasoning/textDelta params"; - const record = readParams(value, "item/reasoning/textDelta"); - - return { - contentIndex: readRequiredNumber(record, "contentIndex", label), - delta: readRequiredString(record, "delta", label), - itemId: readRequiredString(record, "itemId", label), - threadId: readRequiredString(record, "threadId", label), - turnId: readRequiredString(record, "turnId", label), - }; -} - -function parseReasoningSummaryPartAddedNotification( - value: unknown, -): ReasoningSummaryPartAddedNotification { - const label = "item/reasoning/summaryPartAdded params"; - const record = readParams(value, "item/reasoning/summaryPartAdded"); - - return { - itemId: readRequiredString(record, "itemId", label), - summaryIndex: readRequiredNumber(record, "summaryIndex", label), - threadId: readRequiredString(record, "threadId", label), - turnId: readRequiredString(record, "turnId", label), - }; -} - -function parseReasoningSummaryTextDeltaNotification( - value: unknown, -): ReasoningSummaryTextDeltaNotification { - const label = "item/reasoning/summaryTextDelta params"; - const record = readParams(value, "item/reasoning/summaryTextDelta"); - - return { - delta: readRequiredString(record, "delta", label), - itemId: readRequiredString(record, "itemId", label), - summaryIndex: readRequiredNumber(record, "summaryIndex", label), - threadId: readRequiredString(record, "threadId", label), - turnId: readRequiredString(record, "turnId", label), - }; -} - -function parseMcpToolCallProgressNotification(value: unknown): McpToolCallProgressNotification { - const label = "item/mcpToolCall/progress params"; - const record = readParams(value, "item/mcpToolCall/progress"); - - return { - itemId: readRequiredString(record, "itemId", label), - message: readRequiredString(record, "message", label), - threadId: readRequiredString(record, "threadId", label), - turnId: readRequiredString(record, "turnId", label), - }; -} - -function parseServerRequestResolvedNotification(value: unknown): ServerRequestResolvedNotification { - const label = "serverRequest/resolved params"; - const record = readParams(value, "serverRequest/resolved"); - const requestId = record["requestId"]; - - if (typeof requestId !== "number" && typeof requestId !== "string") { - throw new Error(`${label}.requestId must be a string or number.`); - } - - return { - requestId, - threadId: readRequiredString(record, "threadId", label), - }; -} - -function parseThreadStarted(value: unknown): ServerNotificationParams["thread/started"] { - const record = readParams(value, "thread/started"); - - return { - thread: parseThread(record["thread"], "thread/started params.thread"), - }; -} - -function parseThreadStatusChanged( - value: unknown, -): ServerNotificationParams["thread/status/changed"] { - const label = "thread/status/changed params"; - const record = readParams(value, "thread/status/changed"); - - return { - status: parseThreadStatus(record["status"], `${label}.status`), - threadId: readRequiredString(record, "threadId", label), - }; -} - -function parseThreadTokenUsageUpdated( - value: unknown, -): ServerNotificationParams["thread/tokenUsage/updated"] { - const label = "thread/tokenUsage/updated params"; - const record = readParams(value, "thread/tokenUsage/updated"); - - return { - threadId: readRequiredString(record, "threadId", label), - tokenUsage: parseThreadTokenUsage(record["tokenUsage"], `${label}.tokenUsage`), - turnId: readRequiredString(record, "turnId", label), - }; -} - -function parseTurnNotification( - value: unknown, - method: "turn/completed" | "turn/started", -): ServerNotificationParams[typeof method] { - const label = `${method} params`; - const record = readParams(value, method); - const turn = parseTurn(record["turn"], `${label}.turn`); - - if ( - method === "turn/completed" && - turn.status !== "completed" && - turn.status !== "failed" && - turn.status !== "interrupted" - ) { - throw new Error(`${label}.turn.status must be terminal.`); - } - - return { - threadId: readRequiredString(record, "threadId", label), - turn, - }; -} - -function parseTurnPlanUpdated(value: unknown): TurnPlanUpdatedNotification { - const label = "turn/plan/updated params"; - const record = readParams(value, "turn/plan/updated"); - - return { - ...parseThreadTurnIds(record, label), - explanation: readOptionalNullableString(record, "explanation", label) ?? null, - plan: parseTurnPlan(record["plan"], `${label}.plan`), - }; -} - -function parseTurnDiffUpdated(value: unknown): TurnDiffUpdatedNotification { - const label = "turn/diff/updated params"; - const record = readParams(value, "turn/diff/updated"); - - return { - ...parseThreadTurnIds(record, label), - diff: readRequiredString(record, "diff", label), - }; -} - -function parseFileChangePatchUpdated(value: unknown): FileChangePatchUpdatedNotification { - const label = "item/fileChange/patchUpdated params"; - const record = readParams(value, "item/fileChange/patchUpdated"); - - return { - ...parseThreadTurnIds(record, label), - changes: parseFileUpdateChanges(record["changes"], `${label}.changes`), - itemId: readRequiredString(record, "itemId", label), - }; -} - -const SERVER_NOTIFICATION_PARAM_PARSERS: { - [Method in ServerNotificationMethod]: (value: unknown) => ServerNotificationParams[Method]; -} = { - configWarning: parseConfigWarningNotification, - error: parseErrorNotification, - "item/agentMessage/delta": parseAgentMessageDeltaNotification, - "item/commandExecution/outputDelta": (value) => - parseOptionalToolDeltaNotification(value, "item/commandExecution/outputDelta"), - "item/completed": (value) => parseItemNotificationBase(value, "item/completed"), - "item/fileChange/outputDelta": (value) => - parseOptionalToolDeltaNotification(value, "item/fileChange/outputDelta"), - "item/fileChange/patchUpdated": parseFileChangePatchUpdated, - "item/mcpToolCall/progress": parseMcpToolCallProgressNotification, - "item/plan/delta": parsePlanDeltaNotification, - "item/reasoning/summaryPartAdded": parseReasoningSummaryPartAddedNotification, - "item/reasoning/summaryTextDelta": parseReasoningSummaryTextDeltaNotification, - "item/reasoning/textDelta": parseReasoningTextDeltaNotification, - "item/started": (value) => parseItemNotificationBase(value, "item/started"), - "remoteControl/status/changed": parseRemoteControlStatusChangedNotification, - "serverRequest/resolved": parseServerRequestResolvedNotification, - "thread/settings/updated": parseThreadSettingsUpdatedNotification, - "thread/started": parseThreadStarted, - "thread/status/changed": parseThreadStatusChanged, - "thread/tokenUsage/updated": parseThreadTokenUsageUpdated, - "turn/completed": (value) => parseTurnNotification(value, "turn/completed"), - "turn/diff/updated": parseTurnDiffUpdated, - "turn/plan/updated": parseTurnPlanUpdated, - "turn/started": (value) => parseTurnNotification(value, "turn/started"), - warning: parseWarningNotification, -}; - -export function parseServerNotificationParams( - method: M, - value: unknown, -): ServerNotificationParams[M] { - return SERVER_NOTIFICATION_PARAM_PARSERS[method](value); -} diff --git a/src/runtimes/openai/generated/app-server-protocol-types.ts b/src/runtimes/openai/generated/app-server-protocol-types.ts deleted file mode 100644 index 1e641dc..0000000 --- a/src/runtimes/openai/generated/app-server-protocol-types.ts +++ /dev/null @@ -1,466 +0,0 @@ -export const OPENAI_APP_SERVER_SCHEMA_VERSION = "0.144.5" as const; - -export type JsonPrimitive = boolean | number | string | null; -export type JsonValue = JsonPrimitive | JsonObject | JsonValue[]; -export type JsonObject = Readonly>; - -export type RequestId = number | string; -export type ApprovalPolicy = "untrusted" | "on-failure" | "on-request" | "never"; -export type ImageDetail = "high" | "original"; -export type SandboxMode = "read-only" | "workspace-write" | "danger-full-access"; -export type SandboxPolicy = SandboxMode | JsonObject; - -export type UserInput = - | { type: "text"; text: string; text_elements: [] } - | { type: "image"; detail?: ImageDetail; url: string } - | { type: "localImage"; detail?: ImageDetail; path: string } - | { type: "skill"; name: string; path: string } - | { type: "mention"; name: string; path: string }; - -export interface InitializeParams { - capabilities: { - experimentalApi: boolean; - requestAttestation: boolean; - } | null; - clientInfo: { - name: string; - title?: string; - version: string; - }; -} - -export interface InitializeResponse { - protocolVersion?: string; -} - -export interface ThreadStartParams { - approvalPolicy?: ApprovalPolicy | null; - approvalsReviewer?: string | JsonObject | null; - baseInstructions?: string | null; - config?: JsonObject | null; - cwd?: string | null; - developerInstructions?: string | null; - ephemeral?: boolean | null; - model?: string | null; - modelProvider?: string | null; - sandbox?: SandboxMode | null; - serviceName?: string | null; - serviceTier?: string | null; - sessionStartSource?: string | null; -} - -export interface ThreadResumeParams extends Omit< - ThreadStartParams, - "ephemeral" | "serviceName" | "sessionStartSource" -> { - threadId: string; -} - -export type ThreadActiveFlag = "waitingOnApproval" | "waitingOnUserInput"; - -export type ThreadStatus = - | { type: "notLoaded" } - | { type: "idle" } - | { type: "systemError" } - | { type: "active"; activeFlags: ThreadActiveFlag[] }; - -export interface Thread { - id: string; - status?: ThreadStatus; -} - -export interface ThreadStartResponse { - thread: Thread; -} - -export interface ThreadResumeResponse { - thread: Thread; -} - -export interface ThreadInjectItemsParams { - items: JsonObject[]; - threadId: string; -} - -export type ThreadInjectItemsResponse = Record; - -export interface TurnStartParams { - approvalPolicy?: ApprovalPolicy | null; - approvalsReviewer?: string | JsonObject | null; - cwd?: string | null; - effort?: string | null; - input: UserInput[]; - model?: string | null; - outputSchema?: JsonValue | null; - sandboxPolicy?: SandboxPolicy | null; - serviceTier?: string | null; - summary?: string | null; - threadId: string; -} - -export type TurnStatus = "completed" | "interrupted" | "failed" | "inProgress"; -export type TurnItemsView = "notLoaded" | "summary" | "full"; - -export type PatchChangeKind = - | { type: "add" } - | { type: "delete" } - | { move_path: string | null; type: "update" }; - -export interface FileUpdateChange { - diff: string; - kind: PatchChangeKind; - path: string; -} - -export type TurnPlanStepStatus = "pending" | "inProgress" | "completed"; - -export interface TurnPlanStep { - status: TurnPlanStepStatus; - step: string; -} - -export type ThreadItem = JsonObject & { - id?: string; - type: string; -}; - -export interface Turn { - completedAt?: number | null; - durationMs?: number | null; - error?: TurnError | null; - id: string; - items?: ThreadItem[]; - itemsView?: TurnItemsView; - startedAt?: number | null; - status?: TurnStatus; -} - -export interface TurnStartResponse { - turn: Required; -} - -export interface TurnInterruptParams { - threadId: string; - turnId: string; -} - -export interface TurnInterruptResponse { - turn?: Turn; -} - -export interface TokenUsageBreakdown { - cachedInputTokens: number; - inputTokens: number; - outputTokens: number; - reasoningOutputTokens: number; - totalTokens: number; -} - -export interface ThreadTokenUsage { - last: TokenUsageBreakdown; - modelContextWindow: number | null; - total: TokenUsageBreakdown; -} - -export interface ItemNotificationBase { - completedAtMs?: number; - item?: ThreadItem; - itemId?: string; - startedAtMs?: number; - threadId?: string; - turnId?: string; -} - -export interface PlanDeltaNotification { - delta: string; - itemId: string; - threadId: string; - turnId: string; -} - -export interface TurnPlanUpdatedNotification { - explanation: string | null; - plan: TurnPlanStep[]; - threadId: string; - turnId: string; -} - -export interface TurnDiffUpdatedNotification { - diff: string; - threadId: string; - turnId: string; -} - -export interface FileChangePatchUpdatedNotification { - changes: FileUpdateChange[]; - itemId: string; - threadId: string; - turnId: string; -} - -export interface ReasoningTextDeltaNotification { - contentIndex: number; - delta: string; - itemId: string; - threadId: string; - turnId: string; -} - -export interface ReasoningSummaryPartAddedNotification { - itemId: string; - summaryIndex: number; - threadId: string; - turnId: string; -} - -export interface ReasoningSummaryTextDeltaNotification { - delta: string; - itemId: string; - summaryIndex: number; - threadId: string; - turnId: string; -} - -export interface McpToolCallProgressNotification { - itemId: string; - message: string; - threadId: string; - turnId: string; -} - -export interface ServerRequestResolvedNotification { - requestId: RequestId; - threadId: string; -} - -export interface TextPosition { - column: number; - line: number; -} - -export interface TextRange { - end: TextPosition; - start: TextPosition; -} - -export interface ConfigWarningNotification { - details?: string | null; - path?: string | null; - range?: TextRange | null; - summary: string; -} - -export interface WarningNotification { - message: string; - threadId: string | null; -} - -export interface TurnError { - additionalDetails: string | null; - message: string; -} - -export interface ErrorNotification { - error: TurnError; - threadId: string; - turnId: string; - willRetry: boolean; -} - -export type RemoteControlConnectionStatus = "disabled" | "connecting" | "connected" | "errored"; - -export interface RemoteControlStatusChangedNotification { - environmentId?: string | null; - installationId: string; - serverName: string; - status: RemoteControlConnectionStatus; -} - -export interface ThreadSettingsUpdatedNotification { - threadId: string; - threadSettings: JsonObject; -} - -export interface AgentMessageDeltaNotification { - delta: string; - itemId: string; - threadId: string; - turnId: string; -} - -export interface ServerNotificationParams { - configWarning: ConfigWarningNotification; - error: ErrorNotification; - "item/agentMessage/delta": AgentMessageDeltaNotification; - "item/commandExecution/outputDelta": { - delta?: string; - itemId?: string; - threadId?: string; - turnId?: string; - }; - "item/completed": ItemNotificationBase; - "item/fileChange/outputDelta": { - delta?: string; - itemId?: string; - threadId?: string; - turnId?: string; - }; - "item/fileChange/patchUpdated": FileChangePatchUpdatedNotification; - "item/mcpToolCall/progress": McpToolCallProgressNotification; - "item/plan/delta": PlanDeltaNotification; - "item/reasoning/summaryPartAdded": ReasoningSummaryPartAddedNotification; - "item/reasoning/summaryTextDelta": ReasoningSummaryTextDeltaNotification; - "item/reasoning/textDelta": ReasoningTextDeltaNotification; - "item/started": ItemNotificationBase; - "remoteControl/status/changed": RemoteControlStatusChangedNotification; - "serverRequest/resolved": ServerRequestResolvedNotification; - "thread/settings/updated": ThreadSettingsUpdatedNotification; - "thread/started": { thread: Thread }; - "thread/status/changed": { status: ThreadStatus; threadId: string }; - "thread/tokenUsage/updated": { threadId: string; tokenUsage: ThreadTokenUsage; turnId: string }; - "turn/completed": { threadId: string; turn: Turn }; - "turn/diff/updated": TurnDiffUpdatedNotification; - "turn/plan/updated": TurnPlanUpdatedNotification; - "turn/started": { threadId: string; turn: Turn }; - warning: WarningNotification; -} - -export type ServerNotificationMethod = keyof ServerNotificationParams; - -export interface ClientRequestParams { - initialize: InitializeParams; - "thread/inject_items": ThreadInjectItemsParams; - "thread/resume": ThreadResumeParams; - "thread/start": ThreadStartParams; - "turn/interrupt": TurnInterruptParams; - "turn/start": TurnStartParams; -} - -export interface ClientRequestResult { - initialize: InitializeResponse; - "thread/inject_items": ThreadInjectItemsResponse; - "thread/resume": ThreadResumeResponse; - "thread/start": ThreadStartResponse; - "turn/interrupt": TurnInterruptResponse; - "turn/start": TurnStartResponse; -} - -export type ClientRequestMethod = keyof ClientRequestParams; - -export interface CommandExecutionRequestApprovalResponse { - decision: "accept" | "acceptForSession" | "decline" | "cancel" | JsonObject; -} - -export interface FileChangeRequestApprovalResponse { - decision: "accept" | "acceptForSession" | "decline" | "cancel"; -} - -export interface PermissionsRequestApprovalResponse { - permissions: JsonObject; - scope: "turn" | "session"; - strictAutoReview?: boolean; -} - -export interface ToolRequestUserInputResponse { - answers: Record; -} - -export interface DynamicToolCallResponse { - contentItems: Array< - { type: "inputText"; text: string } | { type: "inputImage"; imageUrl: string } - >; - success: boolean; -} - -export interface ChatgptAuthTokensRefreshResponse { - accessToken: string; - chatgptAccountId: string; - chatgptPlanType: string | null; -} - -export interface AttestationGenerateResponse { - token: string; -} - -export interface CurrentTimeReadResponse { - currentTimeAt: number; -} - -export interface McpServerElicitationRequestResponse { - _meta: JsonValue | null; - action: "accept" | "decline" | "cancel"; - content: JsonValue | null; -} - -export interface ServerRequestParams { - "account/chatgptAuthTokens/refresh": JsonObject; - "attestation/generate": JsonObject; - "currentTime/read": JsonObject; - "item/commandExecution/requestApproval": JsonObject; - "item/fileChange/requestApproval": JsonObject; - "item/permissions/requestApproval": JsonObject; - "item/tool/call": JsonObject; - "item/tool/requestUserInput": JsonObject; - "mcpServer/elicitation/request": JsonObject; -} - -export interface ServerRequestResult { - "account/chatgptAuthTokens/refresh": ChatgptAuthTokensRefreshResponse; - "attestation/generate": AttestationGenerateResponse; - "currentTime/read": CurrentTimeReadResponse; - "item/commandExecution/requestApproval": CommandExecutionRequestApprovalResponse; - "item/fileChange/requestApproval": FileChangeRequestApprovalResponse; - "item/permissions/requestApproval": PermissionsRequestApprovalResponse; - "item/tool/call": DynamicToolCallResponse; - "item/tool/requestUserInput": ToolRequestUserInputResponse; - "mcpServer/elicitation/request": McpServerElicitationRequestResponse; -} - -export type ServerRequestMethod = keyof ServerRequestParams; - -const SERVER_NOTIFICATION_METHODS = new Set([ - "configWarning", - "error", - "item/agentMessage/delta", - "item/commandExecution/outputDelta", - "item/completed", - "item/fileChange/outputDelta", - "item/fileChange/patchUpdated", - "item/mcpToolCall/progress", - "item/plan/delta", - "item/reasoning/summaryPartAdded", - "item/reasoning/summaryTextDelta", - "item/reasoning/textDelta", - "item/started", - "remoteControl/status/changed", - "serverRequest/resolved", - "thread/settings/updated", - "thread/started", - "thread/status/changed", - "thread/tokenUsage/updated", - "turn/completed", - "turn/diff/updated", - "turn/plan/updated", - "turn/started", - "warning", -]); - -const SERVER_REQUEST_METHODS = new Set([ - "account/chatgptAuthTokens/refresh", - "attestation/generate", - "currentTime/read", - "item/commandExecution/requestApproval", - "item/fileChange/requestApproval", - "item/permissions/requestApproval", - "item/tool/call", - "item/tool/requestUserInput", - "mcpServer/elicitation/request", -]); - -export function isServerNotificationMethod(method: string): method is ServerNotificationMethod { - return SERVER_NOTIFICATION_METHODS.has(method); -} - -export function isServerRequestMethod(method: string): method is ServerRequestMethod { - return SERVER_REQUEST_METHODS.has(method); -} diff --git a/src/runtimes/openai/generated/serde_json/JsonValue.ts b/src/runtimes/openai/generated/serde_json/JsonValue.ts new file mode 100644 index 0000000..dbf7173 --- /dev/null +++ b/src/runtimes/openai/generated/serde_json/JsonValue.ts @@ -0,0 +1,11 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type JsonValue = + | number + | string + | boolean + | Array + | { [key in string]?: JsonValue } + | null; diff --git a/src/runtimes/openai/generated/v2/AdditionalContextEntry.ts b/src/runtimes/openai/generated/v2/AdditionalContextEntry.ts new file mode 100644 index 0000000..82edbaa --- /dev/null +++ b/src/runtimes/openai/generated/v2/AdditionalContextEntry.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AdditionalContextKind } from "./AdditionalContextKind"; + +export type AdditionalContextEntry = { value: string; kind: AdditionalContextKind }; diff --git a/src/runtimes/openai/generated/v2/AdditionalContextKind.ts b/src/runtimes/openai/generated/v2/AdditionalContextKind.ts new file mode 100644 index 0000000..cd60bd7 --- /dev/null +++ b/src/runtimes/openai/generated/v2/AdditionalContextKind.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AdditionalContextKind = "untrusted" | "application"; diff --git a/src/runtimes/openai/generated/v2/ApprovalsReviewer.ts b/src/runtimes/openai/generated/v2/ApprovalsReviewer.ts new file mode 100644 index 0000000..1d93294 --- /dev/null +++ b/src/runtimes/openai/generated/v2/ApprovalsReviewer.ts @@ -0,0 +1,12 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Configures who approval requests are routed to for review. Examples + * include sandbox escapes, blocked network access, MCP approval prompts, and + * ARC escalations. Defaults to `user`. `auto_review` uses a carefully + * prompted subagent to gather relevant context and apply a risk-based + * decision framework before approving or denying the request. + */ +export type ApprovalsReviewer = "user" | "auto_review" | "guardian_subagent"; diff --git a/src/runtimes/openai/generated/v2/AskForApproval.ts b/src/runtimes/openai/generated/v2/AskForApproval.ts new file mode 100644 index 0000000..d52ecbd --- /dev/null +++ b/src/runtimes/openai/generated/v2/AskForApproval.ts @@ -0,0 +1,17 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type AskForApproval = + | "untrusted" + | "on-request" + | { + granular: { + sandbox_approval: boolean; + rules: boolean; + skill_approval: boolean; + request_permissions: boolean; + mcp_elicitations: boolean; + }; + } + | "never"; diff --git a/src/runtimes/openai/generated/v2/ByteRange.ts b/src/runtimes/openai/generated/v2/ByteRange.ts new file mode 100644 index 0000000..fae7a1c --- /dev/null +++ b/src/runtimes/openai/generated/v2/ByteRange.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ByteRange = { start: number; end: number }; diff --git a/src/runtimes/openai/generated/v2/CapabilityRootLocation.ts b/src/runtimes/openai/generated/v2/CapabilityRootLocation.ts new file mode 100644 index 0000000..a8bb28b --- /dev/null +++ b/src/runtimes/openai/generated/v2/CapabilityRootLocation.ts @@ -0,0 +1,15 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Location used to resolve a selected capability root. + */ +export type CapabilityRootLocation = { + type: "environment"; + environmentId: string; + /** + * Absolute path for the root in the selected environment. + */ + path: string; +}; diff --git a/src/runtimes/openai/generated/v2/CurrentTimeReadResponse.ts b/src/runtimes/openai/generated/v2/CurrentTimeReadResponse.ts new file mode 100644 index 0000000..fc991d7 --- /dev/null +++ b/src/runtimes/openai/generated/v2/CurrentTimeReadResponse.ts @@ -0,0 +1,10 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type CurrentTimeReadResponse = { + /** + * Current time as whole Unix seconds. + */ + currentTimeAt: number; +}; diff --git a/src/runtimes/openai/generated/v2/CyberAccessProgram.ts b/src/runtimes/openai/generated/v2/CyberAccessProgram.ts new file mode 100644 index 0000000..ccd4118 --- /dev/null +++ b/src/runtimes/openai/generated/v2/CyberAccessProgram.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * Requested cyber treatment for a ChatGPT-authenticated Codex turn. + * Authorization and model-tier restrictions remain server-owned. + */ +export type CyberAccessProgram = "standard" | "daybreakBlue" | "daybreakRed"; diff --git a/src/runtimes/openai/generated/v2/DynamicToolFunctionSpec.ts b/src/runtimes/openai/generated/v2/DynamicToolFunctionSpec.ts new file mode 100644 index 0000000..5769370 --- /dev/null +++ b/src/runtimes/openai/generated/v2/DynamicToolFunctionSpec.ts @@ -0,0 +1,11 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "../serde_json/JsonValue"; + +export type DynamicToolFunctionSpec = { + name: string; + description: string; + inputSchema: JsonValue; + deferLoading?: boolean; +}; diff --git a/src/runtimes/openai/generated/v2/DynamicToolNamespaceSpec.ts b/src/runtimes/openai/generated/v2/DynamicToolNamespaceSpec.ts new file mode 100644 index 0000000..5f55f30 --- /dev/null +++ b/src/runtimes/openai/generated/v2/DynamicToolNamespaceSpec.ts @@ -0,0 +1,10 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DynamicToolNamespaceTool } from "./DynamicToolNamespaceTool"; + +export type DynamicToolNamespaceSpec = { + name: string; + description: string; + tools: Array; +}; diff --git a/src/runtimes/openai/generated/v2/DynamicToolNamespaceTool.ts b/src/runtimes/openai/generated/v2/DynamicToolNamespaceTool.ts new file mode 100644 index 0000000..c1b6d4d --- /dev/null +++ b/src/runtimes/openai/generated/v2/DynamicToolNamespaceTool.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DynamicToolFunctionSpec } from "./DynamicToolFunctionSpec"; + +export type DynamicToolNamespaceTool = { type: "function" } & DynamicToolFunctionSpec; diff --git a/src/runtimes/openai/generated/v2/DynamicToolSpec.ts b/src/runtimes/openai/generated/v2/DynamicToolSpec.ts new file mode 100644 index 0000000..429bb3f --- /dev/null +++ b/src/runtimes/openai/generated/v2/DynamicToolSpec.ts @@ -0,0 +1,9 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DynamicToolFunctionSpec } from "./DynamicToolFunctionSpec"; +import type { DynamicToolNamespaceSpec } from "./DynamicToolNamespaceSpec"; + +export type DynamicToolSpec = + | ({ type: "function" } & DynamicToolFunctionSpec) + | ({ type: "namespace" } & DynamicToolNamespaceSpec); diff --git a/src/runtimes/openai/generated/v2/NetworkAccess.ts b/src/runtimes/openai/generated/v2/NetworkAccess.ts new file mode 100644 index 0000000..7b697b2 --- /dev/null +++ b/src/runtimes/openai/generated/v2/NetworkAccess.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type NetworkAccess = "restricted" | "enabled"; diff --git a/src/runtimes/openai/generated/v2/SandboxMode.ts b/src/runtimes/openai/generated/v2/SandboxMode.ts new file mode 100644 index 0000000..b8cf432 --- /dev/null +++ b/src/runtimes/openai/generated/v2/SandboxMode.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SandboxMode = "read-only" | "workspace-write" | "danger-full-access"; diff --git a/src/runtimes/openai/generated/v2/SandboxPolicy.ts b/src/runtimes/openai/generated/v2/SandboxPolicy.ts new file mode 100644 index 0000000..fe2700b --- /dev/null +++ b/src/runtimes/openai/generated/v2/SandboxPolicy.ts @@ -0,0 +1,17 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { NetworkAccess } from "./NetworkAccess"; + +export type SandboxPolicy = + | { type: "dangerFullAccess" } + | { type: "readOnly"; networkAccess: boolean } + | { type: "externalSandbox"; networkAccess: NetworkAccess } + | { + type: "workspaceWrite"; + writableRoots: Array; + networkAccess: boolean; + excludeTmpdirEnvVar: boolean; + excludeSlashTmp: boolean; + }; diff --git a/src/runtimes/openai/generated/v2/SelectedCapabilityRoot.ts b/src/runtimes/openai/generated/v2/SelectedCapabilityRoot.ts new file mode 100644 index 0000000..65b7093 --- /dev/null +++ b/src/runtimes/openai/generated/v2/SelectedCapabilityRoot.ts @@ -0,0 +1,18 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { CapabilityRootLocation } from "./CapabilityRootLocation"; + +/** + * A user-selected root that can expose one or more runtime capabilities. + */ +export type SelectedCapabilityRoot = { + /** + * Stable identifier supplied by the capability selection platform. + */ + id: string; + /** + * Where the selected root can be resolved. + */ + location: CapabilityRootLocation; +}; diff --git a/src/runtimes/openai/generated/v2/SortDirection.ts b/src/runtimes/openai/generated/v2/SortDirection.ts new file mode 100644 index 0000000..d8597a4 --- /dev/null +++ b/src/runtimes/openai/generated/v2/SortDirection.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type SortDirection = "asc" | "desc"; diff --git a/src/runtimes/openai/generated/v2/TextElement.ts b/src/runtimes/openai/generated/v2/TextElement.ts new file mode 100644 index 0000000..e81e531 --- /dev/null +++ b/src/runtimes/openai/generated/v2/TextElement.ts @@ -0,0 +1,15 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ByteRange } from "./ByteRange"; + +export type TextElement = { + /** + * Byte range in the parent `text` buffer that this element occupies. + */ + byteRange: ByteRange; + /** + * Optional human-readable placeholder for the element, displayed in the UI. + */ + placeholder: string | null; +}; diff --git a/src/runtimes/openai/generated/v2/ThreadBackgroundTerminalsCleanParams.ts b/src/runtimes/openai/generated/v2/ThreadBackgroundTerminalsCleanParams.ts new file mode 100644 index 0000000..0b8f181 --- /dev/null +++ b/src/runtimes/openai/generated/v2/ThreadBackgroundTerminalsCleanParams.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadBackgroundTerminalsCleanParams = { threadId: string }; diff --git a/src/runtimes/openai/generated/v2/ThreadHistoryMode.ts b/src/runtimes/openai/generated/v2/ThreadHistoryMode.ts new file mode 100644 index 0000000..db0f2d8 --- /dev/null +++ b/src/runtimes/openai/generated/v2/ThreadHistoryMode.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadHistoryMode = "legacy" | "paginated"; diff --git a/src/runtimes/openai/generated/v2/ThreadInjectItemsParams.ts b/src/runtimes/openai/generated/v2/ThreadInjectItemsParams.ts new file mode 100644 index 0000000..34f1513 --- /dev/null +++ b/src/runtimes/openai/generated/v2/ThreadInjectItemsParams.ts @@ -0,0 +1,12 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { JsonValue } from "../serde_json/JsonValue"; + +export type ThreadInjectItemsParams = { + threadId: string; + /** + * Raw Responses API items to append to the thread's model-visible history. + */ + items: Array; +}; diff --git a/src/runtimes/openai/generated/v2/ThreadResumeInitialTurnsPageParams.ts b/src/runtimes/openai/generated/v2/ThreadResumeInitialTurnsPageParams.ts new file mode 100644 index 0000000..096e993 --- /dev/null +++ b/src/runtimes/openai/generated/v2/ThreadResumeInitialTurnsPageParams.ts @@ -0,0 +1,20 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { SortDirection } from "./SortDirection"; +import type { TurnItemsView } from "./TurnItemsView"; + +export type ThreadResumeInitialTurnsPageParams = { + /** + * Optional turn page size. + */ + limit?: number | null; + /** + * Optional turn pagination direction; defaults to descending. + */ + sortDirection?: SortDirection | null; + /** + * How much item detail to include for each returned turn; defaults to summary. + */ + itemsView?: TurnItemsView | null; +}; diff --git a/src/runtimes/openai/generated/v2/ThreadResumeParams.ts b/src/runtimes/openai/generated/v2/ThreadResumeParams.ts new file mode 100644 index 0000000..0c62e9a --- /dev/null +++ b/src/runtimes/openai/generated/v2/ThreadResumeParams.ts @@ -0,0 +1,84 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { Personality } from "../Personality"; +import type { ResponseItem } from "../ResponseItem"; +import type { JsonValue } from "../serde_json/JsonValue"; +import type { ApprovalsReviewer } from "./ApprovalsReviewer"; +import type { AskForApproval } from "./AskForApproval"; +import type { SandboxMode } from "./SandboxMode"; +import type { ThreadResumeInitialTurnsPageParams } from "./ThreadResumeInitialTurnsPageParams"; + +/** + * There are three ways to resume a thread: + * 1. By thread_id: load the thread from disk by thread_id and resume it. + * 2. By history: instantiate the thread from memory and resume it. + * 3. By path: load the thread from disk by path and resume it. + * + * For non-running threads, the precedence is: history > non-empty path > thread_id. + * If using history or a non-empty path for a non-running thread, the thread_id + * param will be ignored. + * + * If thread_id identifies a running thread, app-server rejoins that thread and + * treats a non-empty path as a consistency check against the active rollout path. + * Empty string path values are treated as absent. + * + * Prefer using thread_id whenever possible. + */ +export type ThreadResumeParams = { + threadId: string; + /** + * [UNSTABLE] FOR CODEX CLOUD - DO NOT USE. + * If specified, the thread will be resumed with the provided history + * instead of loaded from disk. + */ + history?: Array | null; + /** + * [UNSTABLE] Specify the rollout path to resume from. + * If specified for a non-running thread, the thread_id param will be ignored. + * If thread_id identifies a running thread, the path must match the active + * rollout path. + */ + path?: string | null; + /** + * Configuration overrides for the resumed thread, if any. + */ + model?: string | null; + modelProvider?: string | null; + serviceTier?: string | null | null; + cwd?: string | null; + /** + * Replace the thread's runtime workspace roots. Paths must be absolute. + */ + runtimeWorkspaceRoots?: Array | null; + approvalPolicy?: AskForApproval | null; + /** + * Override where approval requests are routed for review on this thread + * and subsequent turns. + */ + approvalsReviewer?: ApprovalsReviewer | null; + sandbox?: SandboxMode | null; + /** + * Named profile id for the resumed thread. Cannot be combined with + * `sandbox`. + */ + permissions?: string | null; + config?: { [key in string]?: JsonValue } | null; + baseInstructions?: string | null; + developerInstructions?: string | null; + personality?: Personality | null; + /** + * When true, return only thread metadata and live-resume state without + * populating `thread.turns`. This is useful when the client plans to call + * `thread/turns/list` immediately after resuming. Full-history hydration + * is deprecated for paginated threads; use this with `thread/turns/list` + * and `thread/items/list` instead. + */ + excludeTurns?: boolean; + /** + * When present, include a `thread/turns/list` page in the resume response + * so clients can bootstrap recent turns without a second request. + */ + initialTurnsPage?: ThreadResumeInitialTurnsPageParams | null; +}; diff --git a/src/runtimes/openai/generated/v2/ThreadSource.ts b/src/runtimes/openai/generated/v2/ThreadSource.ts new file mode 100644 index 0000000..f27154a --- /dev/null +++ b/src/runtimes/openai/generated/v2/ThreadSource.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadSource = string; diff --git a/src/runtimes/openai/generated/v2/ThreadStartParams.ts b/src/runtimes/openai/generated/v2/ThreadStartParams.ts new file mode 100644 index 0000000..35745ab --- /dev/null +++ b/src/runtimes/openai/generated/v2/ThreadStartParams.ts @@ -0,0 +1,91 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { MultiAgentMode } from "../MultiAgentMode"; +import type { Personality } from "../Personality"; +import type { JsonValue } from "../serde_json/JsonValue"; +import type { ApprovalsReviewer } from "./ApprovalsReviewer"; +import type { AskForApproval } from "./AskForApproval"; +import type { DynamicToolSpec } from "./DynamicToolSpec"; +import type { SandboxMode } from "./SandboxMode"; +import type { SelectedCapabilityRoot } from "./SelectedCapabilityRoot"; +import type { ThreadHistoryMode } from "./ThreadHistoryMode"; +import type { ThreadSource } from "./ThreadSource"; +import type { ThreadStartSource } from "./ThreadStartSource"; +import type { TurnEnvironmentParams } from "./TurnEnvironmentParams"; + +export type ThreadStartParams = { + model?: string | null; + modelProvider?: string | null; + /** + * Allow a provider with an authoritative static model catalog to replace an unavailable + * requested model with its default. + */ + allowProviderModelFallback?: boolean; + serviceTier?: string | null | null; + cwd?: string | null; + /** + * Replace the thread's runtime workspace roots. Paths must be absolute. + */ + runtimeWorkspaceRoots?: Array | null; + approvalPolicy?: AskForApproval | null; + /** + * Override where approval requests are routed for review on this thread + * and subsequent turns. + */ + approvalsReviewer?: ApprovalsReviewer | null; + sandbox?: SandboxMode | null; + /** + * Named profile id for this thread. Cannot be combined with `sandbox`. + */ + permissions?: string | null; + config?: { [key in string]?: JsonValue } | null; + serviceName?: string | null; + baseInstructions?: string | null; + developerInstructions?: string | null; + personality?: Personality | null; + /** + * @deprecated Ignored. Use Ultra reasoning effort for proactive multi-agent behavior. + */ + multiAgentMode?: MultiAgentMode | null; + ephemeral?: boolean | null; + /** + * Persisted thread history contract to use for this new thread. + */ + historyMode?: ThreadHistoryMode | null; + sessionStartSource?: ThreadStartSource | null; + /** + * Optional client-supplied analytics source classification for this thread. + */ + threadSource?: ThreadSource | null; + /** + * Optional project identity for this new thread. Durable threads persist + * the assignment; ephemeral threads expose it only in live responses. + */ + projectId?: string | null; + /** + * Optional sticky environments for this thread. + * + * Omitted selects the default environment when environment access is + * enabled. Empty disables environment access for turns that do not + * provide a turn override. Non-empty selects the first environment as the + * current turn environment. + */ + environments?: Array | null; + dynamicTools?: Array | null; + /** + * Capability roots selected for this thread by the hosting platform. + */ + selectedCapabilityRoots?: Array | null; + /** + * Test-only experimental field used to validate experimental gating and + * schema filtering behavior in a stable way. + */ + mockExperimentalField?: string | null; + /** + * If true, opt into emitting raw Responses API items on the event stream. + * This is for internal use only (e.g. Codex Cloud). + */ + experimentalRawEvents?: boolean; +}; diff --git a/src/runtimes/openai/generated/v2/ThreadStartSource.ts b/src/runtimes/openai/generated/v2/ThreadStartSource.ts new file mode 100644 index 0000000..ea1b839 --- /dev/null +++ b/src/runtimes/openai/generated/v2/ThreadStartSource.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ThreadStartSource = "startup" | "clear"; diff --git a/src/runtimes/openai/generated/v2/TurnEnvironmentParams.ts b/src/runtimes/openai/generated/v2/TurnEnvironmentParams.ts new file mode 100644 index 0000000..566cba2 --- /dev/null +++ b/src/runtimes/openai/generated/v2/TurnEnvironmentParams.ts @@ -0,0 +1,13 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { LegacyAppPathString } from "../LegacyAppPathString"; + +export type TurnEnvironmentParams = { + environmentId: string; + cwd: LegacyAppPathString; + /** + * Environment-native runtime workspace roots. Omitted defaults to `cwd`. + */ + runtimeWorkspaceRoots?: Array | null; +}; diff --git a/src/runtimes/openai/generated/v2/TurnItemsView.ts b/src/runtimes/openai/generated/v2/TurnItemsView.ts new file mode 100644 index 0000000..9056923 --- /dev/null +++ b/src/runtimes/openai/generated/v2/TurnItemsView.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type TurnItemsView = "notLoaded" | "summary" | "full"; diff --git a/src/runtimes/openai/generated/v2/TurnStartParams.ts b/src/runtimes/openai/generated/v2/TurnStartParams.ts new file mode 100644 index 0000000..2284507 --- /dev/null +++ b/src/runtimes/openai/generated/v2/TurnStartParams.ts @@ -0,0 +1,127 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { CollaborationMode } from "../CollaborationMode"; +import type { MultiAgentMode } from "../MultiAgentMode"; +import type { Personality } from "../Personality"; +import type { ReasoningEffort } from "../ReasoningEffort"; +import type { ReasoningSummary } from "../ReasoningSummary"; +import type { JsonValue } from "../serde_json/JsonValue"; +import type { AdditionalContextEntry } from "./AdditionalContextEntry"; +import type { ApprovalsReviewer } from "./ApprovalsReviewer"; +import type { AskForApproval } from "./AskForApproval"; +import type { CyberAccessProgram } from "./CyberAccessProgram"; +import type { SandboxPolicy } from "./SandboxPolicy"; +import type { TurnEnvironmentParams } from "./TurnEnvironmentParams"; +import type { TurnToolOutput } from "./TurnToolOutput"; +import type { UserInput } from "./UserInput"; + +export type TurnStartParams = { + threadId: string; + clientUserMessageId?: string | null; + input: Array; + /** + * Optional source classification for the caller that starts this turn. + * Ignored when this request steers an already-active turn. + */ + turnTrigger?: string | null; + toolOutput?: TurnToolOutput | null; + /** + * Optional metadata to enrich Codex's ResponsesAPI turn metadata. + * + * Entries are flattened into the JSON string sent as + * `client_metadata["x-codex-turn-metadata"]` on ResponsesAPI HTTP and websocket requests. + * + * They are not sent as top-level ResponsesAPI `client_metadata` keys, and reserved keys + * such as `session_id`, `thread_id`, `turn_id`, and `window_id` cannot be overridden. + */ + responsesapiClientMetadata?: { [key in string]?: string } | null; + /** + * Optional client-provided context fragments keyed by an opaque source identifier. + */ + additionalContext?: { [key in string]?: AdditionalContextEntry } | null; + /** + * Optional environments for this turn and subsequent turns. + * + * Omitted uses the thread sticky environments. Empty disables + * environment access for this turn. Non-empty selects the first + * environment as the current turn environment for this turn. + */ + environments?: Array | null; + /** + * Override the working directory for this turn and subsequent turns. + */ + cwd?: string | null; + /** + * Replace the thread's runtime workspace roots for this turn and + * subsequent turns. Paths must be absolute. + */ + runtimeWorkspaceRoots?: Array | null; + /** + * Override the approval policy for this turn and subsequent turns. + */ + approvalPolicy?: AskForApproval | null; + /** + * Override where approval requests are routed for review on this turn and + * subsequent turns. + */ + approvalsReviewer?: ApprovalsReviewer | null; + /** + * Override the sandbox policy for this turn and subsequent turns. + */ + sandboxPolicy?: SandboxPolicy | null; + /** + * Select a named permissions profile id for this turn and subsequent + * turns. Cannot be combined with `sandboxPolicy`. + */ + permissions?: string | null; + /** + * Override the model for this turn and subsequent turns. + */ + model?: string | null; + /** + * Override the service tier for this turn and subsequent turns. + */ + serviceTier?: string | null | null; + /** + * Override the service tier only when this request starts a new turn. + * Use "default" for standard speed. Omitted or null inherits the thread's tier. + * Does not change the thread's tier or a turn being steered. + */ + serviceTierForTurn?: string | null; + /** + * Override the reasoning effort for this turn and subsequent turns. + */ + effort?: ReasoningEffort | null; + /** + * Override the reasoning summary for this turn and subsequent turns. + */ + summary?: ReasoningSummary | null; + /** + * Override the personality for this turn and subsequent turns. + */ + personality?: Personality | null; + /** + * Optional JSON Schema used to constrain the final assistant message for + * this turn. + */ + outputSchema?: JsonValue | null; + /** + * EXPERIMENTAL - Set a pre-set collaboration mode. + * Takes precedence over model, reasoning_effort, and developer instructions if set. + * + * For `collaboration_mode.settings.developer_instructions`, `null` means + * "use the built-in instructions for the selected mode". + */ + collaborationMode?: CollaborationMode | null; + /** + * @deprecated Ignored. Use `effort: "ultra"` for proactive multi-agent behavior. + */ + multiAgentMode?: MultiAgentMode | null; + /** + * EXPERIMENTAL - Request a workspace-authorized cyber program for this + * turn. Omission preserves automatic behavior. This does not grant access. + */ + cyberAccessProgram?: CyberAccessProgram | null; +}; diff --git a/src/runtimes/openai/generated/v2/TurnStatus.ts b/src/runtimes/openai/generated/v2/TurnStatus.ts new file mode 100644 index 0000000..476922e --- /dev/null +++ b/src/runtimes/openai/generated/v2/TurnStatus.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type TurnStatus = "completed" | "interrupted" | "failed" | "inProgress"; diff --git a/src/runtimes/openai/generated/v2/TurnToolOutput.ts b/src/runtimes/openai/generated/v2/TurnToolOutput.ts new file mode 100644 index 0000000..d4e731a --- /dev/null +++ b/src/runtimes/openai/generated/v2/TurnToolOutput.ts @@ -0,0 +1,10 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FunctionCallOutputBody } from "../FunctionCallOutputBody"; + +export type TurnToolOutput = { + name: string; + namespace: string | null; + output: FunctionCallOutputBody; +}; diff --git a/src/runtimes/openai/generated/v2/UserInput.ts b/src/runtimes/openai/generated/v2/UserInput.ts new file mode 100644 index 0000000..f879af0 --- /dev/null +++ b/src/runtimes/openai/generated/v2/UserInput.ts @@ -0,0 +1,21 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ImageDetail } from "../ImageDetail"; +import type { TextElement } from "./TextElement"; + +export type UserInput = + | { + type: "text"; + text: string; + /** + * UI-defined spans within `text` used to render or persist special elements. + */ + text_elements: Array; + } + | { type: "image"; detail?: ImageDetail; url: string } + | { type: "localImage"; detail?: ImageDetail; path: string } + | { type: "audio"; url: string } + | { type: "localAudio"; path: string } + | { type: "skill"; name: string; path: string } + | { type: "mention"; name: string; path: string }; diff --git a/src/runtimes/openai/private-citation-filter.ts b/src/runtimes/openai/private-citation-filter.ts index a39e798..d1e202a 100644 --- a/src/runtimes/openai/private-citation-filter.ts +++ b/src/runtimes/openai/private-citation-filter.ts @@ -123,15 +123,20 @@ export class OpenAiPrivateCitationStreamFilter { } finish(): OpenAiPrivateCitationFilterResult { + const result = this.previewFinish(); + this.#discardUntilEnd = false; + this.#pendingMarkup = ""; + return result; + } + + previewFinish(): OpenAiPrivateCitationFilterResult { if (this.#discardUntilEnd) { - this.#discardUntilEnd = false; return { privateCitationCount: 0, text: "" }; } const result = scanOpenAiPrivateCitations(this.#pendingMarkup, { preserveIncompleteMarkup: false, }); - this.#pendingMarkup = ""; return { privateCitationCount: result.privateCitationCount, diff --git a/src/runtimes/provider-json.ts b/src/runtimes/provider-json.ts index 88e6378..35d0a74 100644 --- a/src/runtimes/provider-json.ts +++ b/src/runtimes/provider-json.ts @@ -2,6 +2,67 @@ import { formatLogValue } from "../observability"; export type JsonObject = Record; +function jsonStringContentBytes(value: string): number { + return Buffer.byteLength(JSON.stringify(value), "utf8") - 2; +} + +export function chunkJsonText( + text: string, + firstChunkBytes: number, + remainingChunkBytes = firstChunkBytes, +): string[] { + if (text.length === 0 || jsonStringContentBytes(text) <= firstChunkBytes) { + return [text]; + } + + const chunks: string[] = []; + let offset = 0; + let chunkBytes = firstChunkBytes; + + while (offset < text.length) { + let end = offset; + let low = offset + 1; + let high = Math.min(text.length, offset + chunkBytes); + + while (low <= high) { + const probe = Math.floor((low + high) / 2); + let candidate = probe; + const previous = text.charCodeAt(candidate - 1); + const next = text.charCodeAt(candidate); + + if ( + candidate < text.length && + previous >= 0xd800 && + previous <= 0xdbff && + next >= 0xdc00 && + next <= 0xdfff + ) { + candidate -= 1; + } + + if (jsonStringContentBytes(text.slice(offset, candidate)) <= chunkBytes) { + end = candidate; + low = probe + 1; + } else { + high = probe - 1; + } + } + + if (end === offset) { + if (chunks.length > 0) { + throw new RangeError("JSON text chunk byte budget cannot fit the next character."); + } + chunks.push(""); + } else { + chunks.push(text.slice(offset, end)); + offset = end; + } + chunkBytes = remainingChunkBytes; + } + + return chunks; +} + export function isRecord(value: unknown): value is JsonObject { return value !== null && typeof value === "object" && !Array.isArray(value); } @@ -16,6 +77,11 @@ export function readNonEmptyString(value: JsonObject | null, key: string): strin return entry !== null && entry.length > 0 ? entry : null; } +export function readNumber(value: JsonObject | null, key: string): number | null { + const entry = value?.[key]; + return typeof entry === "number" && Number.isFinite(entry) ? entry : null; +} + export function readRecord(value: JsonObject | null, key: string): JsonObject | null { const entry = value?.[key]; return isRecord(entry) ? entry : null; diff --git a/src/runtimes/provider-options.ts b/src/runtimes/provider-options.ts index eb210a8..059807e 100644 --- a/src/runtimes/provider-options.ts +++ b/src/runtimes/provider-options.ts @@ -9,19 +9,18 @@ function deepMergeRecords( base: Record, providerOptions: JsonObject, ): Record { - const result: Record = { ...base }; - - for (const [key, value] of Object.entries(providerOptions)) { - const current = result[key]; - - if (isMergeableRecord(current) && isJsonObject(value)) { - result[key] = deepMergeRecords(current, value); - } else { - result[key] = structuredClone(value); - } - } - - return result; + return Object.fromEntries([ + ...Object.entries(base), + ...Object.entries(providerOptions).map(([key, value]) => { + const current = Object.hasOwn(base, key) ? base[key] : undefined; + return [ + key, + isMergeableRecord(current) && isJsonObject(value) + ? deepMergeRecords(current, value) + : structuredClone(value), + ]; + }), + ]); } export function mergeProviderOptions(base: T, providerOptions: JsonObject): T { diff --git a/src/runtimes/provider-registry.ts b/src/runtimes/provider-registry.ts index 488af3e..68a999f 100644 --- a/src/runtimes/provider-registry.ts +++ b/src/runtimes/provider-registry.ts @@ -1,4 +1,3 @@ -import type { AgentDriverHostPortName } from "../host-ports"; import type { DriverRuntime, DriverRuntimeTransport } from "../protocol/runtime"; import type { DriverStartInput } from "../protocol/start"; import type { DriverCapability } from "../runtime-command"; @@ -11,24 +10,10 @@ export interface AgentDriverProviderDescriptor { readonly capabilities: readonly DriverCapability[]; createBackend(input: DriverStartInput): AgentDriverBackend; readonly id: DriverRuntimeTransport; - readonly requiredHostPorts: readonly AgentDriverHostPortName[]; readonly runtime: DriverRuntime; } -export interface AgentDriverProviderRegistry { - createBackend(input: DriverStartInput): AgentDriverBackend; - getByStartInput(input: DriverStartInput): AgentDriverProviderDescriptor; - list(): readonly AgentDriverProviderDescriptor[]; -} - -const SHARED_REQUIRED_HOST_PORTS = [ - "event_sink", - "permission", - "mcp", - "skill", -] as const satisfies readonly AgentDriverHostPortName[]; - -const TEXT_TOOL_CAPABILITIES = [ +const PROVIDER_CAPABILITIES = [ { id: "custom_tool_execute", status: "unsupported", version: 1 }, { id: "file_change", status: "supported", version: 1 }, { id: "input_start", status: "supported", version: 1 }, @@ -40,92 +25,52 @@ const TEXT_TOOL_CAPABILITIES = [ { id: "turn_cancel", status: "supported", version: 1 }, { id: "usage", status: "supported", version: 1 }, { id: "visible_activity", status: "supported", version: 1 }, + { id: "native_resume", status: "supported", version: 1 }, + { id: "thinking_stream", status: "supported", version: 1 }, ] as const satisfies readonly DriverCapability[]; const PROVIDERS = [ { - capabilities: [ - ...TEXT_TOOL_CAPABILITIES, - { id: "native_resume", status: "supported", version: 1 }, - { id: "thinking_stream", status: "unsupported", version: 1 }, - ], + capabilities: PROVIDER_CAPABILITIES, createBackend: (payload) => new OpenAiAppServerDriverBackend(payload), id: "openai-app-server", - requiredHostPorts: SHARED_REQUIRED_HOST_PORTS, runtime: "openai-runtime", }, { - capabilities: [ - ...TEXT_TOOL_CAPABILITIES, - { id: "native_resume", status: "supported", version: 1 }, - { id: "thinking_stream", status: "supported", version: 1 }, - ], + capabilities: PROVIDER_CAPABILITIES, createBackend: (payload) => new ClaudeAgentSdkDriverBackend(payload), id: "claude-agent-sdk", - requiredHostPorts: SHARED_REQUIRED_HOST_PORTS, runtime: "claude-agent-sdk", }, { - capabilities: [ - ...TEXT_TOOL_CAPABILITIES, - { id: "native_resume", status: "supported", version: 1 }, - { id: "thinking_stream", status: "supported", version: 1 }, - ], + capabilities: PROVIDER_CAPABILITIES, createBackend: (payload) => new AcpDriverBackend(payload), id: "acp-fallback", - requiredHostPorts: [...SHARED_REQUIRED_HOST_PORTS, "file", "host_integration"], runtime: "acp-fallback", }, ] as const satisfies readonly AgentDriverProviderDescriptor[]; -export function createAgentDriverProviderRegistry( - providers: readonly AgentDriverProviderDescriptor[] = PROVIDERS, -): AgentDriverProviderRegistry { - const providersByTransport = new Map(); - - for (const provider of providers) { - registerProviderTransport(providersByTransport, provider, provider.id); - } - - return { - createBackend(input) { - return this.getByStartInput(input).createBackend(input); - }, - getByStartInput(input) { - return resolveProviderForStartInput(providersByTransport, input); - }, - list() { - return providers; - }, - }; -} - -export const AGENT_DRIVER_PROVIDER_REGISTRY = createAgentDriverProviderRegistry(); +export const AGENT_DRIVER_PROVIDER_REGISTRY = { + createBackend(input: DriverStartInput): AgentDriverBackend { + return resolveProviderForStartInput(input).createBackend(input); + }, + getByStartInput: resolveProviderForStartInput, + list: () => PROVIDERS, +}; export function createAgentDriverProviderCapabilities(input: { permissionRequestStatus: DriverCapability["status"]; provider: AgentDriverProviderDescriptor; }): readonly DriverCapability[] { - const capabilitiesById = new Map(); - - for (const capability of input.provider.capabilities) { - capabilitiesById.set(capability.id, capability); - } - - capabilitiesById.set("permission_request", { - id: "permission_request", - status: input.permissionRequestStatus, - version: 1, - }); - - return [...capabilitiesById.values()]; + return input.provider.capabilities.map((capability) => + capability.id === "permission_request" + ? { ...capability, status: input.permissionRequestStatus } + : capability, + ); } -function resolveProviderForStartInput( - providersByTransport: Map, - input: DriverStartInput, -): AgentDriverProviderDescriptor { - const provider = providersByTransport.get(input.runtimeTransport); +function resolveProviderForStartInput(input: DriverStartInput): AgentDriverProviderDescriptor { + const provider = PROVIDERS.find((candidate) => candidate.id === input.runtimeTransport); if (!provider) { throw new Error(`Unsupported runtime transport: ${input.runtimeTransport}.`); @@ -144,19 +89,3 @@ function resolveProviderForStartInput( return provider; } - -function registerProviderTransport( - providersByTransport: Map, - provider: AgentDriverProviderDescriptor, - transport: DriverRuntimeTransport, -): void { - const existing = providersByTransport.get(transport); - - if (existing) { - throw new Error( - `Runtime transport ${transport} is already registered by provider ${existing.id}.`, - ); - } - - providersByTransport.set(transport, provider); -} diff --git a/src/runtimes/runtime-public-id.ts b/src/runtimes/runtime-public-id.ts new file mode 100644 index 0000000..b0e4adc --- /dev/null +++ b/src/runtimes/runtime-public-id.ts @@ -0,0 +1,38 @@ +import { createHash } from "node:crypto"; + +const MAX_PUBLIC_NATIVE_ID_BYTES = 256; +const HASHED_PUBLIC_ID_PATTERN = /^rid1_[A-Za-z0-9_-]{43}$/u; +const HASH_DOMAIN = "mosoo.runtime-public-id/v1"; +const SOURCE_EVENT_HASH_DOMAIN = "mosoo.runtime-source-event-id/v1"; + +type RuntimePublicIdNamespace = + | "claude-agent" + | "claude-task" + | "claude-tool" + | "openai-item" + | "openai-thread" + | "openai-turn"; + +export function toRuntimePublicId(nativeId: string, namespace: RuntimePublicIdNamespace): string { + if ( + Buffer.byteLength(nativeId, "utf8") <= MAX_PUBLIC_NATIVE_ID_BYTES && + !HASHED_PUBLIC_ID_PATTERN.test(nativeId) + ) { + return nativeId; + } + + const digest = createHash("sha256") + .update(JSON.stringify([HASH_DOMAIN, namespace, nativeId])) + .digest("base64url"); + return `rid1_${digest}`; +} + +export function createRuntimeSourceEventId( + scope: string, + ...identity: readonly (number | string)[] +): string { + const digest = createHash("sha256") + .update(JSON.stringify([SOURCE_EVENT_HASH_DOMAIN, scope, ...identity])) + .digest("base64url"); + return `${scope}:sid1_${digest}`; +} diff --git a/src/runtimes/runtime-turn-transcript.ts b/src/runtimes/runtime-turn-transcript.ts index 555076d..5c7de2a 100644 --- a/src/runtimes/runtime-turn-transcript.ts +++ b/src/runtimes/runtime-turn-transcript.ts @@ -1,26 +1,21 @@ -import { createDriverId } from "../protocol/id"; -import type { MessageId } from "../protocol/id"; +import { createHash } from "node:crypto"; -export class RuntimeAssistantMessageIdIndex { - readonly #messageIds = new Map(); +import { createDriverIdFromBytes } from "../protocol/id"; +import type { MessageId, SessionId } from "../protocol/id"; - get(key: TKey): MessageId | null { - return this.#messageIds.get(key) ?? null; - } +type RuntimeAssistantMessageNamespace = + | "claude-assistant" + | "claude-auxiliary" + | "openai-message" + | "openai-reasoning"; - getOrCreate(key: TKey): MessageId { - const existing = this.#messageIds.get(key); - - if (existing !== undefined) { - return existing; - } - - const messageId = createDriverId() as MessageId; - this.#messageIds.set(key, messageId); - return messageId; - } - - reset(): void { - this.#messageIds.clear(); - } +export function createRuntimeAssistantMessageId( + sessionId: SessionId, + namespace: RuntimeAssistantMessageNamespace, + key: string, +): MessageId { + const digest = createHash("sha256") + .update(JSON.stringify(["mosoo.runtime-assistant-message-id/v1", sessionId, namespace, key])) + .digest(); + return createDriverIdFromBytes(digest.subarray(0, 16)) as MessageId; } diff --git a/src/runtimes/skill-bootstrap.ts b/src/runtimes/skill-bootstrap.ts index 7f15455..eae650d 100644 --- a/src/runtimes/skill-bootstrap.ts +++ b/src/runtimes/skill-bootstrap.ts @@ -1,9 +1,28 @@ import { createHash } from "node:crypto"; -import { mkdir, writeFile } from "node:fs/promises"; -import { join } from "node:path"; +import { readlink, symlink, unlink } from "node:fs/promises"; +import type { FileHandle } from "node:fs/promises"; +import { dirname, join, relative, resolve } from "node:path"; +import type { AgentDriverMaterializedSkill } from "../host-ports"; +import type { Logger } from "../observability"; import type { DriverSkillCatalogEntry } from "../protocol/boot"; import type { DriverExecutionInput } from "../protocol/execution"; +import { + assertDirectoryIdentity, + closeFileHandles, + cleanupAtomicWriteTemporaryFiles, + directoryEntryPath, + ensureAbsoluteRealDirectory, + ensureRealDirectoryAt, + hasErrorCode, + openAbsoluteRealDirectory, + openOptionalRealDirectory, + readDirectoryEntriesBounded, + writeFileAtomically, +} from "./atomic-file"; + +const NATIVE_SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const MAX_NATIVE_SKILL_ALIAS_ENTRIES = 1_024; interface SkillCatalogManifestEntry { frontmatter: DriverSkillCatalogEntry["frontmatter"]; @@ -23,21 +42,338 @@ function getSkillCatalogRoot(execution: DriverExecutionInput): string { return join(execution.session.sharedRootPath, ".mosoo", "skills"); } +async function openOptionalAbsoluteRealDirectory( + path: string, + label: string, +): Promise { + try { + return await openAbsoluteRealDirectory(path, label); + } catch (error) { + if (hasErrorCode(error, "ENOENT")) { + return null; + } + throw error; + } +} + +async function ensureNativeSkillAliasRoot( + sharedRootPath: string, + create: boolean, + signal: AbortSignal, +): Promise<{ directory: FileHandle; path: string } | null> { + signal.throwIfAborted(); + const sharedRoot = resolve(sharedRootPath); + const aliasRoot = join(sharedRoot, ".agents", "skills"); + const sharedRootDirectory = await openAbsoluteRealDirectory(sharedRoot, "Session shared root"); + let agentsRootDirectory: FileHandle | null = null; + let aliasDirectory: FileHandle | null = null; + + try { + agentsRootDirectory = create + ? await ensureRealDirectoryAt( + sharedRootDirectory, + ".agents", + "Native skill alias root", + signal, + ) + : await openOptionalRealDirectory( + directoryEntryPath(sharedRootDirectory, ".agents"), + "Native skill alias root", + ); + if (agentsRootDirectory !== null) { + aliasDirectory = create + ? await ensureRealDirectoryAt( + agentsRootDirectory, + "skills", + "Native skill alias root", + signal, + ) + : await openOptionalRealDirectory( + directoryEntryPath(agentsRootDirectory, "skills"), + "Native skill alias root", + ); + } + } catch (error) { + const closeFailures = await closeFileHandles([ + aliasDirectory, + agentsRootDirectory, + sharedRootDirectory, + ]); + if (closeFailures.length > 0) { + throw new AggregateError( + [error, ...closeFailures], + "Failed to open native skill alias root.", + ); + } + throw error; + } + + const closeFailures = await closeFileHandles([agentsRootDirectory, sharedRootDirectory]); + if (closeFailures.length > 0) { + const aliasCloseFailures = await closeFileHandles([aliasDirectory]); + throw new AggregateError( + [...closeFailures, ...aliasCloseFailures], + "Failed to close native skill alias ancestors.", + ); + } + return aliasDirectory === null ? null : { directory: aliasDirectory, path: aliasRoot }; +} + +function resolveMaterializedSkillMount(sharedRootPath: string, mountPath: string): string { + const resolvedMount = resolve(mountPath); + if (dirname(resolvedMount) !== resolve(sharedRootPath, ".mosoo", "skill")) { + throw new Error(`Resolved skill mount path is outside the allowed root: ${mountPath}.`); + } + return resolvedMount; +} + +function isManagedNativeSkillAliasTarget(sharedRootPath: string, target: string): boolean { + return dirname(resolve(target)) === resolve(sharedRootPath, ".mosoo", "skill"); +} + +export async function exposeNativeSkillAliases( + execution: DriverExecutionInput, + logger: Logger, + materializedSkills: readonly AgentDriverMaterializedSkill[], + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + const desired = new Map(); + + for (const skill of materializedSkills) { + signal.throwIfAborted(); + if (skill.skillName.length > 64 || !NATIVE_SKILL_NAME_PATTERN.test(skill.skillName)) { + logger.warn("driver.skill.native_alias.skipped", { + reason: "invalid_name", + skillId: skill.skillId, + skillName: skill.skillName, + }); + continue; + } + if (desired.has(skill.skillName)) { + throw new Error(`Materialized skills contain a duplicate skill name: ${skill.skillName}.`); + } + + const mountPath = resolveMaterializedSkillMount( + execution.session.sharedRootPath, + skill.mountPath, + ); + if (resolve(skill.skillMarkdownPath) !== join(mountPath, "SKILL.md")) { + throw new Error(`Native skill path mismatch for "${skill.skillName}".`); + } + desired.set(skill.skillName, skill); + } + + const aliasRoot = await ensureNativeSkillAliasRoot( + execution.session.sharedRootPath, + desired.size > 0, + signal, + ); + if (aliasRoot === null) { + return []; + } + + await using aliasDirectory = aliasRoot.directory; + const retainedAliases = new Set(); + const entries = await readDirectoryEntriesBounded( + aliasDirectory, + "Native skill alias root", + MAX_NATIVE_SKILL_ALIAS_ENTRIES, + signal, + ); + for (const entry of entries) { + signal.throwIfAborted(); + const aliasPath = directoryEntryPath(aliasDirectory, entry.name); + const skill = desired.get(entry.name); + + if (entry.isSymbolicLink()) { + const target = resolve(aliasRoot.path, await readlink(aliasPath)); + if (skill !== undefined && target === resolve(skill.mountPath)) { + retainedAliases.add(entry.name); + continue; + } + if (isManagedNativeSkillAliasTarget(execution.session.sharedRootPath, target)) { + await unlink(aliasPath); + continue; + } + } + if (skill !== undefined) { + throw new Error(`Native skill alias "${entry.name}" collides with an existing path.`); + } + } + + const result: string[] = []; + for (const [skillName, skill] of desired) { + signal.throwIfAborted(); + result.push(join(aliasRoot.path, skillName)); + if (!retainedAliases.has(skillName)) { + await symlink( + relative(aliasRoot.path, resolve(skill.mountPath)), + directoryEntryPath(aliasDirectory, skillName), + "dir", + ); + } + } + await aliasDirectory.sync(); + signal.throwIfAborted(); + await assertDirectoryIdentity(aliasDirectory, aliasRoot.path, "Native skill alias root"); + + return result; +} + +async function ensureSkillCatalogRoot( + execution: DriverExecutionInput, + signal: AbortSignal, +): Promise<{ directory: FileHandle; path: string }> { + signal.throwIfAborted(); + const sharedRoot = resolve(execution.session.sharedRootPath); + const mosooRoot = join(sharedRoot, ".mosoo"); + const skillCatalogRoot = join(mosooRoot, "skills"); + const sharedRootDirectory = await openAbsoluteRealDirectory(sharedRoot, "Session shared root"); + let mosooRootDirectory: FileHandle | null = null; + let skillCatalogRootDirectory: FileHandle | null = null; + let operationError: unknown = null; + + try { + mosooRootDirectory = await ensureRealDirectoryAt( + sharedRootDirectory, + ".mosoo", + "Skill bootstrap .mosoo root", + signal, + ); + skillCatalogRootDirectory = await ensureRealDirectoryAt( + mosooRootDirectory, + "skills", + "Skill bootstrap catalog root", + signal, + ); + } catch (error) { + operationError = error; + } + + const ancestorCloseFailures = await closeFileHandles([mosooRootDirectory, sharedRootDirectory]); + if (operationError !== null || ancestorCloseFailures.length > 0) { + const catalogCloseFailures = await closeFileHandles([skillCatalogRootDirectory]); + const failures = [...ancestorCloseFailures, ...catalogCloseFailures]; + if (operationError !== null) { + throw failures.length > 0 + ? new AggregateError([operationError, ...failures], "Failed to open skill catalog root.") + : operationError; + } + throw new AggregateError(failures, "Failed to close skill catalog ancestors."); + } + if (skillCatalogRootDirectory === null) { + throw new Error("Skill catalog root was not opened."); + } + return { directory: skillCatalogRootDirectory, path: skillCatalogRoot }; +} + +async function unlinkManagedFile( + directory: FileHandle, + name: string, + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + try { + await unlink(directoryEntryPath(directory, name)); + } catch (error) { + if (!hasErrorCode(error, "ENOENT")) { + throw error; + } + } +} + +async function removeSkillBootstrapArtifacts( + execution: DriverExecutionInput, + signal: AbortSignal, +): Promise { + const sharedRoot = resolve(execution.session.sharedRootPath); + const sharedRootDirectory = await openOptionalAbsoluteRealDirectory( + sharedRoot, + "Session shared root", + ); + if (sharedRootDirectory === null) { + return; + } + await using ownedSharedRootDirectory = sharedRootDirectory; + const mosooRootDirectory = await openOptionalRealDirectory( + directoryEntryPath(ownedSharedRootDirectory, ".mosoo"), + "Skill bootstrap .mosoo root", + ); + if (mosooRootDirectory === null) { + return; + } + await using ownedMosooRootDirectory = mosooRootDirectory; + const skillCatalogRootDirectory = await openOptionalRealDirectory( + directoryEntryPath(ownedMosooRootDirectory, "skills"), + "Skill bootstrap catalog root", + ); + if (skillCatalogRootDirectory === null) { + return; + } + await using ownedSkillCatalogRootDirectory = skillCatalogRootDirectory; + + await cleanupAtomicWriteTemporaryFiles( + ownedSkillCatalogRootDirectory, + ["manifest.json", "README.md"], + signal, + ); + await unlinkManagedFile(ownedSkillCatalogRootDirectory, "manifest.json", signal); + await unlinkManagedFile(ownedSkillCatalogRootDirectory, "README.md", signal); + await ownedSkillCatalogRootDirectory.sync(); + await assertDirectoryIdentity( + ownedSkillCatalogRootDirectory, + getSkillCatalogRoot(execution), + "Skill bootstrap catalog root", + ); +} + function getSkillCatalogManifestEntries( execution: DriverExecutionInput, + materializedSkills: readonly AgentDriverMaterializedSkill[], ): SkillCatalogManifestEntry[] { - return execution.skillCatalog.map((entry) => ({ - frontmatter: entry.frontmatter, - mountPath: entry.mountPath, - resolutionMode: entry.resolutionMode, - skillId: entry.skillId, - skillMarkdownPath: join(entry.mountPath, "SKILL.md"), - skillName: entry.skillName, - })); -} - -function buildSkillCatalogReadme(execution: DriverExecutionInput): string { - const manifestEntries = getSkillCatalogManifestEntries(execution); + const materializedById = new Map(materializedSkills.map((skill) => [skill.skillId, skill])); + const matchedSkillIds = new Set(); + const entries = execution.skillCatalog.map((entry) => { + if (entry.resolutionMode === "tombstone") { + return { + frontmatter: entry.frontmatter, + mountPath: entry.mountPath, + resolutionMode: entry.resolutionMode, + skillId: entry.skillId, + skillMarkdownPath: join(entry.mountPath, "SKILL.md"), + skillName: entry.skillName, + }; + } + + const materialized = materializedById.get(entry.skillId); + if ( + materialized === undefined || + materialized.skillName !== entry.skillName || + resolve(materialized.mountPath) !== resolve(entry.mountPath) + ) { + throw new Error(`Skill catalog does not match materialized skill ${entry.skillId}.`); + } + matchedSkillIds.add(entry.skillId); + + return { + frontmatter: entry.frontmatter, + mountPath: materialized.mountPath, + resolutionMode: entry.resolutionMode, + skillId: materialized.skillId, + skillMarkdownPath: materialized.skillMarkdownPath, + skillName: materialized.skillName, + }; + }); + + if (matchedSkillIds.size !== materializedSkills.length) { + throw new Error("Skill catalog does not match the materialized skill set."); + } + + return entries; +} + +function buildSkillCatalogReadme(manifestEntries: readonly SkillCatalogManifestEntry[]): string { const lines = [ "# Skill Catalog", "", @@ -72,56 +408,67 @@ function buildSkillCatalogReadme(execution: DriverExecutionInput): string { export async function writeSkillBootstrapArtifacts( execution: DriverExecutionInput, + materializedSkills: readonly AgentDriverMaterializedSkill[], + signal: AbortSignal, ): Promise { + signal.throwIfAborted(); + const manifestEntries = getSkillCatalogManifestEntries(execution, materializedSkills); + if (execution.skillCatalog.length === 0) { + await removeSkillBootstrapArtifacts(execution, signal); return null; } - const skillCatalogRoot = getSkillCatalogRoot(execution); - const manifestPath = join(skillCatalogRoot, "manifest.json"); - const readmePath = join(skillCatalogRoot, "README.md"); + const skillCatalogRoot = await ensureSkillCatalogRoot(execution, signal); + await using skillCatalogDirectory = skillCatalogRoot.directory; + const manifestPath = join(skillCatalogRoot.path, "manifest.json"); + const readmePath = join(skillCatalogRoot.path, "README.md"); - await mkdir(skillCatalogRoot, { recursive: true }); - await writeFile( - manifestPath, - JSON.stringify(getSkillCatalogManifestEntries(execution), null, 2), - "utf8", + await cleanupAtomicWriteTemporaryFiles( + skillCatalogDirectory, + ["manifest.json", "README.md"], + signal, ); - await writeFile(readmePath, buildSkillCatalogReadme(execution), "utf8"); - - return { - manifestPath, - readmePath, - }; -} - -export function buildRuntimeBootstrapText(execution: DriverExecutionInput): string { - const systemPrompt = execution.systemPrompt.trim(); - const manifestPath = join(getSkillCatalogRoot(execution), "manifest.json"); - const readmePath = join(getSkillCatalogRoot(execution), "README.md"); - const availableSkills = execution.skillCatalog.filter( - (entry) => entry.resolutionMode !== "tombstone", + await writeFileAtomically( + skillCatalogDirectory, + "manifest.json", + JSON.stringify(manifestEntries, null, 2), + 0o644, + signal, + ); + await writeFileAtomically( + skillCatalogDirectory, + "README.md", + buildSkillCatalogReadme(manifestEntries), + 0o644, + signal, ); - const unavailableSkills = execution.skillCatalog.filter( - (entry) => entry.resolutionMode === "tombstone", + await assertDirectoryIdentity( + skillCatalogDirectory, + skillCatalogRoot.path, + "Skill bootstrap catalog root", ); - if (!systemPrompt && execution.skillCatalog.length === 0) { - return ""; - } + return { manifestPath, readmePath }; +} - const sections = [ - "Internal runtime bootstrap for this session.", - "Record these instructions for future turns.", - "Do not treat this as an end-user request.", - "Do not ask follow-up questions, do not call tools, and do not modify files in response to this bootstrap message.", - ]; +function buildRuntimeContextSections(execution: DriverExecutionInput): string[] { + const systemPrompt = execution.systemPrompt.trim(); + const sections: string[] = []; if (systemPrompt) { sections.push(`Agent profile prompt:\n${systemPrompt}`); } if (execution.skillCatalog.length > 0) { + const manifestPath = join(getSkillCatalogRoot(execution), "manifest.json"); + const readmePath = join(getSkillCatalogRoot(execution), "README.md"); + const availableSkills = execution.skillCatalog.filter( + (entry) => entry.resolutionMode !== "tombstone", + ); + const unavailableSkills = execution.skillCatalog.filter( + (entry) => entry.resolutionMode === "tombstone", + ); const skillLines = [ `Skill catalog README: ${readmePath}`, `Skill catalog manifest: ${manifestPath}`, @@ -148,37 +495,78 @@ export function buildRuntimeBootstrapText(execution: DriverExecutionInput): stri sections.push(skillLines.join("\n")); } - sections.push("Reply with exactly READY."); - return sections.join("\n\n"); + return sections; +} + +export function buildRuntimeBootstrapText(execution: DriverExecutionInput): string { + const contextSections = buildRuntimeContextSections(execution); + + if (contextSections.length === 0) { + return ""; + } + + return [ + "Internal runtime bootstrap for this session.", + "Record these instructions for future turns.", + "Do not treat this as an end-user request.", + "Do not ask follow-up questions, do not call tools, and do not modify files in response to this bootstrap message.", + ...contextSections, + "Reply with exactly READY.", + ].join("\n\n"); } export function buildNativeRuntimeSystemPrompt(execution: DriverExecutionInput): string | null { - const bootstrap = buildRuntimeBootstrapText(execution) - .replace("Internal runtime bootstrap for this session.", "Runtime context for this session.") - .replace("Record these instructions for future turns.", "") - .replace("Do not treat this as an end-user request.", "") - .replace( - "Do not ask follow-up questions, do not call tools, and do not modify files in response to this bootstrap message.", - "", - ) - .replace("Reply with exactly READY.", "") - .trim(); + const contextSections = buildRuntimeContextSections(execution); - return bootstrap.length > 0 ? bootstrap : null; + return contextSections.length > 0 + ? ["Runtime context for this session.", ...contextSections].join("\n\n") + : null; } export async function writeNativeRuntimeSystemPrompt( execution: DriverExecutionInput, + materializedSkills: readonly AgentDriverMaterializedSkill[], + signal: AbortSignal, ): Promise { + signal.throwIfAborted(); + getSkillCatalogManifestEntries(execution, materializedSkills); const systemPrompt = buildNativeRuntimeSystemPrompt(execution); + const path = join(execution.session.homePath, "runtime-instructions.md"); if (systemPrompt === null) { + const homeDirectory = await openOptionalAbsoluteRealDirectory( + execution.session.homePath, + "Runtime home", + ); + if (homeDirectory === null) { + return null; + } + await using ownedHomeDirectory = homeDirectory; + await cleanupAtomicWriteTemporaryFiles(ownedHomeDirectory, ["runtime-instructions.md"], signal); + await unlinkManagedFile(ownedHomeDirectory, "runtime-instructions.md", signal); + await ownedHomeDirectory.sync(); + await assertDirectoryIdentity( + ownedHomeDirectory, + resolve(execution.session.homePath), + "Runtime home", + ); return null; } - const path = join(execution.session.homePath, "runtime-instructions.md"); - await mkdir(execution.session.homePath, { recursive: true }); - await writeFile(path, `${systemPrompt}\n`, { encoding: "utf8", mode: 0o600 }); + await using homeDirectory = await ensureAbsoluteRealDirectory( + execution.session.homePath, + "Runtime home", + signal, + ); + await cleanupAtomicWriteTemporaryFiles(homeDirectory, ["runtime-instructions.md"], signal); + await writeFileAtomically( + homeDirectory, + "runtime-instructions.md", + `${systemPrompt}\n`, + 0o600, + signal, + ); + await assertDirectoryIdentity(homeDirectory, resolve(execution.session.homePath), "Runtime home"); return path; } diff --git a/src/runtimes/skill-materialization.ts b/src/runtimes/skill-materialization.ts index 1889421..6a55a2c 100644 --- a/src/runtimes/skill-materialization.ts +++ b/src/runtimes/skill-materialization.ts @@ -1,17 +1,8 @@ -import { createHash } from "node:crypto"; -import { - chmod, - lstat, - mkdir, - readFile, - readdir, - readlink, - rm, - symlink, - unlink, - writeFile, -} from "node:fs/promises"; -import { dirname, join, relative, resolve } from "node:path"; +import { createHash, randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { mkdir, open, opendir, rename, rmdir, unlink } from "node:fs/promises"; +import type { FileHandle } from "node:fs/promises"; +import { basename, dirname, join, resolve } from "node:path"; import type { AgentDriverMaterializedSkill } from "../host-ports"; import type { Logger } from "../observability"; @@ -19,297 +10,1110 @@ import type { DriverResolvedSkill } from "../protocol/boot"; import type { DriverExecutionInput } from "../protocol/execution"; import { extractZipArchive } from "../skill-package"; import type { SkillArchiveExtractOptions, SkillPackageEntry } from "../skill-package"; +import { readBoundedStreamBytes } from "../utils/async"; +import { + assertDirectoryIdentity, + closeFileHandles, + directoryEntryPath, + ensureRealDirectoryAt, + hasErrorCode, + openedDirectoryPath, + openAbsoluteRealDirectory, + openOptionalRealDirectory, + openRealDirectory, + openRelativeRealDirectory, + readPathStats, + readDirectoryEntriesBounded, +} from "./atomic-file"; -export type MaterializedSkill = AgentDriverMaterializedSkill; - +const MAX_SKILL_COMPRESSED_BYTES = 25 * 1024 * 1024; const MAX_SKILL_ENTRY_BYTES = 2 * 1024 * 1024; const MAX_SKILL_UNCOMPRESSED_BYTES = 25 * 1024 * 1024; -const NATIVE_SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const SKILL_DOWNLOAD_TIMEOUT_MS = 30_000; const SKILL_ARCHIVE_EXTRACT_OPTIONS: SkillArchiveExtractOptions = { maxEntryCount: 256, maxFileBytes: MAX_SKILL_ENTRY_BYTES, maxTotalFileBytes: MAX_SKILL_UNCOMPRESSED_BYTES, }; -interface SkillMaterializationMarker { - readonly blobSha256: string; +interface MaterializationRoots { + readonly mosooDirectory: FileHandle; + readonly mosooRoot: string; + readonly mountRoot: string; + readonly transactionDirectory: FileHandle; + readonly transactionRoot: string; +} + +interface ResolvedSkillInput { + readonly mountPath: string; + readonly skill: DriverResolvedSkill; readonly snapshotId: string; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); +interface MaterializationOwner { + currentName: string; + readonly newDirectory: FileHandle | null; + readonly ownerDirectory: FileHandle; } -function parseSkillMaterializationMarker(value: unknown): SkillMaterializationMarker | null { - if (!isRecord(value) || typeof value["blobSha256"] !== "string") { - return null; - } +interface QuarantineCleanupBudget { + readonly deadlineMs: number; + remainingEntries: number; +} - if (typeof value["snapshotId"] !== "string") { - return null; - } +interface QuarantineCleanupFrame { + readonly directory: Awaited>; + readonly handle: FileHandle; + readonly removePath: string; +} - return { - blobSha256: value["blobSha256"], - snapshotId: value["snapshotId"], +const latestGenerationByRoot = new Map(); +const activeStagingPaths = new Set(); + +const SKILL_STAGING_DIRECTORY_PATTERN = + /^stage-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const SKILL_QUARANTINE_DIRECTORY_PATTERN = + /^quarantine-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; +const MAX_MANAGED_DIRECTORY_ENTRIES = 1_024; +const SKILL_QUARANTINE_CLEANUP_BUDGET_MS = 250; +const MAX_SKILL_QUARANTINE_CLEANUP_ENTRIES = 1_024; +const MAX_SKILL_QUARANTINE_CLEANUP_DEPTH = 64; +const SKILL_TRANSACTION_ACTIVE_NAME = "active"; +const SKILL_TRANSACTION_COMMIT_MARKER_NAME = "COMMITTED"; + +function createSerialLock(): (operation: () => Promise) => Promise { + let tail: Promise = Promise.resolve(); + return async (operation: () => Promise): Promise => { + const predecessor = tail; + const release = Promise.withResolvers(); + tail = release.promise; + await predecessor; + try { + return await operation(); + } finally { + release.resolve(); + } }; } -function enforceSkillMountPath(sessionOrganizationPath: string, mountPath: string): void { +// ponytail: startup-only materialization is serialized process-wide until contention is measured. +const withCommitLock = createSerialLock(); +const withCleanupLock = createSerialLock(); + +function enforceSkillMountPath(sessionOrganizationPath: string, mountPath: string): string { const allowedRoot = resolve(sessionOrganizationPath, ".mosoo", "skill"); const resolvedMountPath = resolve(mountPath); - const relativeMountPath = relative(allowedRoot, resolvedMountPath); - if ( - relativeMountPath.length === 0 || - relativeMountPath.startsWith("..") || - relativeMountPath.includes("/") - ) { + if (dirname(resolvedMountPath) !== allowedRoot) { throw new Error(`Resolved skill mount path is outside the allowed root: ${mountPath}.`); } + + return resolvedMountPath; } -async function ensureNativeSkillDirectory(path: string): Promise { - try { - const stats = await lstat(path); +async function ensureMaterializationRoots( + sessionOrganizationPath: string, + create: boolean, + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + const sharedRoot = resolve(sessionOrganizationPath); + const mosooRoot = join(sharedRoot, ".mosoo"); + const mountRoot = join(mosooRoot, "skill"); + const transactionRoot = join(mosooRoot, ".skill-transactions"); + const sharedRootDirectory = await openAbsoluteRealDirectory(sharedRoot, "Session shared root"); + let mosooRootDirectory: FileHandle | null = null; + let transactionDirectory: FileHandle | null = null; + let result: MaterializationRoots | null = null; - if (!stats.isDirectory()) { - throw new Error(`Native skill alias root is not a directory: ${path}.`); + try { + mosooRootDirectory = create + ? await ensureRealDirectoryAt(sharedRootDirectory, ".mosoo", "Session .mosoo root", signal) + : await openOptionalRealDirectory( + directoryEntryPath(sharedRootDirectory, ".mosoo"), + "Session .mosoo root", + ); + if (mosooRootDirectory === null) { + result = null; + } else { + transactionDirectory = await openOptionalRealDirectory( + directoryEntryPath(mosooRootDirectory, ".skill-transactions"), + "Skill transaction root", + ); + if ( + create || + transactionDirectory !== null || + (await readPathStats(directoryEntryPath(mosooRootDirectory, "skill"))) !== null + ) { + transactionDirectory ??= await ensureRealDirectoryAt( + mosooRootDirectory, + ".skill-transactions", + "Skill transaction root", + signal, + ); + result = { + mosooDirectory: mosooRootDirectory, + mosooRoot, + mountRoot, + transactionDirectory, + transactionRoot, + }; + } } } catch (error) { - if (isRecord(error) && error["code"] === "ENOENT") { - await mkdir(path); - return; + const closeFailures = await closeFileHandles([ + transactionDirectory, + mosooRootDirectory, + sharedRootDirectory, + ]); + if (closeFailures.length > 0) { + throw new AggregateError([error, ...closeFailures], "Failed to open skill roots."); } - throw error; } + + const closeFailures = await closeFileHandles([ + result === null ? mosooRootDirectory : null, + sharedRootDirectory, + ]); + if (closeFailures.length > 0) { + const ownedCloseFailures = + result === null + ? [] + : await closeFileHandles([result.transactionDirectory, result.mosooDirectory]); + throw new AggregateError( + [...closeFailures, ...ownedCloseFailures], + "Failed to close skill root ancestors.", + ); + } + return result; +} + +async function assertMaterializationRootIdentities(roots: MaterializationRoots): Promise { + await assertDirectoryIdentity(roots.mosooDirectory, roots.mosooRoot, "Session .mosoo root"); + await assertDirectoryIdentity( + roots.transactionDirectory, + roots.transactionRoot, + "Skill transaction root", + ); +} + +async function assertSkillMountLeaf(mountPath: string): Promise { + const stats = await readPathStats(mountPath); + + if (stats !== null && (stats.isSymbolicLink() || !stats.isDirectory())) { + throw new Error(`Resolved skill mount must be a real directory or absent: ${mountPath}.`); + } +} + +function createQuarantineCleanupBudget(): QuarantineCleanupBudget { + return { + deadlineMs: Date.now() + SKILL_QUARANTINE_CLEANUP_BUDGET_MS, + remainingEntries: MAX_SKILL_QUARANTINE_CLEANUP_ENTRIES, + }; } -function isManagedNativeSkillAliasTarget(sharedRootPath: string, target: string): boolean { +async function openQuarantineCleanupFrame(removePath: string): Promise { + const handle = await openRealDirectory(removePath, "Skill quarantine directory"); + try { - enforceSkillMountPath(sharedRootPath, target); + return { + directory: await opendir(openedDirectoryPath(handle)), + handle, + removePath, + }; + } catch (error) { + const closeFailures = await closeFileHandles([handle]); + if (closeFailures.length > 0) { + throw new AggregateError( + [error, ...closeFailures], + "Failed to open a skill quarantine directory.", + ); + } + throw error; + } +} + +async function closeQuarantineCleanupFrames( + frames: readonly QuarantineCleanupFrame[], +): Promise { + const results = await Promise.allSettled( + frames.flatMap((frame) => [frame.directory.close(), frame.handle.close()]), + ); + return results.flatMap((result) => (result.status === "rejected" ? [result.reason] : [])); +} + +async function cleanupQuarantine( + path: string, + budget: QuarantineCleanupBudget, + transactionDirectory: FileHandle, +): Promise { + const rootStats = await readPathStats(path); + + if (rootStats === null) { return true; - } catch { - return false; } + if (rootStats.isSymbolicLink() || !rootStats.isDirectory()) { + await unlink(path); + return true; + } + + const frames: QuarantineCleanupFrame[] = [await openQuarantineCleanupFrame(path)]; + let complete = true; + let operationError: unknown = null; + + try { + while (frames.length > 0) { + if (budget.remainingEntries <= 0 || Date.now() >= budget.deadlineMs) { + complete = false; + break; + } + + const frame = frames[frames.length - 1]; + if (frame === undefined) { + throw new Error("Skill quarantine traversal lost its active directory."); + } + const entry = await frame.directory.read(); + + if (entry === null) { + frames.pop(); + const closeFailures = await closeQuarantineCleanupFrames([frame]); + if (closeFailures.length > 0) { + throw new AggregateError(closeFailures, "Failed to close skill quarantine handles."); + } + budget.remainingEntries -= 1; + try { + await rmdir(frame.removePath); + } catch (error) { + if (hasErrorCode(error, "ENOENT")) { + continue; + } + if (hasErrorCode(error, "ENOTEMPTY")) { + complete = false; + break; + } + throw error; + } + continue; + } + + budget.remainingEntries -= 1; + const entryPath = directoryEntryPath(frame.handle, entry.name); + const stats = await readPathStats(entryPath); + if (stats === null) { + continue; + } + if (stats.isDirectory() && !stats.isSymbolicLink()) { + if (frames.length >= MAX_SKILL_QUARANTINE_CLEANUP_DEPTH) { + await rename( + entryPath, + directoryEntryPath(transactionDirectory, `quarantine-${randomUUID()}`), + ); + await transactionDirectory.sync(); + complete = false; + continue; + } + frames.push(await openQuarantineCleanupFrame(entryPath)); + } else { + await unlink(entryPath); + } + } + } catch (error) { + operationError = error; + } + + const closeFailures = await closeQuarantineCleanupFrames(frames); + if (operationError !== null) { + if (closeFailures.length > 0) { + throw new AggregateError( + [operationError, ...closeFailures], + "Skill quarantine cleanup and resource cleanup failed.", + ); + } + throw operationError; + } + if (closeFailures.length > 0) { + throw new AggregateError(closeFailures, "Failed to close skill quarantine handles."); + } + return complete; } -export async function exposeNativeSkillAliases( +export async function materializeResolvedSkills( execution: DriverExecutionInput, logger: Logger, - materializedSkills: readonly MaterializedSkill[], -): Promise { - const desired = new Map(); - - for (const skill of materializedSkills) { - if (skill.skillName.length > 64 || !NATIVE_SKILL_NAME_PATTERN.test(skill.skillName)) { - logger.warn("driver.skill.native_alias.skipped", { - reason: "invalid_name", - skillId: skill.skillId, - skillName: skill.skillName, - }); - continue; + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + const inputs = resolveSkillInputs(execution, logger); + const rootKey = resolve(execution.session.sharedRootPath); + const generation = new AbortController(); + const superseded = new Error("Skill materialization was superseded by a newer generation."); + + await withCommitLock(async () => { + signal.throwIfAborted(); + latestGenerationByRoot.get(rootKey)?.abort(superseded); + latestGenerationByRoot.set(rootKey, generation); + }); + + const operationSignal = AbortSignal.any([signal, generation.signal]); + try { + const roots = await ensureMaterializationRoots( + execution.session.sharedRootPath, + inputs.length > 0, + operationSignal, + ); + if (roots === null) { + return []; + } + + await using _mosooDirectory = roots.mosooDirectory; + await using _transactionDirectory = roots.transactionDirectory; + return await materializeSkillGeneration( + logger, + inputs, + roots, + generation.signal, + operationSignal, + ); + } finally { + if (latestGenerationByRoot.get(rootKey) === generation) { + latestGenerationByRoot.delete(rootKey); + } + } +} + +async function materializeSkillGeneration( + logger: Logger, + inputs: readonly ResolvedSkillInput[], + roots: MaterializationRoots, + generationSignal: AbortSignal, + operationSignal: AbortSignal, +): Promise { + let liveCatalogExists = false; + await withCommitLock(async () => { + operationSignal.throwIfAborted(); + await withCleanupLock(async () => recoverSkillTransactions(roots, logger, operationSignal)); + await assertMaterializationRootIdentities(roots); + await assertDesiredSkillMounts(roots, inputs); + liveCatalogExists = + (await readPathStats(directoryEntryPath(roots.mosooDirectory, "skill"))) !== null; + }); + + if (inputs.length === 0 && !liveCatalogExists) { + operationSignal.throwIfAborted(); + return []; + } + + const owner = await createMaterializationOwner(roots, operationSignal); + let result: AgentDriverMaterializedSkill[] | null = null; + let materializationError: unknown = null; + try { + for (const input of inputs) { + await prepareSkill(input, owner, operationSignal); } - if (desired.has(skill.skillName)) { - logger.warn("driver.skill.native_alias.skipped", { - reason: "duplicate_name", + + await withCommitLock(async () => { + operationSignal.throwIfAborted(); + await assertMaterializationRootIdentities(roots); + await assertDesiredSkillMounts(roots, inputs); + await commitSkillTransaction(owner, roots, operationSignal); + }); + generationSignal.throwIfAborted(); + await assertMaterializationRootIdentities(roots); + result = inputs.map(({ mountPath, skill, snapshotId }) => ({ + mountPath, + skillId: skill.skillId, + skillMarkdownPath: join(mountPath, "SKILL.md"), + skillName: skill.skillName, + snapshotId, + })); + } catch (error) { + materializationError = error; + } + + const cleanupFailures = await disposeMaterializationOwner(owner, roots, logger); + if (materializationError !== null) { + if (cleanupFailures.length > 0) { + throw new AggregateError( + [materializationError, ...cleanupFailures], + "Skill materialization cleanup failed.", + ); + } + throw materializationError; + } + if (cleanupFailures.length > 0) { + throw new AggregateError(cleanupFailures, "Skill materialization cleanup failed."); + } + if (result === null) { + throw new Error("Skill generation completed without a result."); + } + return result; +} + +function resolveSkillInputs(execution: DriverExecutionInput, logger: Logger): ResolvedSkillInput[] { + const inputs: ResolvedSkillInput[] = []; + const mounts = new Set(); + const names = new Set(); + + for (const skill of execution.skills) { + const snapshotId = skill.snapshotId; + + if ( + skill.resolutionMode === "tombstone" || + snapshotId === undefined || + snapshotId === null || + snapshotId.length === 0 + ) { + logger.info("driver.skill.skipped", { + reason: skill.warningCode ?? "skill.tombstone", skillId: skill.skillId, skillName: skill.skillName, }); continue; } - enforceSkillMountPath(execution.session.sharedRootPath, skill.mountPath); - if (resolve(skill.skillMarkdownPath) !== resolve(skill.mountPath, "SKILL.md")) { - throw new Error(`Native skill path mismatch for "${skill.skillName}".`); + const mountPath = enforceSkillMountPath(execution.session.sharedRootPath, skill.mountPath); + if (mounts.has(mountPath)) { + throw new Error(`Resolved skills contain a duplicate mount path: ${mountPath}.`); } - desired.set(skill.skillName, skill); + if (names.has(skill.skillName)) { + throw new Error(`Resolved skills contain a duplicate skill name: ${skill.skillName}.`); + } + mounts.add(mountPath); + names.add(skill.skillName); + inputs.push({ mountPath, skill, snapshotId }); } - for (const skill of desired.values()) { - const stats = await lstat(skill.skillMarkdownPath); + validateActiveSkillCatalog(execution, inputs); + return inputs; +} - if (!stats.isFile()) { - throw new Error(`Native skill "${skill.skillName}" does not contain SKILL.md.`); +function validateActiveSkillCatalog( + execution: DriverExecutionInput, + inputs: readonly ResolvedSkillInput[], +): void { + const activeCatalog = new Map(); + const catalogIds = new Set(); + const catalogNames = new Set(); + const catalogMounts = new Set(); + + for (const entry of execution.skillCatalog) { + const mountPath = enforceSkillMountPath(execution.session.sharedRootPath, entry.mountPath); + if (catalogIds.has(entry.skillId)) { + throw new Error(`Skill catalog contains a duplicate skill ID: ${entry.skillId}.`); + } + if (catalogNames.has(entry.skillName)) { + throw new Error(`Skill catalog contains a duplicate skill name: ${entry.skillName}.`); + } + if (catalogMounts.has(mountPath)) { + throw new Error(`Skill catalog contains a duplicate mount path: ${mountPath}.`); + } + + catalogIds.add(entry.skillId); + catalogNames.add(entry.skillName); + catalogMounts.add(mountPath); + if (entry.resolutionMode !== "tombstone") { + activeCatalog.set(entry.skillId, entry); } } - const agentsRoot = join(execution.session.sharedRootPath, ".agents"); - const aliasRoot = join(agentsRoot, "skills"); - await ensureNativeSkillDirectory(agentsRoot); - await ensureNativeSkillDirectory(aliasRoot); + if (activeCatalog.size !== inputs.length) { + throw new Error("Active skill catalog does not match the resolved skill set."); + } - const staleAliases: string[] = []; - const retainedAliases = new Set(); - const entries = (await readdir(aliasRoot, { withFileTypes: true })).toSorted((a, b) => - a.name.localeCompare(b.name), - ); + for (const input of inputs) { + const entry = activeCatalog.get(input.skill.skillId); + if ( + entry === undefined || + entry.skillName !== input.skill.skillName || + resolve(entry.mountPath) !== input.mountPath || + entry.resolutionMode !== input.skill.resolutionMode + ) { + throw new Error( + `Active skill catalog entry does not match resolved skill ${input.skill.skillId}.`, + ); + } + } +} - for (const entry of entries) { - const aliasPath = join(aliasRoot, entry.name); - const skill = desired.get(entry.name); +async function assertDesiredSkillMounts( + roots: MaterializationRoots, + inputs: readonly ResolvedSkillInput[], +): Promise { + const mountPath = directoryEntryPath(roots.mosooDirectory, "skill"); + const mountDirectory = await openOptionalRealDirectory(mountPath, "Skill mount root"); + if (mountDirectory === null) { + return; + } + await using ownedMountDirectory = mountDirectory; + for (const input of inputs) { + await assertSkillMountLeaf(directoryEntryPath(ownedMountDirectory, basename(input.mountPath))); + } + await assertDirectoryIdentity(ownedMountDirectory, roots.mountRoot, "Skill mount root"); +} - if (entry.isSymbolicLink()) { - const target = resolve(aliasRoot, await readlink(aliasPath)); +async function createMaterializationOwner( + roots: MaterializationRoots, + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); + const transactionId = randomUUID(); + const currentName = `stage-${transactionId}`; + const currentPath = directoryEntryPath(roots.transactionDirectory, currentName); + const ownerKey = join(roots.transactionRoot, currentName); + let ownerDirectory: FileHandle | null = null; + let newDirectory: FileHandle | null = null; + activeStagingPaths.add(ownerKey); - if (skill !== undefined && target === resolve(skill.mountPath)) { - retainedAliases.add(entry.name); - continue; - } - if (isManagedNativeSkillAliasTarget(execution.session.sharedRootPath, target)) { - staleAliases.push(aliasPath); - continue; - } + try { + await mkdir(currentPath, { mode: 0o700 }); + ownerDirectory = await openRealDirectory(currentPath, "Skill transaction owner"); + newDirectory = await ensureRealDirectoryAt(ownerDirectory, "new", "New skill owner", signal); + await Promise.all([newDirectory.sync(), ownerDirectory.sync()]); + await roots.transactionDirectory.sync(); + return { + currentName, + newDirectory, + ownerDirectory, + }; + } catch (error) { + activeStagingPaths.delete(ownerKey); + const failures = await closeFileHandles([newDirectory, ownerDirectory]); + try { + await cleanupQuarantine( + currentPath, + createQuarantineCleanupBudget(), + roots.transactionDirectory, + ); + } catch (cleanupError) { + failures.push(cleanupError); } - - if (skill !== undefined) { - throw new Error(`Native skill alias "${entry.name}" collides with an existing path.`); + if (failures.length > 0) { + throw new AggregateError([error, ...failures], "Failed to create a skill transaction owner."); } + throw error; } +} - await Promise.all(staleAliases.map((aliasPath) => unlink(aliasPath))); +async function prepareSkill( + input: ResolvedSkillInput, + owner: MaterializationOwner, + signal: AbortSignal, +): Promise { + if (owner.newDirectory === null) { + throw new Error("New skill transaction root is missing."); + } + const downloaded = await downloadSkillPackage(input.skill, signal); - const aliasPaths: string[] = []; - for (const [skillName, skill] of desired) { - const aliasPath = join(aliasRoot, skillName); - aliasPaths.push(aliasPath); + signal.throwIfAborted(); + const entries = extractZipArchive(downloaded, SKILL_ARCHIVE_EXTRACT_OPTIONS); + if (!entries.some((entry) => entry.entryKind === "file" && entry.path === "SKILL.md")) { + throw new Error(`Skill package for ${input.skill.skillId} does not contain SKILL.md.`); + } - if (!retainedAliases.has(skillName)) { - await symlink(relative(aliasRoot, resolve(skill.mountPath)), aliasPath, "dir"); - } + signal.throwIfAborted(); + const stagingPath = directoryEntryPath(owner.newDirectory, basename(input.mountPath)); + await mkdir(stagingPath, { mode: 0o700 }); + await using stagingDirectory = await openRealDirectory(stagingPath, "Staged skill root"); + for (const entry of entries) { + await materializeSkillEntry(stagingDirectory, entry, signal); } + await syncSkillStageDirectories(stagingDirectory, entries, signal); + await owner.newDirectory.sync(); + await assertDirectoryIdentity(stagingDirectory, stagingPath, "Staged skill root"); - return aliasPaths; + signal.throwIfAborted(); + await using manifest = await open( + directoryEntryPath(stagingDirectory, "SKILL.md"), + constants.O_RDONLY | constants.O_NOFOLLOW, + ); + if (!(await manifest.stat()).isFile()) { + throw new Error(`Skill package for ${input.skill.skillId} has an invalid SKILL.md.`); + } } -export async function materializeResolvedSkills( - execution: DriverExecutionInput, - logger: Logger, -): Promise { - const materializedSkills = await Promise.all( - execution.skills.map((skill) => materializeResolvedSkill(execution, logger, skill)), +async function commitSkillTransaction( + owner: MaterializationOwner, + roots: MaterializationRoots, + signal: AbortSignal, +): Promise { + if (owner.newDirectory === null) { + throw new Error("New skill transaction root is missing."); + } + await Promise.all([owner.newDirectory.sync(), owner.ownerDirectory.sync()]); + await assertDirectoryIdentity( + owner.newDirectory, + directoryEntryPath(owner.ownerDirectory, "new"), + "New skill catalog root", ); + signal.throwIfAborted(); + await assertMaterializationRootIdentities(roots); + await renameMaterializationOwner(owner, roots, SKILL_TRANSACTION_ACTIVE_NAME); - return materializedSkills.filter((skill): skill is MaterializedSkill => skill !== null); -} - -async function readSkillMaterializationMarker( - markerPath: string, -): Promise { + let markerCreated = false; try { - return parseSkillMaterializationMarker(JSON.parse(await readFile(markerPath, "utf8"))); - } catch { - return null; + await moveLiveSkillCatalogToBackup(owner, roots); + signal.throwIfAborted(); + await assertMaterializationRootIdentities(roots); + await assertDirectoryIdentity( + owner.newDirectory, + directoryEntryPath(owner.ownerDirectory, "new"), + "New skill catalog root", + ); + if ((await readPathStats(directoryEntryPath(roots.mosooDirectory, "skill"))) !== null) { + throw new Error("Skill mount root changed during catalog commit."); + } + await using marker = await open( + directoryEntryPath(owner.ownerDirectory, SKILL_TRANSACTION_COMMIT_MARKER_NAME), + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + 0o600, + ); + markerCreated = true; + await marker.sync(); + await owner.ownerDirectory.sync(); + } catch (error) { + try { + if (markerCreated) { + await finishCommittedSkillTransaction(owner, roots); + await retireMaterializationOwner(owner, roots); + } else { + await rollbackSkillTransaction(owner, roots); + } + } catch (recoveryError) { + throw new AggregateError( + [error, recoveryError], + markerCreated + ? "Skill materialization committed but could not finish installing the catalog." + : "Skill materialization failed and could not be fully rolled back.", + ); + } + throw error; } + + await finishCommittedSkillTransaction(owner, roots); + await retireMaterializationOwner(owner, roots); } -async function materializeResolvedSkill( - execution: DriverExecutionInput, - logger: Logger, - skill: DriverResolvedSkill, -): Promise { - const snapshotId = skill.snapshotId; +async function moveLiveSkillCatalogToBackup( + owner: MaterializationOwner, + roots: MaterializationRoots, +): Promise { + const livePath = directoryEntryPath(roots.mosooDirectory, "skill"); + const oldPath = directoryEntryPath(owner.ownerDirectory, "old"); + const liveStats = await readPathStats(livePath); + if (liveStats === null) { + return; + } + if (liveStats.isSymbolicLink() || !liveStats.isDirectory()) { + throw new Error(`Skill mount root must be a real directory: ${roots.mountRoot}.`); + } + if ((await readPathStats(oldPath)) !== null) { + throw new Error("Skill transaction backup already exists."); + } + await rename(livePath, oldPath); + await Promise.all([roots.mosooDirectory.sync(), owner.ownerDirectory.sync()]); + const oldStats = await readPathStats(oldPath); + if (oldStats === null || oldStats.dev !== liveStats.dev || oldStats.ino !== liveStats.ino) { + throw new Error("Skill mount root changed during catalog commit."); + } +} + +async function rollbackSkillTransaction( + owner: MaterializationOwner, + roots: MaterializationRoots, +): Promise { + if (owner.newDirectory === null) { + throw new Error("Interrupted skill transaction lost its new catalog root."); + } + const livePath = directoryEntryPath(roots.mosooDirectory, "skill"); + const oldPath = directoryEntryPath(owner.ownerDirectory, "old"); + const [liveStats, oldStats] = await Promise.all([ + readPathStats(livePath), + readPathStats(oldPath), + ]); + if (liveStats !== null && oldStats !== null) { + throw new Error("Interrupted skill transaction has both live and old catalog roots."); + } if ( - skill.resolutionMode === "tombstone" || - snapshotId === undefined || - snapshotId === null || - snapshotId.length === 0 + (liveStats !== null && (liveStats.isSymbolicLink() || !liveStats.isDirectory())) || + (oldStats !== null && (oldStats.isSymbolicLink() || !oldStats.isDirectory())) ) { - logger.info("driver.skill.skipped", { - reason: skill.warningCode ?? "skill.tombstone", - skillId: skill.skillId, - skillName: skill.skillName, - }); - return null; + throw new Error("Interrupted skill catalog root must be a real directory."); + } + if (oldStats !== null) { + await rename(oldPath, livePath); + await Promise.all([roots.mosooDirectory.sync(), owner.ownerDirectory.sync()]); } + await retireMaterializationOwner(owner, roots); +} - enforceSkillMountPath(execution.session.sharedRootPath, skill.mountPath); - const skillMarkdownPath = join(skill.mountPath, "SKILL.md"); - const markerPath = join(skill.mountPath, ".mosoo-skill-cache.json"); - const marker = await readSkillMaterializationMarker(markerPath); +async function finishCommittedSkillTransaction( + owner: MaterializationOwner, + roots: MaterializationRoots, +): Promise { + const livePath = directoryEntryPath(roots.mosooDirectory, "skill"); + const newPath = directoryEntryPath(owner.ownerDirectory, "new"); + const [liveStats, newStats] = await Promise.all([ + readPathStats(livePath), + readPathStats(newPath), + ]); + if (liveStats !== null && newStats !== null) { + throw new Error("Committed skill transaction has both live and new catalog roots."); + } + if (liveStats === null && newStats === null) { + throw new Error("Committed skill transaction lost its catalog root."); + } + if (liveStats !== null) { + if (liveStats.isSymbolicLink() || !liveStats.isDirectory()) { + throw new Error("Committed skill catalog root must be a real directory."); + } + return; + } + if (newStats!.isSymbolicLink() || !newStats!.isDirectory()) { + throw new Error("New skill catalog root must be a real directory."); + } + await rename(newPath, livePath); + await Promise.all([roots.mosooDirectory.sync(), owner.ownerDirectory.sync()]); + if (owner.newDirectory !== null) { + await assertDirectoryIdentity(owner.newDirectory, roots.mountRoot, "Skill mount root"); + } +} - if ( - marker?.blobSha256 === skill.blobSha256 && - marker.snapshotId === snapshotId && - (await readFile(skillMarkdownPath, "utf8").then( - () => true, - () => false, - )) - ) { - logger.info("driver.skill.materialization.cache_hit", { - skillId: skill.skillId, - skillName: skill.skillName, - snapshotId, - }); +async function renameMaterializationOwner( + owner: MaterializationOwner, + roots: MaterializationRoots, + nextName: string, +): Promise { + const previousName = owner.currentName; + const previousPath = directoryEntryPath(roots.transactionDirectory, previousName); + try { + await assertDirectoryIdentity(owner.ownerDirectory, previousPath, "Skill transaction owner"); + } catch (error) { + await quarantineChangedOwnerEntry(owner, roots, previousName); + throw error; + } + await rename( + directoryEntryPath(roots.transactionDirectory, previousName), + directoryEntryPath(roots.transactionDirectory, nextName), + ); + activeStagingPaths.delete(join(roots.transactionRoot, previousName)); + owner.currentName = nextName; + await roots.transactionDirectory.sync(); + try { + await assertDirectoryIdentity( + owner.ownerDirectory, + directoryEntryPath(roots.transactionDirectory, nextName), + "Skill transaction owner", + ); + } catch (error) { + await quarantineChangedOwnerEntry(owner, roots, nextName); + throw error; + } +} + +async function quarantineChangedOwnerEntry( + owner: MaterializationOwner, + roots: MaterializationRoots, + entryName: string, +): Promise { + const entryPath = directoryEntryPath(roots.transactionDirectory, entryName); + const stats = await readPathStats(entryPath); + activeStagingPaths.delete(join(roots.transactionRoot, entryName)); + if (stats === null) { + return; + } + if (stats.isSymbolicLink() || !stats.isDirectory()) { + await unlink(entryPath); + await roots.transactionDirectory.sync(); + return; + } + + const quarantineName = `quarantine-${randomUUID()}`; + await rename(entryPath, directoryEntryPath(roots.transactionDirectory, quarantineName)); + owner.currentName = quarantineName; + await roots.transactionDirectory.sync(); +} + +async function retireMaterializationOwner( + owner: MaterializationOwner, + roots: MaterializationRoots, +): Promise { + await renameMaterializationOwner(owner, roots, `quarantine-${randomUUID()}`); +} + +async function openExistingMaterializationOwner( + roots: MaterializationRoots, +): Promise { + const currentPath = directoryEntryPath(roots.transactionDirectory, SKILL_TRANSACTION_ACTIVE_NAME); + const ownerDirectory = await openRealDirectory(currentPath, "Active skill transaction owner"); + let newDirectory: FileHandle | null = null; + try { + newDirectory = await openOptionalRealDirectory( + directoryEntryPath(ownerDirectory, "new"), + "Active new skill owner", + ); return { - mountPath: skill.mountPath, - skillId: skill.skillId, - skillMarkdownPath, - skillName: skill.skillName, - snapshotId, + currentName: SKILL_TRANSACTION_ACTIVE_NAME, + newDirectory, + ownerDirectory, }; + } catch (error) { + const closeFailures = await closeFileHandles([newDirectory, ownerDirectory]); + if (closeFailures.length > 0) { + throw new AggregateError( + [error, ...closeFailures], + "Failed to open the active skill transaction.", + ); + } + throw error; } +} - await rm(skill.mountPath, { force: true, recursive: true }); - await mkdir(skill.mountPath, { recursive: true }); - const compressed = await downloadSkillPackage(skill); - const actualSha256 = createHash("sha256").update(compressed).digest("hex"); +async function hasSkillTransactionCommitMarker(owner: MaterializationOwner): Promise { + let marker: FileHandle; + try { + marker = await open( + directoryEntryPath(owner.ownerDirectory, SKILL_TRANSACTION_COMMIT_MARKER_NAME), + constants.O_RDONLY | constants.O_NOFOLLOW, + ); + } catch (error) { + if (hasErrorCode(error, "ENOENT")) { + return false; + } + throw error; + } - if (actualSha256 !== skill.blobSha256) { - throw new Error(`Skill blob checksum mismatch for ${skill.skillId}.`); + await using ownedMarker = marker; + const stats = await ownedMarker.stat(); + if (!stats.isFile() || stats.size !== 0) { + throw new Error("Skill transaction commit marker is invalid."); + } + return true; +} + +async function disposeMaterializationOwner( + owner: MaterializationOwner, + roots: MaterializationRoots, + logger: Logger, +): Promise { + activeStagingPaths.delete(join(roots.transactionRoot, owner.currentName)); + const failures = await closeFileHandles([owner.newDirectory, owner.ownerDirectory]); + if (owner.currentName === SKILL_TRANSACTION_ACTIVE_NAME) { + return failures; } - const entries = extractZipArchive(compressed, SKILL_ARCHIVE_EXTRACT_OPTIONS); - if (!entries.some((entry) => entry.entryKind === "file" && entry.path === "SKILL.md")) { - throw new Error(`Skill package for ${skill.skillId} does not contain SKILL.md.`); + try { + await withCleanupLock(async () => { + if ( + !(await cleanupQuarantine( + directoryEntryPath(roots.transactionDirectory, owner.currentName), + createQuarantineCleanupBudget(), + roots.transactionDirectory, + )) + ) { + logger.info("driver.skill.materialization.transaction_cleanup_deferred", { + transactionPath: owner.currentName, + }); + } + }); + } catch (error) { + logger.warn("driver.skill.materialization.transaction_cleanup_failed", { + error, + transactionPath: owner.currentName, + }); } - await Promise.all( - entries.map(async (entry) => { - await materializeSkillEntry(skill.mountPath, entry); - }), - ); - await writeFile( - markerPath, - JSON.stringify({ - blobSha256: skill.blobSha256, - snapshotId, - }), - "utf8", + return failures; +} + +async function recoverSkillTransactions( + roots: MaterializationRoots, + logger: Logger, + signal: AbortSignal, +): Promise { + const entries = await readDirectoryEntriesBounded( + roots.transactionDirectory, + "Skill transaction root", + MAX_MANAGED_DIRECTORY_ENTRIES, + signal, ); + const cleanupBudget = createQuarantineCleanupBudget(); + const activeEntry = entries.find((entry) => entry.name === SKILL_TRANSACTION_ACTIVE_NAME); + if (activeEntry !== undefined) { + signal.throwIfAborted(); + if (activeEntry.isSymbolicLink() || !activeEntry.isDirectory()) { + throw new Error("Active skill transaction owner must be a real directory."); + } + const owner = await openExistingMaterializationOwner(roots); + let retiredPath: string | null = null; + { + await using _ownerDirectory = owner.ownerDirectory; + await using _newDirectory = owner.newDirectory; + if (await hasSkillTransactionCommitMarker(owner)) { + await finishCommittedSkillTransaction(owner, roots); + await retireMaterializationOwner(owner, roots); + logger.info("driver.skill.materialization.transaction_finished", {}); + } else { + await rollbackSkillTransaction(owner, roots); + logger.info("driver.skill.materialization.transaction_restored", {}); + } + retiredPath = directoryEntryPath(roots.transactionDirectory, owner.currentName); + } + if (retiredPath !== null) { + try { + if (!(await cleanupQuarantine(retiredPath, cleanupBudget, roots.transactionDirectory))) { + logger.info("driver.skill.materialization.transaction_cleanup_deferred", { + transactionPath: basename(retiredPath), + }); + } + } catch (error) { + logger.warn("driver.skill.materialization.transaction_cleanup_failed", { + error, + transactionPath: basename(retiredPath), + }); + } + } + } - return { - mountPath: skill.mountPath, - skillId: skill.skillId, - skillMarkdownPath, - skillName: skill.skillName, - snapshotId, - }; + for (const entry of entries) { + signal.throwIfAborted(); + const isStaging = SKILL_STAGING_DIRECTORY_PATTERN.test(entry.name); + const isQuarantine = SKILL_QUARANTINE_DIRECTORY_PATTERN.test(entry.name); + if ( + (!isStaging && !isQuarantine) || + activeStagingPaths.has(join(roots.transactionRoot, entry.name)) + ) { + continue; + } + if (entry.isSymbolicLink() || !entry.isDirectory()) { + await unlink(directoryEntryPath(roots.transactionDirectory, entry.name)); + continue; + } + + try { + if ( + !(await cleanupQuarantine( + directoryEntryPath(roots.transactionDirectory, entry.name), + cleanupBudget, + roots.transactionDirectory, + )) + ) { + logger.info("driver.skill.materialization.transaction_cleanup_deferred", { + transactionPath: entry.name, + }); + } + } catch (error) { + logger.warn("driver.skill.materialization.transaction_cleanup_failed", { + error, + transactionPath: entry.name, + }); + } + } + await roots.transactionDirectory.sync(); } -async function materializeSkillEntry(mountPath: string, entry: SkillPackageEntry): Promise { - const absolutePath = join(mountPath, entry.path); +async function materializeSkillEntry( + stagingDirectory: FileHandle, + entry: SkillPackageEntry, + signal: AbortSignal, +): Promise { + signal.throwIfAborted(); if (entry.entryKind === "directory") { - await mkdir(absolutePath, { recursive: true }); + await using _directory = await openRelativeRealDirectory( + stagingDirectory, + entry.path, + "Staged skill directory", + true, + signal, + ); return; } - await mkdir(dirname(absolutePath), { recursive: true }); - await writeFile(absolutePath, entry.body); + await using parent = await openRelativeRealDirectory( + stagingDirectory, + dirname(entry.path), + "Staged skill directory", + true, + signal, + ); + signal.throwIfAborted(); + await using file = await open( + directoryEntryPath(parent, basename(entry.path)), + constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, + entry.isExecutable ? 0o755 : 0o644, + ); + await file.writeFile(entry.body, { signal }); + await file.sync(); +} + +async function syncSkillStageDirectories( + stagingDirectory: FileHandle, + entries: readonly SkillPackageEntry[], + signal: AbortSignal, +): Promise { + const directories = new Set(["."]); + + for (const entry of entries) { + let directory = entry.entryKind === "directory" ? entry.path : dirname(entry.path); + while (directory !== ".") { + directories.add(directory); + directory = dirname(directory); + } + } - if (entry.isExecutable) { - await chmod(absolutePath, 0o755); + for (const directory of [...directories].toSorted( + (a, b) => b.split("/").length - a.split("/").length, + )) { + signal.throwIfAborted(); + await using handle = await openRelativeRealDirectory( + stagingDirectory, + directory, + "Staged skill directory", + false, + signal, + ); + await handle.sync(); } } -async function downloadSkillPackage(skill: DriverResolvedSkill): Promise { - const response = await fetch(skill.downloadUrl); +async function downloadSkillPackage( + skill: DriverResolvedSkill, + signal: AbortSignal, +): Promise { + const requestSignal = AbortSignal.any([signal, AbortSignal.timeout(SKILL_DOWNLOAD_TIMEOUT_MS)]); + requestSignal.throwIfAborted(); + const response = await fetch(skill.downloadUrl, { signal: requestSignal }); if (!response.ok) { + void response.body?.cancel().catch(() => {}); throw new Error(`Failed to download skill package for ${skill.skillId}: ${response.status}.`); } + if (response.body === null) { + throw new Error(`Skill package download for ${skill.skillId} has no response body.`); + } - return new Uint8Array(await response.arrayBuffer()); + const contentLength = response.headers.get("content-length"); + if ( + contentLength !== null && + /^\d+$/.test(contentLength) && + Number(contentLength) > MAX_SKILL_COMPRESSED_BYTES + ) { + void response.body.cancel().catch(() => {}); + throw new Error( + `Compressed skill package exceeds the limit (${MAX_SKILL_COMPRESSED_BYTES} bytes).`, + ); + } + + const bytes = await readBoundedStreamBytes( + response.body, + MAX_SKILL_COMPRESSED_BYTES, + new Error(`Compressed skill package exceeds the limit (${MAX_SKILL_COMPRESSED_BYTES} bytes).`), + requestSignal, + ); + + if (createHash("sha256").update(bytes).digest("hex") !== skill.blobSha256) { + throw new Error(`Skill blob checksum mismatch for ${skill.skillId}.`); + } + return bytes; } diff --git a/src/skill-package/archive.ts b/src/skill-package/archive.ts index 3396cfc..3625adb 100644 --- a/src/skill-package/archive.ts +++ b/src/skill-package/archive.ts @@ -1,16 +1,13 @@ -import { Unzip, UnzipInflate, zipSync } from "fflate"; -import type { ZipOptions, Zippable } from "fflate"; +import { Unzip, UnzipInflate } from "fflate"; import { SkillPackageError } from "./errors"; import { admitSkillPackagePath, createSkillPackagePathAdmission } from "./path-admission"; import type { AdmittedSkillPackagePath, SkillPackagePathKind } from "./path-admission"; import { rejectUnsupportedArchivePath } from "./path-admission"; -export type SkillEntryKind = "directory" | "file"; - export interface SkillPackageEntry { body: Uint8Array; - entryKind: SkillEntryKind; + entryKind: SkillPackagePathKind; isExecutable: boolean; path: string; } @@ -28,41 +25,12 @@ interface ZipArchiveMetadata { uncompressedSize: number; } -const DEFAULT_ZIP_OPTIONS: ZipOptions = { - level: 6, -}; -const DEFAULT_ZIP_LEVEL = 6; -const FIXED_ZIP_MTIME = new Date("1980-01-01T00:00:00.000Z"); const UNIX_ZIP_OS = 3; -const EXECUTABLE_FILE_MODE = 0o10_0755; -const REGULAR_FILE_MODE = 0o10_0644; -const DIRECTORY_MODE = 0o04_0755; const BYTE_VALUE_COUNT = 0x01_00; const ZIP_EXTERNAL_ATTRIBUTE_MODE_FACTOR = 0x01_00_00; const ZIP_STORED_COMPRESSION = 0; const ZIP_DEFLATE_COMPRESSION = 8; -export function createZipArchive(entries: SkillPackageEntry[]): Uint8Array { - const archive: Zippable = {}; - const admission = createSkillPackagePathAdmission(); - - for (const entry of entries) { - const admitted = admission.admit(entry.path, entry.entryKind); - rejectUnsupportedArchivePath(admitted); - const archivePath = entry.entryKind === "directory" ? `${admitted.path}/` : admitted.path; - - archive[archivePath] = [entry.body, createZipEntryOptions(entry)]; - } - - try { - return zipSync(archive, DEFAULT_ZIP_OPTIONS); - } catch (error) { - throw new SkillPackageError( - error instanceof Error ? error.message : "Skill zip compression failed.", - ); - } -} - export function extractZipArchive( bytes: Uint8Array, options: SkillArchiveExtractOptions = {}, @@ -214,43 +182,6 @@ export function extractZipArchive( }); } -export function looksLikeZipArchive(bytes: Uint8Array): boolean { - if (bytes.byteLength < 4) { - return false; - } - - if (bytes[0] !== 0x50 || bytes[1] !== 0x4b) { - return false; - } - - return ( - (bytes[2] === 0x03 && bytes[3] === 0x04) || - (bytes[2] === 0x05 && bytes[3] === 0x06) || - (bytes[2] === 0x07 && bytes[3] === 0x08) - ); -} - -function createZipEntryOptions(entry: SkillPackageEntry): ZipOptions { - return { - attrs: getZipEntryFileMode(entry) * ZIP_EXTERNAL_ATTRIBUTE_MODE_FACTOR, - level: entry.entryKind === "directory" ? 0 : DEFAULT_ZIP_LEVEL, - mtime: FIXED_ZIP_MTIME, - os: UNIX_ZIP_OS, - }; -} - -function getZipEntryFileMode(entry: SkillPackageEntry): number { - if (entry.entryKind === "directory") { - return DIRECTORY_MODE; - } - - if (entry.isExecutable) { - return EXECUTABLE_FILE_MODE; - } - - return REGULAR_FILE_MODE; -} - function listZipArchiveEntries( bytes: Uint8Array, options: SkillArchiveExtractOptions, @@ -294,10 +225,9 @@ function listZipArchiveEntries( } const rawPath = decodeZipFileName(bytes.subarray(fileNameStart, fileNameEnd)); - const entryKind = inferZipEntryKind(rawPath); - const admitted = admission.admit(rawPath, entryKind); + const admitted = admission.admit(rawPath); rejectUnsupportedArchivePath(admitted); - const path = admitted.path; + const { entryKind, path } = admitted; if (options.maxEntryCount !== undefined && metadata.length >= options.maxEntryCount) { throw new SkillPackageError( @@ -377,7 +307,7 @@ function findEndOfCentralDirectory(bytes: Uint8Array): number { function readAdmittedZipArchivePath(path: string): AdmittedSkillPackagePath | SkillPackageError { try { - const admitted = admitSkillPackagePath(path, inferZipEntryKind(path)); + const admitted = admitSkillPackagePath(path); rejectUnsupportedArchivePath(admitted); return admitted; } catch (error) { @@ -385,10 +315,6 @@ function readAdmittedZipArchivePath(path: string): AdmittedSkillPackagePath | Sk } } -function inferZipEntryKind(path: string): SkillPackagePathKind { - return path.endsWith("/") || path.endsWith("\\") ? "directory" : "file"; -} - function decodeZipFileName(bytes: Uint8Array): string { try { return new TextDecoder("utf-8", { fatal: true }).decode(bytes); diff --git a/src/skill-package/path-admission.ts b/src/skill-package/path-admission.ts index 75fca02..0c76610 100644 --- a/src/skill-package/path-admission.ts +++ b/src/skill-package/path-admission.ts @@ -8,7 +8,7 @@ export interface AdmittedSkillPackagePath { } export interface SkillPackagePathAdmission { - admit(path: string, entryKind: SkillPackagePathKind): AdmittedSkillPackagePath; + admit(path: string, entryKind?: SkillPackagePathKind): AdmittedSkillPackagePath; } export const SKILL_PACKAGE_MANIFEST_PATH = "SKILL.md"; diff --git a/src/stores/cma-store.ts b/src/stores/cma-store.ts index 3461508..f9b9f70 100644 --- a/src/stores/cma-store.ts +++ b/src/stores/cma-store.ts @@ -1,6 +1,11 @@ -import type { CmaInboundEvent, CmaOutboundEvent, CmaSessionStatus } from "../projections/cma"; +import type { + CmaInboundEvent, + CmaOutboundEvent, + CmaProjectedDriverCommand, + CmaSessionStatus, +} from "../projections/cma"; import type { RuntimeEventEnvelope } from "../runtime-events"; -import type { RuntimeCommand, RuntimeCommandResult } from "../runtime-command"; +import type { RuntimeCommandResult } from "../runtime-command"; export const CMA_MAX_EVENT_BYTES = 1_024 * 1_024; export const CMA_MAX_REPLAY_BYTES = 8 * CMA_MAX_EVENT_BYTES; @@ -98,7 +103,7 @@ export interface CmaSessionRecord { } export interface CmaSessionEventRecord { - readonly command: RuntimeCommand | null; + readonly command: CmaProjectedDriverCommand | null; readonly commandResult: RuntimeCommandResult | null; readonly commandStatus: "accepted" | "completed" | "failed" | null; readonly createdAt: string; @@ -145,7 +150,7 @@ export interface CmaCreateSessionInput { } export interface CmaClaimInboundEventInput { - readonly command: RuntimeCommand; + readonly command: CmaProjectedDriverCommand; readonly event: CmaInboundEvent; readonly sessionId: string; } @@ -187,6 +192,10 @@ export interface CmaStore { driverEvent: RuntimeEventEnvelope, ): Promise; archiveEnvironment(id: string): Promise; + /** + * Claims a command only on first admission. An accepted command is never + * reissued because an expired worker cannot prove that its effect did not happen. + */ claimInboundEvent(input: CmaClaimInboundEventInput): Promise; createAgent(input: CmaCreateAgentInput): Promise; createEnvironment(input: CmaCreateEnvironmentInput): Promise; diff --git a/src/stores/memory/cma-memory-store.ts b/src/stores/memory/cma-memory-store.ts index 4d27201..f9957c5 100644 --- a/src/stores/memory/cma-memory-store.ts +++ b/src/stores/memory/cma-memory-store.ts @@ -246,15 +246,6 @@ class CmaMemoryStore implements CmaStore { if (session.status === "terminated") { throw new CmaSessionTerminatedError(input.sessionId); } - - if (existing.lease.expiresAt <= this.#nowIso()) { - existing.lease = this.#createLease(); - return { - claimed: true, - event: structuredClone(existing.record), - lease: structuredClone(existing.lease), - }; - } } return { @@ -332,16 +323,39 @@ class CmaMemoryStore implements CmaStore { throw new CmaStoreConflictError("event", input.commandId); } - const updated = { + let updated = { ...claim.record, commandResult, commandStatus: input.status, cursor: createDriverId(), updatedAt: this.#nowIso(), } satisfies CmaSessionEventRecord; + + let settlementError: unknown; + + try { + this.#eventBroker.assertFrame(updated); + } catch (error) { + if (input.status === "failed") { + throw error; + } + + settlementError = error; + updated = { + ...updated, + commandResult: null, + commandStatus: "failed", + }; + } + this.#eventBroker.replace(input.sessionId, claim.record.id, updated); claim.record = updated; this.#eventBroker.publish(updated); + + if (settlementError !== undefined) { + throw settlementError; + } + return structuredClone(updated); } diff --git a/src/surfaces/cma-http/contract.ts b/src/surfaces/cma-http/contract.ts index e9591e1..65f877b 100644 --- a/src/surfaces/cma-http/contract.ts +++ b/src/surfaces/cma-http/contract.ts @@ -1,5 +1,5 @@ -import type { CmaInboundEvent } from "../../projections/cma"; -import type { RuntimeCommand, RuntimeCommandResult } from "../../runtime-command"; +import type { CmaInboundEvent, CmaProjectedDriverCommand } from "../../projections/cma"; +import type { RuntimeCommandResult } from "../../runtime-command"; import type { CmaSessionRecord, CmaStore } from "../../stores/cma-store"; type HttpMethod = "DELETE" | "GET" | "POST"; @@ -22,7 +22,7 @@ export interface CmaHttpBetaHeaderRequirement { } export interface CmaHttpDriverCommandDispatchInput { - readonly command: RuntimeCommand; + readonly command: CmaProjectedDriverCommand; readonly event: CmaInboundEvent; readonly session: CmaSessionRecord; readonly signal: AbortSignal; diff --git a/src/surfaces/cma-http/request.ts b/src/surfaces/cma-http/request.ts index 43466f6..1841e9c 100644 --- a/src/surfaces/cma-http/request.ts +++ b/src/surfaces/cma-http/request.ts @@ -14,6 +14,7 @@ import { CMA_ENVIRONMENT_PACKAGE_MANAGERS, createDefaultCmaEnvironmentConfig, } from "../../stores/cma-environment"; +import { readBoundedStreamBytes } from "../../utils/async"; import { CmaHttpCapabilityGapError, CmaHttpRequestError } from "./contract"; const createAgentFields = new Set(["id", "metadata", "name"]); @@ -169,67 +170,35 @@ function readRequiredRecord( export async function readCmaJsonBody(request: Request): Promise { const contentLength = request.headers.get("content-length"); + const limitError = new CmaHttpRequestError( + 413, + "CMA_REQUEST_BODY_TOO_LARGE", + `Request body exceeds ${CMA_MAX_EVENT_BYTES} UTF-8 bytes.`, + ); if (contentLength !== null && Number(contentLength) > CMA_MAX_EVENT_BYTES) { - throw new CmaHttpRequestError( - 413, - "CMA_REQUEST_BODY_TOO_LARGE", - `Request body exceeds ${CMA_MAX_EVENT_BYTES} UTF-8 bytes.`, - ); + throw limitError; } - const reader = request.body?.getReader(); - - if (!reader) { + if (!request.body) { throw new CmaHttpRequestError(400, "CMA_INVALID_JSON", "Request body must be valid JSON."); } - let body = new Uint8Array(0); - let size = 0; - try { - while (true) { - const chunk = await reader.read(); - - if (chunk.done) { - break; - } - - const nextSize = size + chunk.value.byteLength; - - if (nextSize > CMA_MAX_EVENT_BYTES) { - await reader.cancel().catch(() => undefined); - throw new CmaHttpRequestError( - 413, - "CMA_REQUEST_BODY_TOO_LARGE", - `Request body exceeds ${CMA_MAX_EVENT_BYTES} UTF-8 bytes.`, - ); - } - - if (nextSize > body.byteLength) { - const grown = new Uint8Array( - Math.min(CMA_MAX_EVENT_BYTES, Math.max(nextSize, body.byteLength * 2, 1_024)), - ); - grown.set(body); - body = grown; - } - - body.set(chunk.value, size); - size = nextSize; - } + const body = await readBoundedStreamBytes( + request.body, + CMA_MAX_EVENT_BYTES, + limitError, + request.signal, + ); + return JSON.parse(new TextDecoder().decode(body)) as unknown; } catch (error) { + request.signal.throwIfAborted(); + if (error instanceof CmaHttpRequestError) { throw error; } - throw new CmaHttpRequestError(400, "CMA_INVALID_JSON", "Request body must be valid JSON."); - } finally { - reader.releaseLock(); - } - - try { - return JSON.parse(new TextDecoder().decode(body.subarray(0, size))) as unknown; - } catch { throw new CmaHttpRequestError(400, "CMA_INVALID_JSON", "Request body must be valid JSON."); } } diff --git a/src/surfaces/cma-http/session-events.ts b/src/surfaces/cma-http/session-events.ts index fedd88b..16df461 100644 --- a/src/surfaces/cma-http/session-events.ts +++ b/src/surfaces/cma-http/session-events.ts @@ -126,7 +126,6 @@ export async function handlePostSessionEvent( try { signal.throwIfAborted(); commandResult = (await dispatchDriverCommand({ command, event, session, signal })) ?? null; - signal.throwIfAborted(); } catch { if (!keeper.signal.aborted) { await store @@ -164,7 +163,7 @@ export async function handlePostSessionEvent( } } -export function createCmaSseResponse(events: AsyncIterable): Response { +function createCmaSseResponse(events: AsyncIterable): Response { let cleanupPromise: Promise | undefined; let iterator: AsyncIterator | undefined; const cleanup = () => diff --git a/src/surfaces/cma-sdk/client.ts b/src/surfaces/cma-sdk/client.ts index e528302..a8dc7f8 100644 --- a/src/surfaces/cma-sdk/client.ts +++ b/src/surfaces/cma-sdk/client.ts @@ -1,4 +1,5 @@ import type { CmaInboundEvent } from "../../projections/cma"; +import { CMA_MAX_REPLAY_BYTES } from "../../stores/cma-store"; import type { CmaAgentRecord, CmaCreateAgentInput, @@ -8,71 +9,117 @@ import type { CmaSessionEventRecord, CmaSessionRecord, } from "../../stores/cma-store"; -import { CMA_DEFAULT_BETA_HEADER_NAME, CMA_DEFAULT_BETA_HEADER_VALUE } from "../cma-http"; +import { raceWithAbort, readBoundedStreamBytes } from "../../utils/async"; +import { CMA_DEFAULT_BETA_HEADER_NAME, CMA_DEFAULT_BETA_HEADER_VALUE } from "../cma-http/contract"; import { decodeCmaSseBytes } from "./sse-bytes-decoder"; import { CmaSdkError, - type CmaSdkClient, type CmaSdkClientOptions, type CmaSdkFetch, + type CmaSdkRequestOptions, + type CmaSdkStreamOptions, type CmaSessionEventDispatchRecord, } from "./types"; +const DEFAULT_TIMEOUT_MS = 30_000; +const MAX_TIMER_MS = 2_147_483_647; + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function readErrorCode(body: unknown): string { - if (!isRecord(body)) { - return "CMA_SDK_HTTP_ERROR"; - } - - const error = body["error"]; +function readError( + body: unknown, + fallback: string, +): { readonly code: string; readonly message: string } { + const error = isRecord(body) && isRecord(body["error"]) ? body["error"] : {}; + const code = error["code"]; + const message = error["message"]; + return { + code: typeof code === "string" && code.length > 0 ? code : "CMA_SDK_HTTP_ERROR", + message: typeof message === "string" && message.length > 0 ? message : fallback, + }; +} - if (!isRecord(error)) { - return "CMA_SDK_HTTP_ERROR"; +function readData(body: unknown): unknown { + if (!isRecord(body) || !("data" in body)) { + throw new CmaSdkError(500, "CMA_SDK_INVALID_RESPONSE", "CMA response is missing data.", body); } - const code = error["code"]; - return typeof code === "string" && code.length > 0 ? code : "CMA_SDK_HTTP_ERROR"; + return body["data"]; } -function readErrorMessage(body: unknown, fallback: string): string { - if (!isRecord(body)) { - return fallback; +function assertIntegerInRange( + value: number, + field: string, + minimum: number, + maximum: number, +): void { + if (!Number.isSafeInteger(value) || value < minimum || value > maximum) { + throw new RangeError(`${field} must be an integer between ${minimum} and ${maximum}.`); } +} - const error = body["error"]; +function responseTooLarge(maxResponseBytes: number): CmaSdkError { + return new CmaSdkError( + 500, + "CMA_SDK_RESPONSE_TOO_LARGE", + `CMA response exceeds ${maxResponseBytes} bytes.`, + null, + ); +} - if (!isRecord(error)) { - return fallback; +async function readResponseBody( + response: Response, + maxResponseBytes: number, + signal: AbortSignal, +): Promise { + if (response.status === 204) { + return null; } - const message = error["message"]; - return typeof message === "string" && message.length > 0 ? message : fallback; -} + const contentLength = Number(response.headers.get("content-length")); -function readData(body: unknown): unknown { - if (!isRecord(body) || !("data" in body)) { - throw new CmaSdkError(500, "CMA_SDK_INVALID_RESPONSE", "CMA response is missing data.", body); + if (Number.isFinite(contentLength) && contentLength > maxResponseBytes) { + void response.body?.cancel().catch(() => undefined); + throw responseTooLarge(maxResponseBytes); } - return body["data"]; -} + if (!response.body) { + return null; + } -export function createCmaSdkClient(options: CmaSdkClientOptions): CmaSdkClient { - return new CmaSdkClientCore(options); + const body = await readBoundedStreamBytes( + response.body, + maxResponseBytes, + responseTooLarge(maxResponseBytes), + signal, + ); + + try { + return JSON.parse(new TextDecoder().decode(body)) as unknown; + } catch { + return null; + } } -class CmaSdkClientCore implements CmaSdkClient { +export class CmaSdkClient { readonly #baseUrl: URL; readonly #fetch: CmaSdkFetch; readonly #headers: Headers; + readonly #maxResponseBytes: number; + readonly #signal: AbortSignal | undefined; + readonly #timeoutMs: number; constructor(options: CmaSdkClientOptions) { this.#baseUrl = new URL(options.baseUrl); this.#fetch = options.fetch ?? fetch; this.#headers = new Headers(options.headers); + this.#maxResponseBytes = options.maxResponseBytes ?? CMA_MAX_REPLAY_BYTES; + this.#signal = options.signal; + this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; + assertIntegerInRange(this.#maxResponseBytes, "maxResponseBytes", 1, Number.MAX_SAFE_INTEGER); + assertIntegerInRange(this.#timeoutMs, "timeoutMs", 0, MAX_TIMER_MS); if (options.betaHeader !== false) { this.#headers.set( @@ -82,86 +129,147 @@ class CmaSdkClientCore implements CmaSdkClient { } } - async archiveEnvironment(id: string): Promise { + async archiveEnvironment( + id: string, + options?: CmaSdkRequestOptions, + ): Promise { return this.#requestData( `/v1/environments/${encodeURIComponent(id)}/archive`, - { method: "POST" }, + { ...options, method: "POST" }, ); } - async createAgent(input: CmaCreateAgentInput): Promise { + async createAgent( + input: CmaCreateAgentInput, + options?: CmaSdkRequestOptions, + ): Promise { return this.#requestData("/v1/agents", { + ...options, body: JSON.stringify(input), method: "POST", }); } - async createEnvironment(input: CmaCreateEnvironmentInput): Promise { + async createEnvironment( + input: CmaCreateEnvironmentInput, + options?: CmaSdkRequestOptions, + ): Promise { return this.#requestData("/v1/environments", { + ...options, body: JSON.stringify(input), method: "POST", }); } - async createSession(input: CmaCreateSessionInput): Promise { + async createSession( + input: CmaCreateSessionInput, + options?: CmaSdkRequestOptions, + ): Promise { return this.#requestData("/v1/sessions", { + ...options, body: JSON.stringify(input), method: "POST", }); } - async deleteEnvironment(id: string): Promise { - await this.#request(`/v1/environments/${encodeURIComponent(id)}`, { method: "DELETE" }); + async deleteEnvironment(id: string, options?: CmaSdkRequestOptions): Promise { + await this.#requestBody(`/v1/environments/${encodeURIComponent(id)}`, { + ...options, + method: "DELETE", + }); } - async getAgent(id: string): Promise { - return this.#requestData(`/v1/agents/${encodeURIComponent(id)}`); + async getAgent(id: string, options?: CmaSdkRequestOptions): Promise { + return this.#requestData(`/v1/agents/${encodeURIComponent(id)}`, options); } - async getEnvironment(id: string): Promise { - return this.#requestData(`/v1/environments/${encodeURIComponent(id)}`); + async getEnvironment(id: string, options?: CmaSdkRequestOptions): Promise { + return this.#requestData( + `/v1/environments/${encodeURIComponent(id)}`, + options, + ); } - async getSession(id: string): Promise { - return this.#requestData(`/v1/sessions/${encodeURIComponent(id)}`); + async getSession(id: string, options?: CmaSdkRequestOptions): Promise { + return this.#requestData(`/v1/sessions/${encodeURIComponent(id)}`, options); } - async listAgents(): Promise { - return this.#requestData("/v1/agents"); + async listAgents(options?: CmaSdkRequestOptions): Promise { + return this.#requestData("/v1/agents", options); } - async listEnvironments(): Promise { - return this.#requestData("/v1/environments"); + async listEnvironments(options?: CmaSdkRequestOptions): Promise { + return this.#requestData("/v1/environments", options); } - async listSessionEvents(sessionId: string): Promise { + async listSessionEvents( + sessionId: string, + options?: CmaSdkRequestOptions, + ): Promise { return this.#requestData( `/v1/sessions/${encodeURIComponent(sessionId)}/events`, + options, ); } async sendSessionEvent( sessionId: string, event: CmaInboundEvent, + options?: CmaSdkRequestOptions, ): Promise { return this.#requestData( `/v1/sessions/${encodeURIComponent(sessionId)}/events`, - { body: JSON.stringify(event), method: "POST" }, + { ...options, body: JSON.stringify(event), method: "POST" }, ); } - async *streamSessionEvents( + streamSessionEvents( sessionId: string, - afterCursor?: string, + options: CmaSdkStreamOptions = {}, ): AsyncIterable { - const response = await this.#request(`/v1/sessions/${encodeURIComponent(sessionId)}/events`, { - headers: { - accept: "text/event-stream", - ...(afterCursor === undefined ? {} : { "last-event-id": afterCursor }), + const controller = new AbortController(); + const signal = this.#combineSignals(options.signal, controller.signal); + const iterator = this.#streamSessionEvents(sessionId, options.afterCursor, signal); + const stream: AsyncIterableIterator = { + [Symbol.asyncIterator]() { + return stream; }, - }); + next() { + return iterator.next(); + }, + return() { + controller.abort(); + return iterator.return(undefined); + }, + }; + return stream; + } + + async *#streamSessionEvents( + sessionId: string, + afterCursor: string | undefined, + signal: AbortSignal, + ): AsyncGenerator { + const request = this.#startRequest(signal); + let response: Response; + + try { + response = await this.#request( + `/v1/sessions/${encodeURIComponent(sessionId)}/events`, + { + headers: { + accept: "text/event-stream", + ...(afterCursor === undefined ? {} : { "last-event-id": afterCursor }), + }, + }, + request.signal, + ); + } finally { + request.stop(); + } if (!response.body) { + signal.throwIfAborted(); throw new CmaSdkError( 500, "CMA_SDK_STREAM_UNAVAILABLE", @@ -170,34 +278,66 @@ class CmaSdkClientCore implements CmaSdkClient { ); } - yield* decodeCmaSseBytes(response.body); + yield* decodeCmaSseBytes(response.body, signal); } - async #request(path: string, init: RequestInit = {}): Promise { - const response = await this.#fetch(new URL(path, this.#baseUrl), { - ...init, - headers: this.#createHeaders(init.headers), - }); + async #request(path: string, init: RequestInit, signal: AbortSignal): Promise { + signal.throwIfAborted(); + const response = await raceWithAbort( + this.#fetch(new URL(path, this.#baseUrl), { + ...init, + headers: this.#createHeaders(init.headers, init.body !== null && init.body !== undefined), + signal, + }), + signal, + ); if (response.ok) { return response; } - const body = await this.#readResponseBody(response); - throw new CmaSdkError( - response.status, - readErrorCode(body), - readErrorMessage(body, `CMA request failed with status ${response.status}.`), - body, - ); + const body = await readResponseBody(response, this.#maxResponseBytes, signal); + const error = readError(body, `CMA request failed with status ${response.status}.`); + throw new CmaSdkError(response.status, error.code, error.message, body); + } + + async #requestBody(path: string, init: RequestInit = {}): Promise { + const request = this.#startRequest(init.signal ?? undefined); + + try { + const response = await this.#request(path, init, request.signal); + return await readResponseBody(response, this.#maxResponseBytes, request.signal); + } finally { + request.stop(); + } } async #requestData(path: string, init: RequestInit = {}): Promise { - const response = await this.#request(path, init); - return readData(await this.#readResponseBody(response)) as T; + return readData(await this.#requestBody(path, init)) as T; + } + + #combineSignals(...signals: readonly (AbortSignal | undefined)[]): AbortSignal { + const defined = [this.#signal, ...signals].filter( + (signal): signal is AbortSignal => signal !== undefined, + ); + return defined.length === 1 ? defined[0]! : AbortSignal.any(defined); + } + + #startRequest(signal?: AbortSignal): { readonly signal: AbortSignal; stop(): void } { + const deadline = new AbortController(); + const timeoutId = setTimeout(() => { + deadline.abort( + new DOMException(`CMA request timed out after ${this.#timeoutMs}ms.`, "TimeoutError"), + ); + }, this.#timeoutMs); + + return { + signal: this.#combineSignals(signal, deadline.signal), + stop: () => clearTimeout(timeoutId), + }; } - #createHeaders(input: HeadersInit | undefined): Headers { + #createHeaders(input: HeadersInit | undefined, hasBody: boolean): Headers { const headers = new Headers(this.#headers); if (input !== undefined) { @@ -206,22 +346,10 @@ class CmaSdkClientCore implements CmaSdkClient { } } - if (!headers.has("content-type")) { + if (hasBody && !headers.has("content-type")) { headers.set("content-type", "application/json"); } return headers; } - - async #readResponseBody(response: Response): Promise { - if (response.status === 204) { - return null; - } - - try { - return await response.json(); - } catch { - return null; - } - } } diff --git a/src/surfaces/cma-sdk/index.ts b/src/surfaces/cma-sdk/index.ts index 5c5cbb1..64f7663 100644 --- a/src/surfaces/cma-sdk/index.ts +++ b/src/surfaces/cma-sdk/index.ts @@ -1,9 +1,10 @@ -export { createCmaSdkClient } from "./client"; +export { CmaSdkClient } from "./client"; export { CmaSdkError, type CmaSdkBetaHeader, - type CmaSdkClient, type CmaSdkClientOptions, type CmaSdkFetch, + type CmaSdkRequestOptions, + type CmaSdkStreamOptions, type CmaSessionEventDispatchRecord, } from "./types"; diff --git a/src/surfaces/cma-sdk/sse-bytes-decoder.ts b/src/surfaces/cma-sdk/sse-bytes-decoder.ts index bc3aa48..44f977f 100644 --- a/src/surfaces/cma-sdk/sse-bytes-decoder.ts +++ b/src/surfaces/cma-sdk/sse-bytes-decoder.ts @@ -71,13 +71,22 @@ function sseFrameLimitError(): CmaSdkError { export async function* decodeCmaSseBytes( body: ReadableStream, + signal?: AbortSignal, ): AsyncIterable { const reader = body.getReader(); const decoder = new TextDecoder(); const frame = new Uint8Array(CMA_MAX_EVENT_BYTES + 1); + let cancellation: Promise | undefined; let completed = false; let frameLength = 0; let scanFrom = 0; + const cancel = () => + (cancellation ??= reader.cancel(signal?.reason).then( + () => undefined, + () => undefined, + )); + const onAbort = () => void cancel(); + signal?.addEventListener("abort", onAbort, { once: true }); const consume = (separator: { readonly index: number; @@ -129,8 +138,11 @@ export async function* decodeCmaSseBytes( }; try { + signal?.throwIfAborted(); + while (true) { const chunk = await reader.read(); + signal?.throwIfAborted(); if (chunk.done) { completed = true; @@ -138,6 +150,7 @@ export async function* decodeCmaSseBytes( } for (const record of append(chunk.value)) { + signal?.throwIfAborted(); yield record; } } @@ -150,6 +163,7 @@ export async function* decodeCmaSseBytes( const record = consume(separator); if (record) { + signal?.throwIfAborted(); yield record; } } @@ -158,12 +172,17 @@ export async function* decodeCmaSseBytes( const record = parseSseRecord(decoder.decode(frame.subarray(0, frameLength))); if (record) { + signal?.throwIfAborted(); yield record; } } } finally { + signal?.removeEventListener("abort", onAbort); + if (!completed) { - await reader.cancel(); + void cancel(); } + + reader.releaseLock(); } } diff --git a/src/surfaces/cma-sdk/types.ts b/src/surfaces/cma-sdk/types.ts index f47359c..1544b40 100644 --- a/src/surfaces/cma-sdk/types.ts +++ b/src/surfaces/cma-sdk/types.ts @@ -1,14 +1,6 @@ -import type { CmaInboundEvent } from "../../projections/cma"; -import type { RuntimeCommand, RuntimeCommandResult } from "../../runtime-command"; -import type { - CmaAgentRecord, - CmaCreateAgentInput, - CmaCreateEnvironmentInput, - CmaCreateSessionInput, - CmaEnvironmentRecord, - CmaSessionEventRecord, - CmaSessionRecord, -} from "../../stores/cma-store"; +import type { CmaProjectedDriverCommand } from "../../projections/cma"; +import type { RuntimeCommandResult } from "../../runtime-command"; +import type { CmaSessionEventRecord } from "../../stores/cma-store"; export type CmaSdkFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise; @@ -22,37 +14,26 @@ export interface CmaSdkClientOptions { readonly betaHeader?: CmaSdkBetaHeader | false; readonly fetch?: CmaSdkFetch; readonly headers?: HeadersInit; + readonly maxResponseBytes?: number; + readonly signal?: AbortSignal; + readonly timeoutMs?: number; +} + +export interface CmaSdkRequestOptions { + readonly signal?: AbortSignal; +} + +export interface CmaSdkStreamOptions extends CmaSdkRequestOptions { + readonly afterCursor?: string; } export interface CmaSessionEventDispatchRecord { - readonly command: RuntimeCommand; + readonly command: CmaProjectedDriverCommand; readonly event: CmaSessionEventRecord; readonly result: RuntimeCommandResult | null; readonly status: "accepted"; } -export interface CmaSdkClient { - archiveEnvironment(id: string): Promise; - createAgent(input: CmaCreateAgentInput): Promise; - createEnvironment(input: CmaCreateEnvironmentInput): Promise; - createSession(input: CmaCreateSessionInput): Promise; - deleteEnvironment(id: string): Promise; - getAgent(id: string): Promise; - getEnvironment(id: string): Promise; - getSession(id: string): Promise; - listAgents(): Promise; - listEnvironments(): Promise; - listSessionEvents(sessionId: string): Promise; - sendSessionEvent( - sessionId: string, - event: CmaInboundEvent, - ): Promise; - streamSessionEvents( - sessionId: string, - afterCursor?: string, - ): AsyncIterable; -} - export class CmaSdkError extends Error { readonly body: unknown; readonly code: string; diff --git a/src/utils/async.ts b/src/utils/async.ts index 15aca83..854cc4c 100644 --- a/src/utils/async.ts +++ b/src/utils/async.ts @@ -119,6 +119,67 @@ export async function raceWithAbort(promise: Promise, signal?: AbortSignal } } +export async function readBoundedStreamBytes( + body: ReadableStream, + maxBytes: number, + limitError: Error, + signal?: AbortSignal, +): Promise { + const reader = body.getReader(); + let bytes = new Uint8Array(0); + let cancellation: Promise | undefined; + let completed = false; + let size = 0; + const cancel = () => + (cancellation ??= reader.cancel(signal?.reason).then( + () => undefined, + () => undefined, + )); + const onAbort = () => void cancel(); + signal?.addEventListener("abort", onAbort, { once: true }); + + try { + signal?.throwIfAborted(); + + while (true) { + const chunk = await raceWithAbort(reader.read(), signal); + signal?.throwIfAborted(); + + if (chunk.done) { + completed = true; + break; + } + + if (chunk.value.byteLength > maxBytes - size) { + throw limitError; + } + + const nextSize = size + chunk.value.byteLength; + + if (nextSize > bytes.byteLength) { + const grown = new Uint8Array( + Math.min(maxBytes, Math.max(nextSize, bytes.byteLength * 2, 1_024)), + ); + grown.set(bytes); + bytes = grown; + } + + bytes.set(chunk.value, size); + size = nextSize; + } + } finally { + signal?.removeEventListener("abort", onAbort); + + if (!completed) { + void cancel(); + } + + reader.releaseLock(); + } + + return bytes.subarray(0, size); +} + export function settlePromiseWithTimeout( promise: Promise, options: PromiseTimeoutOptions, diff --git a/tests/acp-agent-process.test.ts b/tests/acp-agent-process.test.ts index 1d0efcb..9659b07 100644 --- a/tests/acp-agent-process.test.ts +++ b/tests/acp-agent-process.test.ts @@ -6,7 +6,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { createBufferedSinkLogger } from "../src/observability"; +import { createDisabledLogger } from "../src/observability"; import { createDriverStartInputFromBootPayload } from "../src/protocol/start"; import { startAcpAgentProcess, @@ -19,19 +19,17 @@ import { createAgentDriverContext } from "../src/core/agent-driver-backend"; import { driverBootPayload, driverStartInput } from "./driver-boot-payload-fixture"; function createHarness() { - const logger = createBufferedSinkLogger({ - level: "debug", - service: "acp-agent-process-test", - sink: async () => {}, - }); const context = createAgentDriverContext({ - eventSink: { pushEvents: async () => ({ accepted: [] }) }, - logger, + eventSink: { + currentRunId: () => null, + pushEvents: async () => ({ accepted: [] }), + }, + logger: createDisabledLogger(), payload: driverStartInput, permission: { request: async () => "reject_once" }, }); - return { context, logger }; + return { context }; } function createPayload(root: string) { @@ -63,9 +61,10 @@ async function startTestAgentProcess( const root = await mkdtemp(join(tmpdir(), "driver-acp-stop-")); const payload = createPayload(root); const readyPath = join(root, "ready"); + let startedChild: AcpAgentProcess | undefined; try { - const child = await startAcpAgentProcess( + const started = await startAcpAgentProcess( harness.context, payload, buildChildEnv(payload), @@ -79,6 +78,9 @@ async function startTestAgentProcess( ...(spawnWatchdog === undefined ? {} : { spawnWatchdog }), }, ); + const child = started.process; + startedChild = child; + await started.ready; while (!(await Bun.file(readyPath).exists())) { await Bun.sleep(5); } @@ -92,6 +94,11 @@ async function startTestAgentProcess( }, }; } catch (error) { + if (startedChild !== undefined) { + await stopAcpAgentProcess(harness.context, startedChild, "test.startup.cleanup").catch( + () => {}, + ); + } await rm(root, { force: true, recursive: true }); throw error; } @@ -136,6 +143,46 @@ async function waitForProcessExit(pid: number): Promise { } describe("ACP agent process lifecycle", () => { + test("does not claim supervision when spawn throws synchronously", async () => { + const harness = createHarness(); + const root = await mkdtemp(join(tmpdir(), "driver-acp-sync-spawn-")); + const payload = createPayload(root); + let child: AcpAgentProcess | undefined; + + try { + await expect( + startAcpAgentProcess( + harness.context, + payload, + buildChildEnv(payload), + new AbortController().signal, + { command: "invalid\0command" }, + ), + ).rejects.toThrow("null bytes"); + + const started = await startAcpAgentProcess( + harness.context, + payload, + buildChildEnv(payload), + new AbortController().signal, + { + args: ["-e", "setInterval(() => {}, 1000)"], + command: process.execPath, + }, + ); + child = started.process; + await started.ready; + await expect( + stopAcpAgentProcess(harness.context, child, "test.stop"), + ).resolves.toBeUndefined(); + } finally { + if (child !== undefined && child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + } + await rm(root, { force: true, recursive: true }); + } + }); + test.each(["SIGTERM", "SIGKILL"] as const)("observes exit after %s", async (exitSignal) => { const harness = createHarness(); const process = await startTestAgentProcess( @@ -150,7 +197,6 @@ describe("ACP agent process lifecycle", () => { expect(process.child.signalCode).toBe(exitSignal); } finally { await process.dispose(); - await harness.logger.destroy(); } }); @@ -175,7 +221,6 @@ describe("ACP agent process lifecycle", () => { } finally { cleanup.resolve(); await process.dispose(); - await harness.logger.destroy(); } }); @@ -190,7 +235,7 @@ describe("ACP agent process lifecycle", () => { let child: AcpAgentProcess | undefined; try { - child = await startAcpAgentProcess( + const started = await startAcpAgentProcess( harness.context, payload, buildChildEnv(payload), @@ -204,6 +249,8 @@ describe("ACP agent process lifecycle", () => { spawnWatchdog: fakeWatchdog(cleanup.promise), }, ); + child = started.process; + await started.ready; while (!(await Bun.file(readyPath).exists())) { await Bun.sleep(10); } @@ -226,7 +273,6 @@ describe("ACP agent process lifecycle", () => { child.kill("SIGKILL"); } await rm(root, { force: true, recursive: true }); - await harness.logger.destroy(); } }, ); @@ -266,12 +312,11 @@ describe("ACP agent process lifecycle", () => { } finally { cleanup.resolve(); await agent.dispose(); - await harness.logger.destroy(); } }, ); - test("cleans the marked tree when watchdog creation throws and allows a later start", async () => { + test("returns ownership when watchdog creation throws and allows explicit cleanup", async () => { const harness = createHarness(); const root = await mkdtemp(join(tmpdir(), "driver-acp-watchdog-create-")); const payload = createPayload(root); @@ -289,29 +334,33 @@ setInterval(() => {}, 1_000); let rootPid = 0; let shellPid = 0; let workerPid = 0; + let failedChild: AcpAgentProcess | undefined; let retryChild: AcpAgentProcess | undefined; process.env["MOSOO_ACP_FALLBACK_COMMAND"] = process.execPath; process.env["MOSOO_ACP_FALLBACK_ARGS"] = JSON.stringify(["-e", script]); try { - await expect( - startAcpAgentProcess( - harness.context, - payload, - buildChildEnv(payload), - new AbortController().signal, - { - spawnWatchdog: (pid) => { - rootPid = pid; - const deadline = Date.now() + 3_000; - while (!existsSync(workerPidPath) && Date.now() < deadline) { - Atomics.wait(sleeper, 0, 0, 10); - } - throw new Error("test watchdog creation failed"); - }, + const failedStart = await startAcpAgentProcess( + harness.context, + payload, + buildChildEnv(payload), + new AbortController().signal, + { + spawnWatchdog: (pid) => { + rootPid = pid; + const deadline = Date.now() + 3_000; + while (!existsSync(workerPidPath) && Date.now() < deadline) { + Atomics.wait(sleeper, 0, 0, 10); + } + throw new Error("test watchdog creation failed"); }, - ), - ).rejects.toThrow("test watchdog creation failed"); + }, + ); + failedChild = failedStart.process; + await expect(failedStart.ready).rejects.toThrow("test watchdog creation failed"); + await expect( + stopAcpAgentProcess(harness.context, failedChild, "startup.failed"), + ).resolves.toBeUndefined(); [shellPid, workerPid] = await Promise.all([ waitForPidFile(shellPidPath), waitForPidFile(workerPidPath), @@ -322,16 +371,25 @@ setInterval(() => {}, 1_000); "-e", "setInterval(() => {}, 1_000)", ]); - retryChild = await startAcpAgentProcess( + const retryStart = await startAcpAgentProcess( harness.context, payload, buildChildEnv(payload), new AbortController().signal, ); + retryChild = retryStart.process; + await retryStart.ready; await expect( stopAcpAgentProcess(harness.context, retryChild, "test.retry"), ).resolves.toBeUndefined(); } finally { + if ( + failedChild !== undefined && + failedChild.exitCode === null && + failedChild.signalCode === null + ) { + failedChild.kill("SIGKILL"); + } if ( retryChild !== undefined && retryChild.exitCode === null && @@ -355,11 +413,10 @@ setInterval(() => {}, 1_000); } } await rm(root, { force: true, recursive: true }); - await harness.logger.destroy(); } }, 7_000); - test("waits for and propagates rejected cleanup when startup aborts", async () => { + test("keeps the handle when startup aborts so rejected cleanup can be retried", async () => { const harness = createHarness(); const root = await mkdtemp(join(tmpdir(), "driver-acp-startup-abort-")); const payload = createPayload(root); @@ -378,44 +435,37 @@ setInterval(() => {}, 1_000); let rootPid = 0; let shellPid = 0; let workerPid = 0; - - const starting = startAcpAgentProcess( - harness.context, - payload, - buildChildEnv(payload), - controller.signal, - { - args: ["-e", script], - command: process.execPath, - spawnWatchdog: (pid, marker) => { - rootPid = pid; - const deadline = Date.now() + 3_000; - while (!existsSync(workerPidPath) && Date.now() < deadline) { - Atomics.wait(sleeper, 0, 0, 10); - } - controller.abort(); - const watchdog = spawnLinuxProcessTreeWatchdog(pid, marker); - if (watchdog === null) { - throw new Error("Test process supervision could not start."); - } - return { - cleanup: watchdog.cleanup.then(() => cleanup.promise), - process: watchdog.process, - }; - }, - }, - ); - let settled = false; - void starting.then( - () => { - settled = true; - }, - () => { - settled = true; - }, - ); + let child: AcpAgentProcess | undefined; try { + const started = await startAcpAgentProcess( + harness.context, + payload, + buildChildEnv(payload), + controller.signal, + { + args: ["-e", script], + command: process.execPath, + spawnWatchdog: (pid, marker) => { + rootPid = pid; + const deadline = Date.now() + 3_000; + while (!existsSync(workerPidPath) && Date.now() < deadline) { + Atomics.wait(sleeper, 0, 0, 10); + } + controller.abort(); + const watchdog = spawnLinuxProcessTreeWatchdog(pid, marker); + if (watchdog === null) { + throw new Error("Test process supervision could not start."); + } + return { + cleanup: watchdog.cleanup.then(() => cleanup.promise), + process: watchdog.process, + }; + }, + }, + ); + child = started.process; + await expect(started.ready).rejects.toThrow(); [shellPid, workerPid] = await Promise.all([ waitForPidFile(shellPidPath), waitForPidFile(workerPidPath), @@ -423,28 +473,25 @@ setInterval(() => {}, 1_000); await Promise.all( [rootPid, shellPid, workerPid].filter((pid) => pid > 0).map(waitForProcessExit), ); - expect(settled).toBe(false); cleanup.reject(new Error("test startup cleanup rejected")); - const error = await starting.then( - () => null, - (reason: unknown) => reason, + await expect(stopAcpAgentProcess(harness.context, child, "startup.failed")).rejects.toThrow( + "test startup cleanup rejected", ); - expect(error).toBeInstanceOf(AggregateError); - expect( - (error as AggregateError).errors.some( - (entry) => entry instanceof Error && entry.message === "test startup cleanup rejected", - ), - ).toBe(true); + await expect( + stopAcpAgentProcess(harness.context, child, "startup.failed.retry"), + ).resolves.toBeUndefined(); } finally { cleanup.resolve(); + if (child !== undefined && child.exitCode === null && child.signalCode === null) { + child.kill("SIGKILL"); + } for (const pid of [rootPid, shellPid, workerPid]) { if (pid > 0 && isProcessRunning(pid)) { process.kill(pid, "SIGKILL"); } } await rm(root, { force: true, recursive: true }); - await harness.logger.destroy(); } }, 7_000); @@ -469,13 +516,15 @@ setInterval(() => {}, 1_000); let workerPid = 0; try { - child = await startAcpAgentProcess( + const started = await startAcpAgentProcess( harness.context, payload, buildChildEnv(payload), new AbortController().signal, { args: ["-e", script], command: process.execPath }, ); + child = started.process; + await started.ready; [shellPid, workerPid] = await Promise.all([ waitForPidFile(shellPidPath), waitForPidFile(workerPidPath), @@ -506,7 +555,6 @@ setInterval(() => {}, 1_000); } } await rm(root, { force: true, recursive: true }); - await harness.logger.destroy(); } }, 5_000); }); diff --git a/tests/acp-client-request-handler.test.ts b/tests/acp-client-request-handler.test.ts index 75dafdc..4a5da6c 100644 --- a/tests/acp-client-request-handler.test.ts +++ b/tests/acp-client-request-handler.test.ts @@ -1,29 +1,201 @@ import { describe, expect, test } from "bun:test"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createAgentDriverContext } from "../src/core/agent-driver-backend"; import { DriverPermissionBroker, PermissionEventDeliveryError, - type DriverPermissionRequest, } from "../src/core/driver-permission-broker"; -import type { DriverRuntimeEventPort } from "../src/core/driver-runtime-io"; +import type { DriverPermissionRequest } from "../src/host-ports"; +import { + DriverEventRejectedError, + type DriverRuntimeEventPort, +} from "../src/core/driver-runtime-io"; +import type { DriverEventInput } from "../src/protocol/events"; +import { createDisabledLogger } from "../src/observability"; import { AcpClientRequestHandler } from "../src/runtimes/acp/acp-client-request-handler"; -import { AcpTurnEventState } from "../src/runtimes/acp/acp-event-translator"; +import { AcpAssistantTranscriptState } from "../src/runtimes/acp/acp-assistant-transcript-state"; +import { DriverEventPublisher } from "../src/runtimes/driver-event-publisher"; +import { beginAcpTranscript } from "./acp-test-helpers"; +import { driverStartInput } from "./driver-boot-payload-fixture"; + +const BASE_HANDLER_OPTIONS = { + allowedRoots: [], + cwd: "/workspace", + env: {}, + isCancelling: () => false, + nativeSessionId: () => "native-session-1", + onUpdateFailure: () => {}, +} satisfies Omit[0], "push" | "turnEvents">; describe("ACP client request handler", () => { + test("drains a committed file report and fences writes admitted after stop", async () => { + const root = await mkdtemp(join(tmpdir(), "driver-acp-client-file-drain-")); + const reportEntered = Promise.withResolvers(); + const releaseReport = Promise.withResolvers(); + const handler = new AcpClientRequestHandler({ + ...BASE_HANDLER_OPTIONS, + cwd: root, + push: async () => {}, + turnEvents: new AcpAssistantTranscriptState(), + }); + const context = createAgentDriverContext({ + eventSink: { + currentRunId: () => null, + pushEvents: async () => ({ accepted: [] }), + }, + logger: createDisabledLogger(), + payload: driverStartInput, + permission: { request: async () => "reject_once" }, + ports: { + file: { + reportChanged: async () => { + reportEntered.resolve(); + await releaseReport.promise; + }, + }, + }, + }); + const path = join(root, "committed.txt"); + + try { + await handler.initializePathScope(); + const write = handler.writeTextFile(context, { + content: "committed", + path, + sessionId: "native-session-1", + }); + await reportEntered.promise; + expect(await readFile(path, "utf8")).toBe("committed"); + + handler.beginStop(); + const drain = handler.drainFileWrites(); + let drained = false; + void drain.then(() => { + drained = true; + }); + await Bun.sleep(0); + expect(drained).toBe(false); + await expect( + handler.writeTextFile(context, { + content: "late", + path: join(root, "late.txt"), + sessionId: "native-session-1", + }), + ).rejects.toThrow("stopping"); + + releaseReport.resolve(); + await expect(Promise.all([write, drain])).resolves.toEqual([{}, undefined]); + } finally { + releaseReport.resolve(); + await handler.closePathScope(); + await rm(root, { force: true, recursive: true }); + } + }); + + test("fails the file drain when notification fails after atomic commit", async () => { + const root = await mkdtemp(join(tmpdir(), "driver-acp-client-file-failure-")); + const failure = new Error("committed file report failed"); + const fatalFailures: Error[] = []; + const handler = new AcpClientRequestHandler({ + ...BASE_HANDLER_OPTIONS, + cwd: root, + onUpdateFailure: (error) => fatalFailures.push(error), + push: async () => {}, + turnEvents: new AcpAssistantTranscriptState(), + }); + const context = createAgentDriverContext({ + eventSink: { + currentRunId: () => null, + pushEvents: async () => ({ accepted: [] }), + }, + logger: createDisabledLogger(), + payload: driverStartInput, + permission: { request: async () => "reject_once" }, + ports: { + file: { reportChanged: async () => Promise.reject(failure) }, + }, + }); + const path = join(root, "committed.txt"); + + try { + await handler.initializePathScope(); + await expect( + handler.writeTextFile(context, { + content: "committed", + path, + sessionId: "native-session-1", + }), + ).rejects.toBe(failure); + expect(await readFile(path, "utf8")).toBe("committed"); + await expect(handler.drainFileWrites()).rejects.toBe(failure); + await expect(handler.drainFileWrites()).resolves.toBeUndefined(); + expect(fatalFailures).toEqual([failure]); + } finally { + await handler.closePathScope(); + await rm(root, { force: true, recursive: true }); + } + }); + + test("does not poison turn or stop drains when a write fails before commit", async () => { + const root = await mkdtemp(join(tmpdir(), "driver-acp-client-file-precommit-")); + const fatalFailures: Error[] = []; + const handler = new AcpClientRequestHandler({ + ...BASE_HANDLER_OPTIONS, + cwd: root, + onUpdateFailure: (error) => fatalFailures.push(error), + push: async () => {}, + turnEvents: new AcpAssistantTranscriptState(), + }); + const context = createAgentDriverContext({ + eventSink: { + currentRunId: () => null, + pushEvents: async () => ({ accepted: [] }), + }, + logger: createDisabledLogger(), + payload: driverStartInput, + permission: { request: async () => "reject_once" }, + }); + const cancellation = new AbortController(); + const failure = new Error("pre-commit cancellation"); + + try { + await handler.initializePathScope(); + handler.openFileWriteIngress(); + cancellation.abort(failure); + await expect( + handler.writeTextFile( + context, + { + content: "not committed", + path: join(root, "cancelled.txt"), + sessionId: "native-session-1", + }, + cancellation.signal, + ), + ).rejects.toBe(failure); + handler.closeFileWriteIngress(); + await expect(handler.drainTurnFileWrites()).resolves.toBeUndefined(); + handler.beginStop(); + await expect(handler.drainFileWrites()).resolves.toBeUndefined(); + expect(fatalFailures).toEqual([]); + } finally { + await handler.closePathScope(); + await rm(root, { force: true, recursive: true }); + } + }); + test("rejects permission requests outside an active turn", async () => { let permissionRequests = 0; let pushes = 0; const handler = new AcpClientRequestHandler({ - allowedRoots: [], - cwd: "/workspace", - env: {}, - isCancelling: () => false, - nativeSessionId: () => "native-session-1", - onUpdateFailure: () => {}, + ...BASE_HANDLER_OPTIONS, push: async () => { pushes += 1; }, - turnEvents: new AcpTurnEventState(), + turnEvents: new AcpAssistantTranscriptState(), }); const context = { ports: { @@ -50,20 +222,10 @@ describe("ACP client request handler", () => { }); test("closes permission ingress before a turn terminal and reopens it for the next turn", async () => { - const turnEvents = new AcpTurnEventState(); + const turnEvents = beginAcpTranscript(); let permissionRequests = 0; - turnEvents.begin({ - messageId: "message-1" as never, - runId: "run-1" as never, - sessionId: "native-session-1", - }); const handler = new AcpClientRequestHandler({ - allowedRoots: [], - cwd: "/workspace", - env: {}, - isCancelling: () => false, - nativeSessionId: () => "native-session-1", - onUpdateFailure: () => {}, + ...BASE_HANDLER_OPTIONS, push: async () => {}, turnEvents, }); @@ -96,16 +258,11 @@ describe("ACP client request handler", () => { test("suppresses turn-scoped session updates before a turn is active", async () => { const pushedReasons: string[] = []; const handler = new AcpClientRequestHandler({ - allowedRoots: [], - cwd: "/workspace", - env: {}, - isCancelling: () => false, - nativeSessionId: () => "native-session-1", - onUpdateFailure: () => {}, + ...BASE_HANDLER_OPTIONS, push: async (_context, reason, _events) => { pushedReasons.push(reason); }, - turnEvents: new AcpTurnEventState(), + turnEvents: new AcpAssistantTranscriptState(), }); for (const replaying of [false, true]) { @@ -135,16 +292,11 @@ describe("ACP client request handler", () => { test("discards deferred updates and permits a later gate", async () => { const pushedReasons: string[] = []; const handler = new AcpClientRequestHandler({ - allowedRoots: [], - cwd: "/workspace", - env: {}, - isCancelling: () => false, - nativeSessionId: () => "native-session-1", - onUpdateFailure: () => {}, + ...BASE_HANDLER_OPTIONS, push: async (_context, reason) => { pushedReasons.push(reason); }, - turnEvents: new AcpTurnEventState(), + turnEvents: new AcpAssistantTranscriptState(), }); const notification = { sessionId: "native-session-1", @@ -169,47 +321,40 @@ describe("ACP client request handler", () => { test("passes the runtime environment to terminal child processes", async () => { const handler = new AcpClientRequestHandler({ - allowedRoots: [], + ...BASE_HANDLER_OPTIONS, cwd: process.cwd(), env: { PATH: "/artifact/bin:/runtime/bin" }, - isCancelling: () => false, - nativeSessionId: () => "native-session-1", - onUpdateFailure: () => {}, push: async () => {}, - turnEvents: new AcpTurnEventState(), + turnEvents: new AcpAssistantTranscriptState(), }); const context = {} as never; - const { terminalId } = await handler.createTerminal(context, { - args: ["-e", "process.stdout.write(process.env.PATH ?? '')"], - command: process.execPath, - env: [], - sessionId: "native-session-1", - }); + await handler.initializePathScope(); - await handler.waitForTerminalExit({ sessionId: "native-session-1", terminalId }); + try { + const { terminalId } = await handler.createTerminal(context, { + args: ["-e", "process.stdout.write(process.env.PATH ?? '')"], + command: process.execPath, + env: [], + sessionId: "native-session-1", + }); - expect(handler.terminalOutput({ sessionId: "native-session-1", terminalId }).output).toBe( - "/artifact/bin:/runtime/bin", - ); - await handler.releaseTerminal(context, { sessionId: "native-session-1", terminalId }); + await handler.waitForTerminalExit({ sessionId: "native-session-1", terminalId }); + + expect(handler.terminalOutput({ sessionId: "native-session-1", terminalId }).output).toBe( + "/artifact/bin:/runtime/bin", + ); + await handler.releaseTerminal(context, { sessionId: "native-session-1", terminalId }); + } finally { + await handler.stopTerminals(context); + } }); test("serializes official SDK notifications and drains scoped suppression", async () => { const gate = Promise.withResolvers(); const pushedReasons: string[] = []; - const turnEvents = new AcpTurnEventState(); - turnEvents.begin({ - messageId: "message-1" as never, - runId: "run-1" as never, - sessionId: "native-session-1", - }); + const turnEvents = beginAcpTranscript(); const handler = new AcpClientRequestHandler({ - allowedRoots: [], - cwd: "/workspace", - env: {}, - isCancelling: () => false, - nativeSessionId: () => "native-session-1", - onUpdateFailure: () => {}, + ...BASE_HANDLER_OPTIONS, push: async (_context, reason) => { pushedReasons.push(reason); if (pushedReasons.length === 1) { @@ -259,24 +404,15 @@ describe("ACP client request handler", () => { expect(pushedReasons).toEqual(["driver.acp.session.update"]); }); - test("does not hold session update admission behind delivery acknowledgement", async () => { + test("keeps accepting bounded updates while a prior delivery is pending", async () => { const firstDelivery = Promise.withResolvers(); const firstAdmitted = Promise.withResolvers(); const failures: Error[] = []; const pending: Promise[] = []; - const turnEvents = new AcpTurnEventState(); + const turnEvents = beginAcpTranscript(); let pushes = 0; - turnEvents.begin({ - messageId: "message-1" as never, - runId: "run-1" as never, - sessionId: "native-session-1", - }); const handler = new AcpClientRequestHandler({ - allowedRoots: [], - cwd: "/workspace", - env: {}, - isCancelling: () => false, - nativeSessionId: () => "native-session-1", + ...BASE_HANDLER_OPTIONS, onUpdateFailure: (error) => failures.push(error), push: async () => { pushes += 1; @@ -315,21 +451,134 @@ describe("ACP client request handler", () => { expect(pushes).toBe(1_025); }); + test("admits replay and completed history but rejects genuinely open retained state", async () => { + const createHandler = () => { + const failures: Error[] = []; + const turnEvents = beginAcpTranscript(); + return { + failures, + handler: new AcpClientRequestHandler({ + ...BASE_HANDLER_OPTIONS, + onUpdateFailure: (error) => failures.push(error), + push: async () => {}, + turnEvents, + }), + }; + }; + + const repeated = createHandler(); + const repeatedUpdate = { + sessionId: "native-session-1", + update: { + rawOutput: "x".repeat(4 * 1_024), + sessionUpdate: "tool_call" as const, + status: "in_progress" as const, + title: "tool", + toolCallId: "same-tool", + }, + }; + for (let index = 0; index < 100; index += 1) { + await repeated.handler.enqueueUpdate({} as never, repeatedUpdate); + } + expect(repeated.failures).toEqual([]); + + const completed = createHandler(); + for (let index = 0; index < 600; index += 1) { + await completed.handler.enqueueUpdate({} as never, { + sessionId: "native-session-1", + update: { + sessionUpdate: "tool_call", + status: "completed", + title: "tool", + toolCallId: `tool-${index}`, + }, + }); + } + expect(completed.failures).toEqual([]); + + const open = createHandler(); + for (let index = 0; index < 509; index += 1) { + await open.handler.enqueueUpdate({} as never, { + sessionId: "native-session-1", + update: { + sessionUpdate: "tool_call", + status: "in_progress", + title: "tool", + toolCallId: `open-tool-${index}`, + }, + }); + } + await expect( + open.handler.enqueueUpdate({} as never, { + sessionId: "native-session-1", + update: { + sessionUpdate: "tool_call", + status: "in_progress", + title: "tool", + toolCallId: "open-tool-over-limit", + }, + }), + ).rejects.toThrow("ACP turn state exceeds 510 retained open items"); + expect(open.failures).toHaveLength(1); + + const byteFlood = createHandler(); + const content = "x".repeat(8 * 1_024); + for (let index = 0; index < 47; index += 1) { + await byteFlood.handler.enqueueUpdate({} as never, { + sessionId: "native-session-1", + update: { + content: { text: content, type: "text" }, + messageId: "native-message-1", + sessionUpdate: "agent_message_chunk", + }, + }); + } + await expect( + byteFlood.handler.enqueueUpdate({} as never, { + sessionId: "native-session-1", + update: { + content: { text: content, type: "text" }, + messageId: "native-message-1", + sessionUpdate: "agent_message_chunk", + }, + }), + ).rejects.toThrow("ACP turn state exceeds 393216 retained UTF-8 bytes"); + expect(byteFlood.failures).toHaveLength(1); + }); + + test("routes permission turn-state overflow through provider fatal cleanup", async () => { + const failures: Error[] = []; + const turnEvents = beginAcpTranscript(); + const handler = new AcpClientRequestHandler({ + ...BASE_HANDLER_OPTIONS, + onUpdateFailure: (error) => failures.push(error), + push: async () => {}, + turnEvents, + }); + + await expect( + handler.requestPermission( + { ports: { permission: { request: async () => "reject_once" } } } as never, + "r".repeat(400_000), + { + options: [{ kind: "allow_once", name: "Allow", optionId: "allow" }], + sessionId: "native-session-1", + toolCall: { + status: "in_progress", + title: "Run command", + } as never, + }, + ), + ).rejects.toThrow("ACP turn state exceeds 393216 retained UTF-8 bytes"); + expect(failures).toHaveLength(1); + }); + test("fails every queued update after the first commit failure", async () => { const failures: Error[] = []; let pushes = 0; - const turnEvents = new AcpTurnEventState(); - turnEvents.begin({ - messageId: "message-1" as never, - runId: "run-1" as never, - sessionId: "native-session-1", - }); + const turnEvents = beginAcpTranscript(); const handler = new AcpClientRequestHandler({ - allowedRoots: [], - cwd: "/workspace", - env: {}, - isCancelling: () => false, - nativeSessionId: () => "native-session-1", + ...BASE_HANDLER_OPTIONS, onUpdateFailure: (error) => failures.push(error), push: async () => { pushes += 1; @@ -362,28 +611,270 @@ describe("ACP client request handler", () => { expect(failures).toHaveLength(1); }); + test("does not translate a queued update before the prior checkpoint settles", async () => { + const firstPublishing = Promise.withResolvers(); + const rejectFirst = Promise.withResolvers(); + const attempts: unknown[] = []; + const turnEvents = beginAcpTranscript(); + const createHandler = ( + push: ConstructorParameters[0]["push"], + ) => + new AcpClientRequestHandler({ + ...BASE_HANDLER_OPTIONS, + push, + turnEvents, + }); + const handler = createHandler(async (_context, _reason, events) => { + attempts.push(structuredClone(events)); + firstPublishing.resolve(); + await rejectFirst.promise; + throw new Error("first update rejected"); + }); + const update = (messageId: string, text: string) => ({ + sessionId: "native-session-1", + update: { + content: { text, type: "text" as const }, + messageId, + sessionUpdate: "agent_message_chunk" as const, + }, + }); + const first = handler.enqueueUpdate({} as never, update("native-1", "first")); + const second = handler.enqueueUpdate({} as never, update("native-2", "second")); + void first.catch(() => {}); + void second.catch(() => {}); + + await firstPublishing.promise; + await Promise.resolve(); + expect(attempts).toHaveLength(1); + rejectFirst.resolve(); + await expect(first).rejects.toThrow("first update rejected"); + await expect(second).rejects.toThrow("first update rejected"); + + const replayed: unknown[] = []; + await expect( + createHandler(async (_context, _reason, events) => { + replayed.push(structuredClone(events)); + }).enqueueUpdate({} as never, update("native-2", "second")), + ).resolves.toBeUndefined(); + expect((replayed[0] as Array<{ kind: string }>).map((event) => event.kind)).toEqual([ + "message.started", + "message.delta", + ]); + }); + + test("rolls back a rejected permission tool before translating a queued update", async () => { + const permissionPublishing = Promise.withResolvers(); + const rejectPermission = Promise.withResolvers(); + const updateAttempts: unknown[] = []; + const turnEvents = beginAcpTranscript(); + const handler = new AcpClientRequestHandler({ + ...BASE_HANDLER_OPTIONS, + push: async (_context, reason, events) => { + if (reason === "driver.acp.permission.tool") { + permissionPublishing.resolve(); + await rejectPermission.promise; + throw new Error("permission tool rejected"); + } + + updateAttempts.push(structuredClone(events)); + }, + turnEvents, + }); + const context = { + ports: { + permission: { + request: async () => "allow_once" as const, + }, + }, + } as never; + const permission = handler.requestPermission(context, 1, { + options: [{ kind: "allow_once", name: "Allow", optionId: "allow" }], + sessionId: "native-session-1", + toolCall: { + rawInput: "x".repeat(250_000), + status: "in_progress", + title: "Run command", + toolCallId: "tool-1", + }, + }); + void permission.catch(() => {}); + + await permissionPublishing.promise; + const update = handler.enqueueUpdate(context, { + sessionId: "native-session-1", + update: { + content: { text: "a".repeat(250_000), type: "text" }, + messageId: "native-message-1", + sessionUpdate: "agent_message_chunk", + }, + }); + await Promise.resolve(); + expect(updateAttempts).toHaveLength(0); + + rejectPermission.resolve(); + await expect(permission).rejects.toThrow("permission tool rejected"); + await expect(update).resolves.toBeUndefined(); + expect((updateAttempts[0] as Array<{ kind: string }>).map((event) => event.kind)).toEqual([ + "message.started", + "message.delta", + ]); + }); + + test("commits a tool update after the publisher resumes a retained suffix", async () => { + const attempts: DriverEventInput[][] = []; + let attempt = 0; + let sequence = 0; + const turnEvents = beginAcpTranscript(); + const context = { + logger: { debug: () => {} }, + ports: { + eventSink: { + currentRunId: () => "run-1", + pushEvents: async ({ events }: { events: DriverEventInput[] }) => { + attempts.push(structuredClone(events)); + attempt += 1; + + if (attempt === 1) { + return { + accepted: [ + { eventId: events[0]!.sourceEventId, seq: ++sequence, type: events[0]!.kind }, + ], + }; + } + + if (attempt === 2) { + throw new Error("tool update transport interrupted"); + } + + return { + accepted: events.map((event) => ({ + eventId: event.sourceEventId, + seq: ++sequence, + type: event.kind, + })), + }; + }, + }, + }, + } as never; + const publisher = new DriverEventPublisher("acp-fallback", () => "native-session-1"); + const handler = new AcpClientRequestHandler({ + ...BASE_HANDLER_OPTIONS, + push: (pushContext, reason, events) => publisher.push(pushContext, reason, events), + turnEvents, + }); + + await expect( + handler.enqueueUpdate(context, { + sessionId: "native-session-1", + update: { + kind: "execute", + sessionUpdate: "tool_call", + status: "in_progress", + title: "Run command", + toolCallId: "tool-1", + }, + }), + ).resolves.toBeUndefined(); + const replayedSuffix = attempts[2]; + expect(replayedSuffix?.map((event) => event.kind)).toEqual([ + "item.started", + "tool.call.updated", + ]); + + const followupStart = attempts.length; + await expect( + handler.enqueueUpdate(context, { + sessionId: "native-session-1", + update: { + rawOutput: "done", + sessionUpdate: "tool_call_update", + status: "completed", + toolCallId: "tool-1", + }, + }), + ).resolves.toBeUndefined(); + expect( + attempts.slice(followupStart).flatMap((batch) => batch.map((event) => event.kind)), + ).toEqual(["tool.call.updated", "item.completed"]); + }); + + test("rolls back an unchanged tool update after an explicit sink rejection", async () => { + const attempts: DriverEventInput[][] = []; + let reject = true; + let sequence = 0; + const turnEvents = beginAcpTranscript(); + const context = { + logger: { debug: () => {} }, + ports: { + eventSink: { + currentRunId: () => "run-1", + pushEvents: async ({ events }: { events: DriverEventInput[] }) => { + attempts.push(structuredClone(events)); + if (reject) { + throw new DriverEventRejectedError( + events[0]!.sourceEventId!, + new Error("tool update rejected"), + ); + } + + return { + accepted: events.map((event) => ({ + eventId: event.sourceEventId, + seq: ++sequence, + type: event.kind, + })), + }; + }, + }, + }, + } as never; + const publisher = new DriverEventPublisher("acp-fallback", () => "native-session-1"); + const createHandler = () => + new AcpClientRequestHandler({ + ...BASE_HANDLER_OPTIONS, + push: (pushContext, reason, events) => publisher.push(pushContext, reason, events), + turnEvents, + }); + const notification = { + sessionId: "native-session-1", + update: { + kind: "execute" as const, + sessionUpdate: "tool_call" as const, + status: "in_progress" as const, + title: "Run command", + toolCallId: "tool-1", + }, + }; + + await expect(createHandler().enqueueUpdate(context, notification)).rejects.toThrow( + "tool update rejected", + ); + const firstToolEventId = attempts[0]!.at(-1)!.sourceEventId; + reject = false; + await expect(createHandler().enqueueUpdate(context, notification)).resolves.toBeUndefined(); + + expect(attempts.at(-1)!.map((event) => event.kind)).toEqual([ + "message.started", + "item.started", + "tool.call.updated", + ]); + expect(attempts.at(-1)!.at(-1)!.sourceEventId).toBe(firstToolEventId); + }); + test.each(["update drain", "tool event"] as const)( "cancels a permission request while waiting on the %s boundary", async (boundary) => { const blocked = Promise.withResolvers(); const release = Promise.withResolvers(); - const turnEvents = new AcpTurnEventState(); + const turnEvents = beginAcpTranscript(); let cancelling = false; let permissionRequests = 0; - turnEvents.begin({ - messageId: "message-1" as never, - runId: "run-1" as never, - sessionId: "native-session-1", - }); const blockedReason = boundary === "update drain" ? "driver.acp.session.update" : "driver.acp.permission.tool"; const handler = new AcpClientRequestHandler({ - allowedRoots: [], - cwd: "/workspace", - env: {}, + ...BASE_HANDLER_OPTIONS, isCancelling: () => cancelling, - nativeSessionId: () => "native-session-1", - onUpdateFailure: () => {}, push: async (_context, reason) => { if (reason === blockedReason) { blocked.resolve(); @@ -432,20 +923,10 @@ describe("ACP client request handler", () => { test("rejects a permission when its turn ends during the update drain", async () => { const updatePublishing = Promise.withResolvers(); const releaseUpdate = Promise.withResolvers(); - const turnEvents = new AcpTurnEventState(); + const turnEvents = beginAcpTranscript(); let permissionRequests = 0; - turnEvents.begin({ - messageId: "message-1" as never, - runId: "run-1" as never, - sessionId: "native-session-1", - }); const handler = new AcpClientRequestHandler({ - allowedRoots: [], - cwd: "/workspace", - env: {}, - isCancelling: () => false, - nativeSessionId: () => "native-session-1", - onUpdateFailure: () => {}, + ...BASE_HANDLER_OPTIONS, push: async (_context, reason) => { if (reason === "driver.acp.session.update") { updatePublishing.resolve(); @@ -490,20 +971,10 @@ describe("ACP client request handler", () => { test("leaves permission lifecycle events to the host port and keeps typed RPC IDs distinct", async () => { const eventKinds: string[] = []; - const requestIds: string[] = []; - const turnEvents = new AcpTurnEventState(); - turnEvents.begin({ - messageId: "message-1" as never, - runId: "run-1" as never, - sessionId: "native-session-1", - }); + const requests: DriverPermissionRequest[] = []; + const turnEvents = beginAcpTranscript(); const handler = new AcpClientRequestHandler({ - allowedRoots: [], - cwd: "/workspace", - env: {}, - isCancelling: () => false, - nativeSessionId: () => "native-session-1", - onUpdateFailure: () => {}, + ...BASE_HANDLER_OPTIONS, push: async (_context, _reason, events) => { eventKinds.push(...events.map((event) => event.kind)); }, @@ -512,8 +983,8 @@ describe("ACP client request handler", () => { const context = { ports: { permission: { - request: async ({ requestId }: { requestId: string }) => { - requestIds.push(requestId); + request: async (request: DriverPermissionRequest) => { + requests.push(request); return "allow_once" as const; }, }, @@ -530,7 +1001,14 @@ describe("ACP client request handler", () => { ).resolves.toEqual({ outcome: { optionId: "allow", outcome: "selected" } }); } - expect(requestIds).toEqual(["number:1", "string:1", "null"]); + expect(requests.map(({ requestId }) => requestId)).toEqual(["number:1", "string:1", "null"]); + expect(requests[0]).toEqual({ + rawInput: "", + requestId: "number:1", + title: "Run command", + toolCallId: "tool-0", + toolKind: null, + }); expect(eventKinds).not.toContain("permission.requested"); expect(eventKinds).not.toContain("permission.resolved"); }); @@ -543,19 +1021,9 @@ describe("ACP client request handler", () => { ] as const)( "maps a %s host decision to a %s option without widening scope", async (decision, optionKind, optionId, selectable) => { - const turnEvents = new AcpTurnEventState(); - turnEvents.begin({ - messageId: "message-1" as never, - runId: "run-1" as never, - sessionId: "native-session-1", - }); + const turnEvents = beginAcpTranscript(); const handler = new AcpClientRequestHandler({ - allowedRoots: [], - cwd: "/workspace", - env: {}, - isCancelling: () => false, - nativeSessionId: () => "native-session-1", - onUpdateFailure: () => {}, + ...BASE_HANDLER_OPTIONS, push: async () => {}, turnEvents, }); @@ -580,19 +1048,9 @@ describe("ACP client request handler", () => { test("returns cancelled when the SDK aborts a pending permission request", async () => { const entered = Promise.withResolvers(); const controller = new AbortController(); - const turnEvents = new AcpTurnEventState(); - turnEvents.begin({ - messageId: "message-1" as never, - runId: "run-1" as never, - sessionId: "native-session-1", - }); + const turnEvents = beginAcpTranscript(); const handler = new AcpClientRequestHandler({ - allowedRoots: [], - cwd: "/workspace", - env: {}, - isCancelling: () => false, - nativeSessionId: () => "native-session-1", - onUpdateFailure: () => {}, + ...BASE_HANDLER_OPTIONS, push: async () => {}, turnEvents, }); @@ -628,20 +1086,10 @@ describe("ACP client request handler", () => { const toolPublishing = Promise.withResolvers(); const releaseTool = Promise.withResolvers(); const controller = new AbortController(); - const turnEvents = new AcpTurnEventState(); + const turnEvents = beginAcpTranscript(); let permissionRequests = 0; - turnEvents.begin({ - messageId: "message-1" as never, - runId: "run-1" as never, - sessionId: "native-session-1", - }); const handler = new AcpClientRequestHandler({ - allowedRoots: [], - cwd: "/workspace", - env: {}, - isCancelling: () => false, - nativeSessionId: () => "native-session-1", - onUpdateFailure: () => {}, + ...BASE_HANDLER_OPTIONS, push: async (_context, reason) => { if (reason === "driver.acp.permission.tool") { toolPublishing.resolve(); @@ -694,19 +1142,9 @@ describe("ACP client request handler", () => { "resolved", new Error("permission transport unavailable"), ); - const turnEvents = new AcpTurnEventState(); - turnEvents.begin({ - messageId: "message-1" as never, - runId: "run-1" as never, - sessionId: "native-session-1", - }); + const turnEvents = beginAcpTranscript(); const handler = new AcpClientRequestHandler({ - allowedRoots: [], - cwd: "/workspace", - env: {}, - isCancelling: () => false, - nativeSessionId: () => "native-session-1", - onUpdateFailure: () => {}, + ...BASE_HANDLER_OPTIONS, push: async () => {}, turnEvents, }); @@ -733,7 +1171,7 @@ describe("ACP client request handler", () => { await expect(handler.drainPermissions()).resolves.toBeUndefined(); }); - test("keeps a late cancelled permission resolution on its originating run", async () => { + test("closes a late cancelled permission on its captured run after ownership changes", async () => { const requestedPublishing = Promise.withResolvers(); const releaseRequested = Promise.withResolvers(); let activeRunId = "run-1"; @@ -754,6 +1192,7 @@ describe("ACP client request handler", () => { return { accepted: events.map((event, index) => ({ + eventId: event.sourceEventId!, seq: index + 1, type: event.kind, })), @@ -763,19 +1202,9 @@ describe("ACP client request handler", () => { const broker = new DriverPermissionBroker(() => null, { eventDeliveryTimeoutMs: 1_000, }); - const turnEvents = new AcpTurnEventState(); - turnEvents.begin({ - messageId: "message-1" as never, - runId: activeRunId as never, - sessionId: "native-session-1", - }); + const turnEvents = beginAcpTranscript({ runId: activeRunId as never }); const handler = new AcpClientRequestHandler({ - allowedRoots: [], - cwd: "/workspace", - env: {}, - isCancelling: () => false, - nativeSessionId: () => "native-session-1", - onUpdateFailure: () => {}, + ...BASE_HANDLER_OPTIONS, push: async () => {}, turnEvents, }); @@ -820,19 +1249,9 @@ describe("ACP client request handler", () => { test("closes update ingress and drains accepted work before stopping", async () => { const blocked = Promise.withResolvers(); const release = Promise.withResolvers(); - const turnEvents = new AcpTurnEventState(); - turnEvents.begin({ - messageId: "message-1" as never, - runId: "run-1" as never, - sessionId: "native-session-1", - }); + const turnEvents = beginAcpTranscript(); const handler = new AcpClientRequestHandler({ - allowedRoots: [], - cwd: "/workspace", - env: {}, - isCancelling: () => false, - nativeSessionId: () => "native-session-1", - onUpdateFailure: () => {}, + ...BASE_HANDLER_OPTIONS, push: async (_context, reason) => { if (reason === "driver.acp.session.update") { blocked.resolve(); diff --git a/tests/acp-configuration.test.ts b/tests/acp-configuration.test.ts index dcde739..d264b2f 100644 --- a/tests/acp-configuration.test.ts +++ b/tests/acp-configuration.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { ClientSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"; -import { createBufferedSinkLogger } from "../src/observability"; import { ACP_PROTOCOL_VERSION, appendOpenCodeInstruction, @@ -13,8 +13,7 @@ import { supportsSessionClose, supportsSessionResume, } from "../src/runtimes/acp/acp-configuration"; -import { AcpDriverBackend, limitAcpInput } from "../src/runtimes/acp/acp-driver-backend"; -import { createAgentDriverContext } from "../src/core/agent-driver-backend"; +import { limitAcpInput } from "../src/runtimes/acp/acp-driver-backend"; import { bootPayload } from "./driver-runtime-boundary-fixtures"; function createInitializeResult(protocolVersion: number | string | null) { @@ -81,6 +80,98 @@ describe("ACP runtime configuration", () => { ); }); + test("leaves JSON-RPC parsing and wire validation to the official SDK", async () => { + let sdkOutput = ""; + const inputReady = Promise.withResolvers>(); + const handled = Promise.withResolvers(); + const input = new ReadableStream({ + start(controller) { + inputReady.resolve(controller); + controller.enqueue( + new TextEncoder().encode( + '{\n{}\n{"jsonrpc":"2.0","id":1,"method":"fs/read_text_file","params":{"sessionId":"session-1","path":"/tmp/alive"}}\n', + ), + ); + }, + }); + const transport = ndJsonStream( + new WritableStream({ + write(chunk) { + sdkOutput += new TextDecoder().decode(chunk); + if (sdkOutput.includes('"content":"alive"')) handled.resolve(); + }, + }), + limitAcpInput(input), + ); + const connection = new ClientSideConnection( + () => ({ + readTextFile: async () => ({ content: "alive" }), + requestPermission: async () => ({ outcome: { outcome: "cancelled" } }), + sessionUpdate: async () => {}, + }), + transport, + ); + + await handled.promise; + (await inputReady.promise).close(); + void connection; + expect( + sdkOutput + .trim() + .split("\n") + .map((line) => JSON.parse(line)), + ).toMatchObject([ + { error: { code: -32700 }, id: null, jsonrpc: "2.0" }, + { error: { code: -32600 }, id: null, jsonrpc: "2.0" }, + { id: 1, jsonrpc: "2.0", result: { content: "alive" } }, + ]); + }); + + test("counts UTF-8 bytes across chunk boundaries", async () => { + const bytes = new TextEncoder().encode("😀\n"); + const stream = (limit: number) => + limitAcpInput( + new ReadableStream({ + start(controller) { + controller.enqueue(bytes.subarray(0, 3)); + controller.enqueue(bytes.subarray(3)); + controller.close(); + }, + }), + limit, + ); + + await expect(new Response(stream(3)).text()).rejects.toThrow("message exceeds 3 bytes"); + await expect(new Response(stream(4)).text()).resolves.toBe("😀\n"); + }); + + test("rejects malformed UTF-8 before the ACP decoder", async () => { + const input = new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.of(0xc3, 0x28, 0x0a)); + controller.close(); + }, + }); + + await expect(new Response(limitAcpInput(input)).text()).rejects.toThrow(); + }); + + test("passes one JSON-RPC object across UTF-8 chunks and an unterminated EOF line", async () => { + const message = '{"jsonrpc":"2.0","method":"session/update","params":{"text":"😀"}}'; + const bytes = new TextEncoder().encode(`\n${message}`); + const emoji = new TextEncoder().encode("😀"); + const split = bytes.findIndex((byte) => byte === emoji[0]) + 2; + const input = new ReadableStream({ + start(controller) { + controller.enqueue(bytes.subarray(0, split)); + controller.enqueue(bytes.subarray(split)); + controller.close(); + }, + }); + + expect(await new Response(limitAcpInput(input)).text()).toBe(`\n${message}`); + }); + test("advertises stable boolean configuration support", () => { expect(buildClientCapabilities()).toEqual({ fs: { @@ -115,18 +206,32 @@ describe("ACP runtime configuration", () => { test("fails fast when a configured auth method is not advertised", () => { expect( - resolveAuthMethod([{ id: "browser-login" }], { + resolveAuthMethod([{ id: "browser-login", name: "Browser login" }], { MOSOO_ACP_AUTH_METHOD_ID: "browser-login", }), ).toBe("browser-login"); - expect(resolveAuthMethod([{ id: "browser-login" }], {})).toBeNull(); + expect(resolveAuthMethod([{ id: "browser-login", name: "Browser login" }], {})).toBeNull(); expect(() => - resolveAuthMethod([{ id: "browser-login" }], { + resolveAuthMethod([{ id: "browser-login", name: "Browser login" }], { MOSOO_ACP_AUTH_METHOD_ID: "device-login", }), ).toThrow(); + + expect(() => + resolveAuthMethod( + [ + { + args: ["auth"], + id: "terminal-login", + name: "Terminal login", + type: "terminal", + }, + ], + { MOSOO_ACP_AUTH_METHOD_ID: "terminal-login" }, + ), + ).toThrow("requires unsupported terminal auth"); }); test("inherits only runtime proxy env and prepends artifact paths", () => { @@ -184,32 +289,4 @@ describe("ACP runtime configuration", () => { expect(env["HTTPS_PROXY"]).toBe("http://explicit-proxy:7897"); expect(env["NO_PROXY"]).toBe("metadata.google.internal"); }); - - test("requires host integration snapshot before starting", async () => { - const logger = createBufferedSinkLogger({ - level: "debug", - service: "acp-configuration-test", - sink: async () => {}, - }); - const backend = new AcpDriverBackend(bootPayload); - const context = createAgentDriverContext({ - eventSink: { - commandUpdate: async () => {}, - pushEvents: async () => ({ accepted: [] }), - }, - logger, - payload: bootPayload, - permission: { - request: async () => "reject_once", - }, - }); - - try { - await expect(backend.start(context, new AbortController().signal)).rejects.toThrow( - "ACP fallback requires a host integration snapshot.", - ); - } finally { - await logger.destroy(); - } - }); }); diff --git a/tests/acp-driver-backend.test.ts b/tests/acp-driver-backend.test.ts index 9525a97..68216f3 100644 --- a/tests/acp-driver-backend.test.ts +++ b/tests/acp-driver-backend.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import type { ClientContext } from "@agentclientprotocol/sdk"; import { readFileSync } from "node:fs"; import { mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; @@ -9,25 +9,32 @@ import { DriverPermissionBroker, PermissionEventDeliveryError, } from "../src/core/driver-permission-broker"; +import { DriverRuntimeStateMachine } from "../src/core/driver-runtime-state"; import type { DriverRuntimeEventPort } from "../src/core/driver-runtime-io"; -import { createBufferedSinkLogger } from "../src/observability"; -import type { AgentDriverPermissionPort } from "../src/host-ports"; +import { DriverTerminalStateMachine } from "../src/core/driver-terminal-state"; +import { createDisabledLogger } from "../src/observability"; +import type { AgentDriverFilePort, AgentDriverPermissionPort } from "../src/host-ports"; import type { DriverBootPayload } from "../src/protocol/boot"; -import { createDriverHostIntegrationSnapshotFromBootExecution } from "../src/protocol/host-integration"; import type { DriverEventInput } from "../src/protocol/events"; import type { RunId } from "../src/protocol/id"; import { createDriverStartInputFromBootPayload } from "../src/protocol/start"; import { AcpDriverBackend } from "../src/runtimes/acp/acp-driver-backend"; +import * as acpAgentProcess from "../src/runtimes/acp/acp-agent-process"; import { AcpClientRequestHandler } from "../src/runtimes/acp/acp-client-request-handler"; import { AcpTurnController } from "../src/runtimes/acp/acp-turn-controller"; import { createAgentDriverContext } from "../src/core/agent-driver-backend"; import { settlePromiseWithTimeout } from "../src/utils/async"; +import { waitForAcpTestCondition } from "./acp-test-helpers"; import { driverBootPayload, DRIVER_TEST_IDS } from "./driver-boot-payload-fixture"; +import { createDispatcher, FakeDriverRuntimeIo } from "./driver-runtime-boundary-fixtures"; const FAKE_AGENT = String.raw` const { appendFileSync, existsSync } = require("node:fs"); const { spawn } = require("node:child_process"); const logPath = process.env.TEST_LOG_PATH; +const agentPidPath = process.env.TEST_AGENT_PID_PATH; +const backpressurePath = process.env.TEST_BACKPRESSURE_PATH; +const closeWritePath = process.env.TEST_CLOSE_WRITE_PATH; const latePidPath = process.env.TEST_LATE_PID_PATH; const openCodeConfigPath = process.env.TEST_OPENCODE_CONFIG_PATH; const responsePath = process.env.TEST_RESPONSE_PATH; @@ -36,12 +43,35 @@ const triggerPath = process.env.TEST_TRIGGER_PATH; if (openCodeConfigPath) { appendFileSync(openCodeConfigPath, process.env.OPENCODE_CONFIG_CONTENT || ""); } +if (agentPidPath) appendFileSync(agentPidPath, process.pid + "\n"); let buffer = ""; +let floodTerminalId = null; let sessionReady = false; let updateSent = false; let pendingPromptId = null; +let pendingGenerationProbePromptId = null; let pendingProviderCancelPromptId = null; let pendingEndTurnPromptId = null; +let resumeMetadataSent = false; +let waitingForFloodBackpressure = false; +let pendingCloseId = null; +const requestFloodOutput = (id) => + requestClient({ + id, + jsonrpc: "2.0", + method: "terminal/output", + params: { sessionId: "native-session-1", terminalId: floodTerminalId }, + }); +const sendFloodWaits = () => { + for (let index = 0; index < 9; index += 1) { + requestClient({ + id: "flood-wait-" + index, + jsonrpc: "2.0", + method: "terminal/wait_for_exit", + params: { sessionId: "native-session-1", terminalId: floodTerminalId }, + }); + } +}; const send = (message) => process.stdout.write(JSON.stringify(message) + "\n"); const requestClient = (message) => { appendFileSync(logPath, message.method + "\n"); @@ -67,6 +97,48 @@ const handle = (message) => { } } else if (message.id === "nested-wait") { appendFileSync(responsePath, JSON.stringify(message) + "\n"); + } else if (message.id === "flood-create") { + if (!message.result?.terminalId) { + throw new Error("Flood terminal creation failed."); + } + floodTerminalId = message.result.terminalId; + requestFloodOutput("flood-output-ready"); + } else if (message.id === "flood-output-ready") { + if (message.result?.output?.length < 1024 * 1024) { + setImmediate(() => requestFloodOutput("flood-output-ready")); + } else { + waitingForFloodBackpressure = true; + requestFloodOutput("flood-output-blocked"); + } + } else if (String(message.id).startsWith("flood-wait-")) { + appendFileSync(responsePath, JSON.stringify(message) + "\n"); + } else if (message.id === "generation-probe-read") { + if (message.result?.content !== "generation-0") { + throw new Error("Generation probe read failed."); + } + appendFileSync(responsePath, JSON.stringify(message) + "\n"); + send({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "native-session-1", + update: { + content: { text: "reconnected", type: "text" }, + messageId: "generation-probe-assistant", + sessionUpdate: "agent_message_chunk", + }, + }, + }); + send({ + id: pendingGenerationProbePromptId, + jsonrpc: "2.0", + result: { stopReason: "end_turn" }, + }); + pendingGenerationProbePromptId = null; + } else if (message.id === "close-write") { + appendFileSync(responsePath, JSON.stringify(message) + "\n"); + send({ id: pendingCloseId, jsonrpc: "2.0", result: {} }); + pendingCloseId = null; } return; } @@ -139,10 +211,59 @@ const handle = (message) => { }, 5); return; } + if (process.env.TEST_METADATA_ON_RESUME === "1" && !resumeMetadataSent) { + resumeMetadataSent = true; + for (const update of [ + { + availableCommands: [{ description: "Resume command", name: "resume" }], + sessionUpdate: "available_commands_update", + }, + { + configOptions: [{ currentValue: true, id: "resume-config", type: "boolean" }], + sessionUpdate: "config_option_update", + }, + { + availableModes: [{ id: "resume-mode", name: "Resume mode" }], + currentModeId: "resume-mode", + sessionUpdate: "current_mode_update", + }, + { sessionUpdate: "session_info_update", title: "Resumed session" }, + ]) { + send({ + jsonrpc: "2.0", + method: "session/update", + params: { sessionId: "native-session-1", update }, + }); + } + } sessionReady = true; result = {}; break; case "session/prompt": + if (message.params.prompt[0]?.text === "eof-before-response") { + process.exit(0); + return; + } + if (message.params.prompt[0]?.text === "response-then-eof") { + const update = { + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "native-session-1", + update: { + content: { text: "done", type: "text" }, + messageId: "assistant-1", + sessionUpdate: "agent_message_chunk", + }, + }, + }; + const response = { id: message.id, jsonrpc: "2.0", result: { stopReason: "end_turn" } }; + process.stdout.write( + JSON.stringify(update) + "\n" + JSON.stringify(response) + "\n", + () => process.exit(0), + ); + return; + } if (message.params.prompt[0]?.text === "crash") { process.exit(17); } @@ -191,6 +312,10 @@ const handle = (message) => { } return; } + if (message.params.prompt[0]?.text === "provider-cancel") { + result = { stopReason: "cancelled" }; + break; + } if (message.params.prompt[0]?.text === "hang") { pendingPromptId = message.id; send({ @@ -223,6 +348,156 @@ const handle = (message) => { }); return; } + if (message.params.prompt[0]?.text === "request-flood") { + requestClient({ + id: "flood-create", + jsonrpc: "2.0", + method: "terminal/create", + params: { + args: [ + "-e", + "process.stdout.write('x'.repeat(1024 * 1024)); setInterval(() => {}, 1000)", + ], + command: process.execPath, + outputByteLimit: 1024 * 1024, + sessionId: "native-session-1", + }, + }); + return; + } + if (message.params.prompt[0]?.text === "generation-leak") { + pendingPromptId = message.id; + for (let index = 0; index < 8; index += 1) { + requestClient({ + id: "generation-write-" + index, + jsonrpc: "2.0", + method: "fs/write_text_file", + params: { + content: "generation-" + index, + path: process.cwd() + "/generation-" + index + ".txt", + sessionId: "native-session-1", + }, + }); + } + return; + } + if (message.params.prompt[0]?.text === "generation-probe") { + pendingGenerationProbePromptId = message.id; + requestClient({ + id: "generation-probe-read", + jsonrpc: "2.0", + method: "fs/read_text_file", + params: { + path: process.cwd() + "/generation-0.txt", + sessionId: "native-session-1", + }, + }); + return; + } + if (message.params.prompt[0]?.text === "ignore-file-report") { + send({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "native-session-1", + update: { + content: { text: "written", type: "text" }, + messageId: "ignore-file-report-assistant", + sessionUpdate: "agent_message_chunk", + }, + }, + }); + requestClient({ + id: "ignored-write", + jsonrpc: "2.0", + method: "fs/write_text_file", + params: { + content: "committed", + path: process.cwd() + "/ignored-write.txt", + sessionId: "native-session-1", + }, + }); + const promptId = message.id; + setTimeout( + () => send({ id: promptId, jsonrpc: "2.0", result: { stopReason: "end_turn" } }), + 50, + ); + return; + } + if (message.params.prompt[0]?.text === "many-tools") { + for (let index = 0; index < 32; index += 1) { + send({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "native-session-1", + update: { + kind: "execute", + sessionUpdate: "tool_call", + status: "in_progress", + title: "Tool " + index, + toolCallId: "tool-" + index, + }, + }, + }); + } + result = { stopReason: "end_turn" }; + break; + } + if (message.params.prompt[0]?.text === "invalid-stop-reason") { + for (let index = 0; index < 509; index += 1) { + send({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "native-session-1", + update: { + kind: "execute", + sessionUpdate: "tool_call", + status: "in_progress", + title: "Tool " + index, + toolCallId: "tool-" + index, + }, + }, + }); + } + result = { stopReason: "s".repeat(1_000) }; + break; + } + if (message.params.prompt[0]?.text === "sticky-update-failure") { + send({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "native-session-1", + update: { + kind: "execute", + sessionUpdate: "tool_call", + status: "in_progress", + title: "Fail delivery", + toolCallId: "tool-sticky", + }, + }, + }); + result = { stopReason: "end_turn" }; + break; + } + if (message.params.prompt[0]?.text === "thought-only") { + send({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "native-session-1", + update: { + content: { text: "final from thought", type: "text" }, + messageId: "assistant-thought", + sessionUpdate: "agent_thought_chunk", + }, + }, + }); + result = { stopReason: "end_turn" }; + break; + } const chunks = message.params.prompt[0]?.text === "burst" ? ["one", "two", "three"] : ["done"]; for (const text of chunks) { @@ -243,6 +518,20 @@ const handle = (message) => { break; case "session/close": if (process.env.TEST_HANG_CLOSE === "1") return; + if (closeWritePath) { + pendingCloseId = message.id; + requestClient({ + id: "close-write", + jsonrpc: "2.0", + method: "fs/write_text_file", + params: { + content: "must not be written", + path: closeWritePath, + sessionId: "native-session-1", + }, + }); + return; + } send({ jsonrpc: "2.0", method: "session/update", @@ -261,6 +550,18 @@ const handle = (message) => { process.stdin.setEncoding("utf8"); process.stdin.on("data", (chunk) => { buffer += chunk; + if ( + waitingForFloodBackpressure && + buffer.includes('"id":"flood-output-blocked"') + ) { + if (buffer.includes("\n")) { + throw new Error("Flood response completed before output backpressure was established."); + } + waitingForFloodBackpressure = false; + process.stdin.pause(); + appendFileSync(backpressurePath, String(Buffer.byteLength(buffer))); + sendFloodWaits(); + } for (let newline; (newline = buffer.indexOf("\n")) >= 0; ) { const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1); @@ -322,15 +623,21 @@ async function createHarness( readonly authenticate?: boolean; readonly blockResume?: boolean; readonly failResume?: boolean; + readonly file?: AgentDriverFilePort; readonly hangClose?: boolean; + readonly metadataOnResume?: boolean; readonly openCodeInstructions?: boolean; onEvents?(events: readonly DriverEventInput[]): void; readonly permission?: AgentDriverPermissionPort["request"]; readonly spawnLateChild?: boolean; readonly updateBeforeSessionResponse?: boolean; + readonly writeOnClose?: boolean; } = {}, ) { const root = await mkdtemp(join(tmpdir(), "driver-acp-backend-")); + const agentPidPath = join(root, "agent.pid"); + const backpressurePath = join(root, "backpressure.log"); + const closeWritePath = join(root, "close-write.txt"); const logPath = join(root, "methods.log"); const latePidPath = join(root, "late.pid"); const openCodeConfigPath = join(root, "opencode-config.json"); @@ -349,6 +656,8 @@ async function createHarness( environment: { variables: { ...(options.openCodeInstructions ? { OPENCODE_CONFIG_CONTENT: "{}" } : {}), + TEST_AGENT_PID_PATH: agentPidPath, + TEST_BACKPRESSURE_PATH: backpressurePath, TEST_LOG_PATH: logPath, TEST_LATE_PID_PATH: latePidPath, TEST_OPENCODE_CONFIG_PATH: openCodeConfigPath, @@ -356,8 +665,10 @@ async function createHarness( TEST_RESUME_GATE_PATH: resumeGatePath, TEST_TRIGGER_PATH: triggerPath, TEST_BLOCK_RESUME: options.blockResume ? "1" : "0", + TEST_CLOSE_WRITE_PATH: options.writeOnClose ? closeWritePath : "", TEST_FAIL_RESUME: options.failResume ? "1" : "0", TEST_HANG_CLOSE: options.hangClose ? "1" : "0", + TEST_METADATA_ON_RESUME: options.metadataOnResume ? "1" : "0", TEST_SPAWN_LATE_CHILD: options.spawnLateChild ? "1" : "0", TEST_UPDATE_BEFORE_SESSION_RESPONSE: options.updateBeforeSessionResponse ? "1" : "0", ...(options.authenticate ? { MOSOO_ACP_AUTH_METHOD_ID: "test-auth" } : {}), @@ -378,23 +689,27 @@ async function createHarness( runtimeTransport: "acp-fallback", } satisfies DriverBootPayload; const payload = createDriverStartInputFromBootPayload(boot); - const logger = createBufferedSinkLogger({ - level: "debug", - service: "acp-driver-backend-test", - sink: async () => {}, - }); + const logger = createDisabledLogger(); let acceptedSeq = 0; const lifecycleFailures: Error[] = []; + const lifecycleFailure = Promise.withResolvers(); const publishedEvents: DriverEventInput[] = []; let block: { readonly entered: ReturnType>; readonly kind: string; readonly release: ReturnType>; } | null = null; + let activeRunId: RunId | null = null; let failKind: string | null = null; const context = createAgentDriverContext({ eventSink: { + currentRunId: () => activeRunId, pushEvents: async ({ events }) => { + const runStarted = events.find((event) => event.kind === "run.started"); + if (runStarted !== undefined) { + activeRunId = runStarted.runId ?? null; + } + if (failKind !== null && events.some((event) => event.kind === failKind)) { failKind = null; throw new Error("event sink unavailable"); @@ -409,23 +724,39 @@ async function createHarness( } publishedEvents.push(...events); + for (const event of events) { + if ( + event.kind === "run.cancelled" || + event.kind === "run.completed" || + event.kind === "run.failed" + ) { + activeRunId = null; + } + } options.onEvents?.(events); return { - accepted: events.map((event) => ({ seq: ++acceptedSeq, type: event.kind })), + accepted: events.map((event) => { + const eventId = event.sourceEventId ?? event.id; + if (eventId === undefined) { + throw new Error("Test event is missing its source identity."); + } + return { eventId, seq: ++acceptedSeq, type: event.kind }; + }), }; }, }, logger, lifecycle: { - fail: (error) => lifecycleFailures.push(error), + fail: (error) => { + lifecycleFailures.push(error); + lifecycleFailure.resolve(error); + }, }, payload, permission: { request: options.permission ?? (async () => "reject_once") }, ports: { - hostIntegration: { - snapshot: async () => createDriverHostIntegrationSnapshotFromBootExecution(boot.execution), - }, + ...(options.file === undefined ? {} : { file: options.file }), skill: { materialize: async () => [] }, }, }); @@ -451,8 +782,29 @@ async function createHarness( } } + const handleInput = backend.handleInput.bind(backend); + backend.handleInput = async (inputContext, input, runId, signal) => { + activeRunId ??= runId; + + try { + await handleInput(inputContext, input, runId, signal); + } finally { + if (activeRunId === runId) { + activeRunId = null; + } + } + }; + return { + agentPidPath, backend, + backpressurePath, + beginRun(runId: RunId) { + if (activeRunId !== null) { + throw new Error(`Test driver run ${activeRunId} is already active.`); + } + activeRunId = runId; + }, blockNext(kind: string) { const entered = Promise.withResolvers(); const release = Promise.withResolvers(); @@ -460,10 +812,10 @@ async function createHarness( return { entered: entered.promise, release: release.resolve }; }, context, + closeWritePath, async destroy() { block?.release.reject(new Error("test cleanup")); await backend.stop(context, "test cleanup", new AbortController().signal).catch(() => {}); - await logger.destroy(); await rm(root, { force: true, recursive: true }); }, failNext(kind: string) { @@ -471,6 +823,7 @@ async function createHarness( }, events: publishedEvents, latePidPath, + lifecycleFailure: lifecycleFailure.promise, lifecycleFailures, async methods() { return (await readFile(logPath, "utf8")).trim().split("\n").filter(Boolean); @@ -493,6 +846,66 @@ async function createHarness( } describe("ACP driver backend lifecycle", () => { + test("fails before provider setup when a configured root cannot be acquired", async () => { + const root = await mkdtemp(join(tmpdir(), "driver-acp-root-init-failure-")); + const missing = join(root, "missing"); + const boot = { + ...driverBootPayload, + execution: { + ...driverBootPayload.execution, + session: { + ...driverBootPayload.execution.session, + additionalDirectories: [missing], + context: { + ...driverBootPayload.execution.session.context, + homePath: join(root, "home"), + sessionOrganizationPath: root, + }, + cwd: root, + }, + }, + runtime: "acp-fallback", + runtimeTransport: "acp-fallback", + } satisfies DriverBootPayload; + const payload = createDriverStartInputFromBootPayload(boot); + const logger = createDisabledLogger(); + let materializations = 0; + const context = createAgentDriverContext({ + eventSink: { + currentRunId: () => null, + pushEvents: async () => ({ accepted: [] }), + }, + logger, + payload, + permission: { request: async () => "reject_once" }, + ports: { + skill: { + materialize: async () => { + materializations += 1; + return []; + }, + }, + }, + }); + const backend = new AcpDriverBackend(payload); + + try { + await expect(backend.start(context, new AbortController().signal)).rejects.toMatchObject({ + code: "ENOENT", + }); + expect(materializations).toBe(0); + await expect( + Promise.all([ + backend.stop(context, "retry cleanup", new AbortController().signal), + backend.stop(context, "retry cleanup", new AbortController().signal), + ]), + ).resolves.toEqual([undefined, undefined]); + } finally { + await backend.stop(context, "test cleanup", new AbortController().signal).catch(() => {}); + await rm(root, { force: true, recursive: true }); + } + }); + test("uses OpenCode native instructions without a hidden bootstrap prompt", async () => { const harness = await createHarness({ openCodeInstructions: true }); @@ -531,80 +944,448 @@ describe("ACP driver backend lifecycle", () => { } }); - test("projects an active transport loss as a failed turn", async () => { + test("fails closed when ACP client request capacity is exhausted", async () => { const harness = await createHarness(); + let replacement: Awaited> | null = null; try { - await expect( + const settlement = await settlePromiseWithTimeout( harness.backend.handleInput( harness.context, - { text: "crash" }, + { text: "request-flood" }, DRIVER_TEST_IDS.runId as RunId, ), - ).rejects.toThrow(); - expect(harness.events.filter((event) => event.kind === "run.failed")).toHaveLength(1); - expect(harness.events.some((event) => event.kind === "run.cancelled")).toBe(false); - expect(harness.lifecycleFailures).toEqual([]); + { label: "backpressured ACP capacity close", timeoutMs: 4_000 }, + ); + expect(settlement).toMatchObject({ + error: expect.objectContaining({ + message: expect.stringContaining("ACP client request capacity exceeded"), + }), + status: "failed", + }); + expect( + (await harness.methods()).filter((method) => method === "terminal/wait_for_exit"), + ).toHaveLength(9); + const bufferedBytes = Number.parseInt(await readFile(harness.backpressurePath, "utf8"), 10); + expect(bufferedBytes).toBeGreaterThan(0); + expect(bufferedBytes).toBeLessThan(1024 * 1024); + const [agentPid] = (await readFile(harness.agentPidPath, "utf8")).trim().split("\n"); + expect(isRunning(Number.parseInt(agentPid!, 10))).toBe(false); + + replacement = await createHarness(); + await expect( + replacement.backend.handleInput( + replacement.context, + { text: "after backpressure" }, + DRIVER_TEST_IDS.runId as RunId, + ), + ).resolves.toBeUndefined(); } finally { + await replacement?.destroy(); await harness.destroy(); } - }); + }, 10_000); - test("clears turn state when prompt-start publication fails", async () => { - const harness = await createHarness(); + test("drains accepted file requests before recycling their connection", async () => { + const blockedReports = Promise.withResolvers(); + const reportsEntered = Promise.withResolvers(); + let reportCount = 0; + const harness = await createHarness({ + file: { + reportChanged: async (_change, _signal) => { + reportCount += 1; + if (reportCount === 8) { + reportsEntered.resolve(); + } + await blockedReports.promise; + }, + }, + }); try { - harness.failNext("run.started"); - await expect( - harness.backend.handleInput( - harness.context, - { text: "first" }, - DRIVER_TEST_IDS.runId as RunId, - ), - ).rejects.toThrow("event sink unavailable"); - await expect( - harness.backend.handleInput( - harness.context, - { text: "second" }, - DRIVER_TEST_IDS.secondRunId as RunId, - ), - ).resolves.toBeUndefined(); + const abandoned = harness.backend.handleInput( + harness.context, + { text: "generation-leak" }, + DRIVER_TEST_IDS.runId as RunId, + ); + void abandoned.catch(() => {}); + await reportsEntered.promise; + await harness.backend.cancelActiveTurn(harness.context, "test generation recycle"); expect( - (await harness.methods()).filter((method) => method === "session/prompt"), - ).toHaveLength(1); + await settlePromiseWithTimeout(abandoned, { + label: "cancelled ACP file request drain", + timeoutMs: 50, + }), + ).toMatchObject({ status: "timed_out" }); + blockedReports.resolve(); + await expect(abandoned).rejects.toThrow("cancelled"); + + const probe = harness.backend.handleInput( + harness.context, + { text: "generation-probe" }, + DRIVER_TEST_IDS.secondRunId as RunId, + ); + void probe.catch(() => {}); + await waitForAcpTestCondition( + async () => + (await harness.responses()).some( + (response) => response["id"] === "generation-probe-read", + ), + "recycled ACP client response", + ); + await probe; } finally { + blockedReports.resolve(); + await new Promise((resolve) => setImmediate(resolve)); await harness.destroy(); } }); - test("does not send a prompt after cancellation crosses the start boundary", async () => { - const harness = await createHarness(); + test("drains committed file reports before full-stop process cleanup", async () => { + const blockedReports = Promise.withResolvers(); + const reportsEntered = Promise.withResolvers(); + const processStopEntered = Promise.withResolvers(); + const originalStop = acpAgentProcess.stopAcpAgentProcess; + const stopSpy = spyOn(acpAgentProcess, "stopAcpAgentProcess").mockImplementation( + async (...args) => { + processStopEntered.resolve(); + await originalStop(...args); + }, + ); + let reportCount = 0; + let harness: Awaited> | null = null; try { - const gate = harness.blockNext("run.started"); + harness = await createHarness({ + file: { + reportChanged: async () => { + reportCount += 1; + if (reportCount === 8) { + reportsEntered.resolve(); + } + await blockedReports.promise; + }, + }, + }); const input = harness.backend.handleInput( harness.context, - { text: "cancel me" }, + { text: "generation-leak" }, DRIVER_TEST_IDS.runId as RunId, ); - await gate.entered; - const cancel = harness.backend.cancelActiveTurn(harness.context, "test cancellation"); - gate.release(); - const settled = await Promise.allSettled([input, cancel]); + void input.catch(() => {}); + await reportsEntered.promise; - expect(settled.map((result) => result.status)).toEqual(["rejected", "fulfilled"]); - expect(await harness.methods()).not.toContain("session/prompt"); - expect(await harness.methods()).not.toContain("session/cancel"); + const stop = harness.backend.stop( + harness.context, + "test committed file drain", + new AbortController().signal, + ); + void stop.catch(() => {}); + expect( + await settlePromiseWithTimeout(processStopEntered.promise, { + label: "ACP process stop before file drain", + timeoutMs: 50, + }), + ).toMatchObject({ status: "timed_out" }); + + blockedReports.resolve(); + await expect(stop).resolves.toBeUndefined(); + await expect(processStopEntered.promise).resolves.toBeUndefined(); + expect(reportCount).toBe(8); + await Promise.allSettled([input]); } finally { - await harness.destroy(); + blockedReports.resolve(); + await harness?.destroy(); + stopSpy.mockRestore(); } }); - test("does not admit a prompt after dispatcher cancellation wins before backend admission", async () => { - const harness = await createHarness(); - const cancellation = new AbortController(); - cancellation.abort(new Error("test admission cancellation")); - + test("fails the turn before its terminal when a provider ignores a committed file report", async () => { + const failure = new Error("committed file.changed failed"); + const reportEntered = Promise.withResolvers(); + const releaseReport = Promise.withResolvers(); + const harness = await createHarness({ + file: { + reportChanged: async () => { + reportEntered.resolve(); + await releaseReport.promise; + throw failure; + }, + }, + }); + + try { + const input = harness.backend.handleInput( + harness.context, + { text: "ignore-file-report" }, + DRIVER_TEST_IDS.runId as RunId, + ); + void input.catch(() => {}); + await reportEntered.promise; + expect( + await settlePromiseWithTimeout(input, { + label: "ACP committed file terminal fence", + timeoutMs: 50, + }), + ).toMatchObject({ status: "timed_out" }); + + releaseReport.resolve(); + expect( + await settlePromiseWithTimeout(input, { + label: "ACP committed file turn failure", + timeoutMs: 2_000, + }), + ).toEqual({ error: failure, status: "failed" }); + expect( + await settlePromiseWithTimeout(harness.lifecycleFailure, { + label: "ACP committed file lifecycle failure", + timeoutMs: 2_000, + }), + ).toEqual({ status: "completed", value: failure }); + expect( + harness.events + .filter( + (event) => + event.kind === "run.cancelled" || + event.kind === "run.completed" || + event.kind === "run.failed", + ) + .map((event) => event.kind), + ).toEqual(["run.failed"]); + await expect( + harness.backend.handleInput( + harness.context, + { text: "must not continue" }, + DRIVER_TEST_IDS.secondRunId as RunId, + ), + ).rejects.toThrow("connection is not initialized"); + await expect( + harness.backend.stop( + harness.context, + "test committed file failure cleanup", + new AbortController().signal, + ), + ).resolves.toBeUndefined(); + await expect( + harness.backend.stop( + harness.context, + "test committed file cleanup join", + new AbortController().signal, + ), + ).resolves.toBeUndefined(); + } finally { + releaseReport.resolve(); + await harness.destroy(); + } + }); + + test("rejects a file write arriving after the full-stop ingress fence", async () => { + const harness = await createHarness({ writeOnClose: true }); + + try { + await expect( + harness.backend.stop( + harness.context, + "test close write fence", + new AbortController().signal, + ), + ).resolves.toBeUndefined(); + + expect(await harness.responses()).toContainEqual( + expect.objectContaining({ + error: expect.any(Object), + id: "close-write", + }), + ); + await expect(readFile(harness.closeWritePath, "utf8")).rejects.toMatchObject({ + code: "ENOENT", + }); + } finally { + await harness.destroy(); + } + }); + + test("projects an active transport loss as a failed turn", async () => { + const harness = await createHarness(); + + try { + await expect( + harness.backend.handleInput( + harness.context, + { text: "crash" }, + DRIVER_TEST_IDS.runId as RunId, + ), + ).rejects.toThrow(); + expect(harness.events.filter((event) => event.kind === "run.failed")).toHaveLength(1); + expect(harness.events.some((event) => event.kind === "run.cancelled")).toBe(false); + expect(harness.lifecycleFailures).toEqual([]); + } finally { + await harness.destroy(); + } + }); + + test("commits a complete prompt response before a same-tick transport EOF", async () => { + const originalStop = acpAgentProcess.stopAcpAgentProcess; + let cleanupFailed = false; + const cleanupRejected = Promise.withResolvers(); + let failedProcess: Parameters[1] | null = null; + let retriedOwnedProcess = false; + const stopSpy = spyOn(acpAgentProcess, "stopAcpAgentProcess").mockImplementation( + async (context, process, reason, deadline, signal) => { + if (reason === "connection.failed" && !cleanupFailed) { + cleanupFailed = true; + failedProcess = process; + cleanupRejected.resolve(); + throw new Error("test connection cleanup failed"); + } + if (failedProcess !== null && process === failedProcess) { + retriedOwnedProcess = true; + } + await originalStop(context, process, reason, deadline, signal); + }, + ); + let harness: Awaited> | null = null; + + try { + harness = await createHarness(); + const terminal = harness.blockNext("run.completed"); + const input = harness.backend.handleInput( + harness.context, + { text: "response-then-eof" }, + DRIVER_TEST_IDS.runId as RunId, + ); + void input.catch(() => {}); + + await Promise.all([terminal.entered, cleanupRejected.promise]); + try { + await expect( + harness.backend.handleInput( + harness.context, + { text: "unreachable" }, + DRIVER_TEST_IDS.secondRunId, + ), + ).rejects.toThrow("ACP driver backend connection is not initialized"); + await Promise.resolve(); + expect(harness.lifecycleFailures).toEqual([]); + } finally { + terminal.release(); + } + await expect(input).resolves.toBeUndefined(); + await harness.lifecycleFailure; + + expect(harness.events.filter((event) => event.kind === "run.completed")).toHaveLength(1); + expect(harness.events.some((event) => event.kind === "run.failed")).toBe(false); + expect(harness.lifecycleFailures).toHaveLength(1); + expect(harness.lifecycleFailures[0]).toBeInstanceOf(AggregateError); + await expect( + harness.backend.stop(harness.context, "test retry cleanup", new AbortController().signal), + ).resolves.toBeUndefined(); + expect(retriedOwnedProcess).toBe(true); + } finally { + await harness?.destroy(); + stopSpy.mockRestore(); + } + }); + + test("lets a transport EOF before the prompt response fail the active turn", async () => { + const harness = await createHarness(); + + try { + await expect( + harness.backend.handleInput( + harness.context, + { text: "eof-before-response" }, + DRIVER_TEST_IDS.runId as RunId, + ), + ).rejects.toThrow(); + + expect(harness.events.filter((event) => event.kind === "run.failed")).toHaveLength(1); + expect(harness.events.some((event) => event.kind === "run.completed")).toBe(false); + expect(harness.lifecycleFailures).toEqual([]); + } finally { + await harness.destroy(); + } + }); + + test("publishes a thought-only fallback through lossless terminal closures", async () => { + const harness = await createHarness(); + + try { + await harness.backend.handleInput( + harness.context, + { text: "thought-only" }, + DRIVER_TEST_IDS.runId as RunId, + ); + + expect( + harness.events.find( + (event) => + event.kind === "message.added" && + (event.payload as Record)["role"] === "agent", + )?.payload, + ).toMatchObject({ content: "final from thought" }); + expect(harness.events.filter((event) => event.kind === "run.completed")).toHaveLength(1); + expect(harness.lifecycleFailures).toEqual([]); + } finally { + await harness.destroy(); + } + }); + + test("clears turn state when prompt-start publication fails", async () => { + const harness = await createHarness(); + + try { + harness.failNext("run.started"); + await expect( + harness.backend.handleInput( + harness.context, + { text: "first" }, + DRIVER_TEST_IDS.runId as RunId, + ), + ).rejects.toThrow("event sink unavailable"); + await expect( + harness.backend.handleInput( + harness.context, + { text: "second" }, + DRIVER_TEST_IDS.secondRunId as RunId, + ), + ).resolves.toBeUndefined(); + expect( + (await harness.methods()).filter((method) => method === "session/prompt"), + ).toHaveLength(1); + } finally { + await harness.destroy(); + } + }); + + test("does not send a prompt after cancellation crosses the start boundary", async () => { + const harness = await createHarness(); + + try { + const gate = harness.blockNext("run.started"); + const input = harness.backend.handleInput( + harness.context, + { text: "cancel me" }, + DRIVER_TEST_IDS.runId as RunId, + ); + await gate.entered; + const cancel = harness.backend.cancelActiveTurn(harness.context, "test cancellation"); + gate.release(); + const settled = await Promise.allSettled([input, cancel]); + + expect(settled.map((result) => result.status)).toEqual(["rejected", "fulfilled"]); + expect(await harness.methods()).not.toContain("session/prompt"); + expect(await harness.methods()).not.toContain("session/cancel"); + } finally { + await harness.destroy(); + } + }); + + test("does not admit a prompt after dispatcher cancellation wins before backend admission", async () => { + const harness = await createHarness(); + const cancellation = new AbortController(); + cancellation.abort(new Error("test admission cancellation")); + try { await expect( harness.backend.handleInput( @@ -632,6 +1413,98 @@ describe("ACP driver backend lifecycle", () => { } }); + test("fails a pre-admission cancelled turn when backend stop cleanup fails", async () => { + const originalStop = acpAgentProcess.stopAcpAgentProcess; + let failedProcess: Parameters[1] | null = null; + let retriedOwnedProcess = false; + const stopSpy = spyOn(acpAgentProcess, "stopAcpAgentProcess").mockImplementation( + async (context, process, reason, deadline, signal) => { + if (reason === "test pre-admission stop" && failedProcess === null) { + failedProcess = process; + throw new Error("test pre-admission cleanup failed"); + } + if (failedProcess !== null && process === failedProcess) { + retriedOwnedProcess = true; + } + await originalStop(context, process, reason, deadline, signal); + }, + ); + let harness: Awaited> | null = null; + + try { + harness = await createHarness(); + const gate = harness.blockNext("run.started"); + const input = harness.backend.handleInput( + harness.context, + { text: "must not run" }, + DRIVER_TEST_IDS.runId as RunId, + ); + void input.catch(() => {}); + await gate.entered; + + await expect( + harness.backend.stop( + harness.context, + "test pre-admission stop", + new AbortController().signal, + ), + ).rejects.toThrow("test pre-admission cleanup failed"); + gate.release(); + + await expect(input).rejects.toThrow("cancelled turn process recycle failed"); + expect(await harness.methods()).not.toContain("session/prompt"); + expect( + harness.events + .filter( + (event) => + event.kind === "run.cancelled" || + event.kind === "run.completed" || + event.kind === "run.failed", + ) + .map((event) => event.kind), + ).toEqual(["run.failed"]); + await expect( + harness.backend.stop( + harness.context, + "test pre-admission retry", + new AbortController().signal, + ), + ).resolves.toBeUndefined(); + expect(retriedOwnedProcess).toBe(true); + } finally { + await harness?.destroy(); + stopSpy.mockRestore(); + } + }); + + test("fails a prompt response with an invalid stop reason before terminal projection", async () => { + const harness = await createHarness(); + + try { + await expect( + harness.backend.handleInput( + harness.context, + { text: "invalid-stop-reason" }, + DRIVER_TEST_IDS.runId as RunId, + ), + ).rejects.toThrow("ACP prompt response contains an invalid stop reason"); + + expect( + harness.events + .filter( + (event) => + event.kind === "run.cancelled" || + event.kind === "run.completed" || + event.kind === "run.failed", + ) + .map((event) => event.kind), + ).toEqual(["run.failed"]); + expect(harness.events.filter((event) => event.kind === "item.completed")).toHaveLength(509); + } finally { + await harness.destroy(); + } + }); + test("sends provider cancellation while its observation event is blocked", async () => { const harness = await createHarness(); @@ -642,21 +1515,17 @@ describe("ACP driver backend lifecycle", () => { DRIVER_TEST_IDS.runId as RunId, ); void input.catch(() => {}); - for (let attempt = 0; attempt < 100; attempt += 1) { - if ((await harness.methods()).includes("session/prompt")) { - break; - } - await Bun.sleep(5); - } + await waitForAcpTestCondition( + async () => (await harness.methods()).includes("session/prompt"), + "ACP session/prompt request", + ); const gate = harness.blockNext("run.cancel.requested"); const cancel = harness.backend.cancelActiveTurn(harness.context, "test cancellation"); await gate.entered; - for (let attempt = 0; attempt < 100; attempt += 1) { - if ((await harness.methods()).includes("session/cancel")) { - break; - } - await Bun.sleep(5); - } + await waitForAcpTestCondition( + async () => (await harness.methods()).includes("session/cancel"), + "ACP session/cancel request", + ); expect(await harness.methods()).toContain("session/cancel"); expect( @@ -691,22 +1560,24 @@ describe("ACP driver backend lifecycle", () => { DRIVER_TEST_IDS.runId as RunId, ); void input.catch(() => {}); - for (let attempt = 0; attempt < 100; attempt += 1) { - if ((await harness.methods()).includes("session/prompt")) { - break; - } - await Bun.sleep(5); - } + await waitForAcpTestCondition( + async () => (await harness.methods()).includes("session/prompt"), + "ACP session/prompt request", + ); - await expect( - harness.backend.cancelActiveTurn(harness.context, "test cancellation"), - ).resolves.toBeUndefined(); - for (let attempt = 0; attempt < 100; attempt += 1) { - if ((await harness.methods()).includes("session/resume")) { - break; - } - await Bun.sleep(5); - } + const gate = harness.blockNext("run.cancel.requested"); + const cancellation = harness.backend.cancelActiveTurn(harness.context, "test cancellation"); + await gate.entered; + await waitForAcpTestCondition( + async () => (await harness.methods()).includes("session/cancel"), + "ACP session/cancel request", + ); + gate.release(); + await expect(cancellation).resolves.toBeUndefined(); + await waitForAcpTestCondition( + async () => (await harness.methods()).includes("session/resume"), + "ACP session/resume request", + ); expect(await harness.methods()).toContain("session/resume"); expect(harness.events).not.toContainEqual( @@ -758,41 +1629,248 @@ describe("ACP driver backend lifecycle", () => { const harness = await createHarness({ failResume: true }); try { - const input = harness.backend.handleInput( - harness.context, - { text: "hang" }, - DRIVER_TEST_IDS.runId as RunId, - ); - void input.catch(() => {}); - for (let attempt = 0; attempt < 100; attempt += 1) { - if ((await harness.methods()).includes("session/prompt")) { - break; - } - await Bun.sleep(5); - } - + const input = harness.backend.handleInput( + harness.context, + { text: "hang" }, + DRIVER_TEST_IDS.runId as RunId, + ); + void input.catch(() => {}); + await waitForAcpTestCondition( + async () => (await harness.methods()).includes("session/prompt"), + "ACP session/prompt request", + ); + + await expect( + harness.backend.cancelActiveTurn(harness.context, "test cancellation"), + ).resolves.toBeUndefined(); + await expect(input).rejects.toThrow("process recycle failed"); + expect(harness.events).toContainEqual(expect.objectContaining({ kind: "run.failed" })); + expect(harness.events).not.toContainEqual(expect.objectContaining({ kind: "run.cancelled" })); + expect(harness.lifecycleFailures).toEqual([]); + } finally { + await harness.destroy(); + } + }); + + test("keeps session metadata admitted while cancelled-turn transcript replay is closed", async () => { + const harness = await createHarness({ metadataOnResume: true }); + + try { + const eventOffset = harness.events.length; + const input = harness.backend.handleInput( + harness.context, + { text: "hang" }, + DRIVER_TEST_IDS.runId as RunId, + ); + void input.catch(() => {}); + await waitForAcpTestCondition( + async () => (await harness.methods()).includes("session/prompt"), + "ACP session/prompt request", + ); + + await harness.backend.cancelActiveTurn(harness.context, "test cancellation"); + await expect(input).rejects.toThrow("cancelled"); + + expect(harness.events.slice(eventOffset).map((event) => event.kind)).toEqual( + expect.arrayContaining([ + "session.commands.updated", + "session.config.updated", + "session.mode.updated", + "session.info.updated", + "run.cancelled", + ]), + ); + } finally { + await harness.destroy(); + } + }); + + test("publishes a failed terminal even when fatal provider cleanup rejects", async () => { + const harness = await createHarness(); + const promptRequested = Promise.withResolvers(); + const prompt = Promise.withResolvers<{ stopReason: "end_turn" }>(); + const events: DriverEventInput[] = []; + const turn = new AcpTurnController(async (_context, _reason, pushed) => { + events.push(...pushed); + }); + const clientRequests = new AcpClientRequestHandler({ + allowedRoots: [process.cwd()], + cwd: process.cwd(), + env: {}, + isCancelling: () => turn.isCancelling(), + nativeSessionId: () => "native-session-1", + onUpdateFailure: () => {}, + push: async () => {}, + turnEvents: turn.events, + }); + const connection = { + notify: async () => {}, + request: async () => { + promptRequested.resolve(); + return prompt.promise; + }, + } as unknown as ClientContext; + const providerError = new Error("provider transport failed"); + const cleanupError = new Error("provider cleanup failed"); + + try { + const input = turn.handleInput( + harness.context, + { text: "fail" }, + DRIVER_TEST_IDS.runId as RunId, + connection, + "native-session-1", + clientRequests, + ); + void input.catch(() => {}); + await promptRequested.promise; + expect(turn.routeFatal(providerError, Promise.reject(cleanupError))).toBeNull(); + prompt.reject(providerError); + + await expect(input).rejects.toThrow("ACP provider failure cleanup failed"); + expect(events.filter((event) => event.kind === "run.failed")).toHaveLength(1); + expect(events.some((event) => event.kind === "run.cancelled")).toBe(false); + } finally { + await harness.destroy(); + } + }); + + test("accepts a notification close after the cancelled terminal is delivered", async () => { + const harness = await createHarness(); + const promptRequested = Promise.withResolvers(); + const terminalDelivered = Promise.withResolvers(); + const prompt = Promise.withResolvers<{ stopReason: "cancelled" }>(); + const turn = new AcpTurnController(async (_context, _reason, events) => { + if (events.some((event) => event.kind === "run.cancelled")) { + terminalDelivered.resolve(); + } + }); + const clientRequests = new AcpClientRequestHandler({ + allowedRoots: [process.cwd()], + cwd: process.cwd(), + env: {}, + isCancelling: () => turn.isCancelling(), + nativeSessionId: () => "native-session-1", + onUpdateFailure: () => {}, + push: async () => {}, + turnEvents: turn.events, + }); + const connection = { + notify: async () => { + prompt.resolve({ stopReason: "cancelled" }); + await terminalDelivered.promise; + throw new Error("ACP transport closed"); + }, + request: async () => { + promptRequested.resolve(); + return prompt.promise; + }, + } as unknown as ClientContext; + + try { + const input = turn.handleInput( + harness.context, + { text: "cancel me" }, + DRIVER_TEST_IDS.runId as RunId, + connection, + "native-session-1", + clientRequests, + ); + void input.catch(() => {}); + await promptRequested.promise; + const cancel = turn.cancel( + harness.context, + "test cancellation", + connection, + "native-session-1", + ); + + await expect(cancel).resolves.toBeUndefined(); + await expect(input).rejects.toThrow("cancelled"); + } finally { + await harness.destroy(); + } + }); + + test("restores an explicitly rejected terminal settlement for authoritative replay", async () => { + const harness = await createHarness(); + let rejectedSettlement: DriverEventInput[] | null = null; + const turn = new AcpTurnController( + async () => {}, + async () => {}, + async (_context, _reason, closures, terminal) => { + rejectedSettlement = structuredClone([...closures, terminal]); + throw new Error("terminal settlement rejected"); + }, + ); + const clientRequests = new AcpClientRequestHandler({ + allowedRoots: [process.cwd()], + cwd: process.cwd(), + env: {}, + isCancelling: () => turn.isCancelling(), + nativeSessionId: () => "native-session-1", + onUpdateFailure: () => {}, + push: async () => {}, + turnEvents: turn.events, + }); + const connection = { + notify: async () => {}, + request: async () => { + await clientRequests.enqueueUpdate(harness.context, { + sessionId: "native-session-1", + update: { + content: { text: "authoritative answer", type: "text" }, + messageId: "native-final", + sessionUpdate: "agent_message_chunk", + }, + }); + return { stopReason: "end_turn" }; + }, + } as unknown as ClientContext; + + try { await expect( - harness.backend.cancelActiveTurn(harness.context, "test cancellation"), - ).resolves.toBeUndefined(); - await expect(input).rejects.toThrow("process recycle failed"); - expect(harness.events).toContainEqual(expect.objectContaining({ kind: "run.failed" })); - expect(harness.events).not.toContainEqual(expect.objectContaining({ kind: "run.cancelled" })); - expect(harness.lifecycleFailures).toEqual([]); + turn.handleInput( + harness.context, + { text: "complete" }, + DRIVER_TEST_IDS.runId as RunId, + connection, + "native-session-1", + clientRequests, + ), + ).rejects.toThrow("terminal settlement rejected"); + + expect(turn.events.activeRunId()).toBe(DRIVER_TEST_IDS.runId); + expect(turn.events.completePrompt("end_turn", null)).toEqual(rejectedSettlement); } finally { + turn.events.clear(); await harness.destroy(); } }); - test("accepts a notification close after the cancelled terminal is delivered", async () => { + test("cancels an unresponsive provider only after the request event is durable", async () => { const harness = await createHarness(); + const state = new DriverTerminalStateMachine(); + const ticket = state.beginRun(DRIVER_TEST_IDS.runId as RunId); const promptRequested = Promise.withResolvers(); - const terminalDelivered = Promise.withResolvers(); - const prompt = Promise.withResolvers<{ stopReason: "cancelled" }>(); - const turn = new AcpTurnController(async (_context, _reason, events) => { - if (events.some((event) => event.kind === "run.cancelled")) { - terminalDelivered.resolve(); - } - }); + const cancellationPushEntered = Promise.withResolvers(); + const releaseCancellationPush = Promise.withResolvers(); + const barrierEntered = Promise.withResolvers(); + let resumeAllowedAtBarrier: boolean | null = null; + const events: DriverEventInput[] = []; + const turn = new AcpTurnController( + async (_context, _reason, pushed) => { + if (pushed.some(({ kind }) => kind === "run.cancel.requested")) { + cancellationPushEntered.resolve(); + await releaseCancellationPush.promise; + } + events.push(...pushed); + }, + async (_context, _providerPromptAdmitted, resumeSignal) => { + resumeAllowedAtBarrier = !resumeSignal.aborted; + barrierEntered.resolve(); + }, + ); const clientRequests = new AcpClientRequestHandler({ allowedRoots: [process.cwd()], cwd: process.cwd(), @@ -804,14 +1882,10 @@ describe("ACP driver backend lifecycle", () => { turnEvents: turn.events, }); const connection = { - notify: async () => { - prompt.resolve({ stopReason: "cancelled" }); - await terminalDelivered.promise; - throw new Error("ACP transport closed"); - }, + notify: () => new Promise(() => {}), request: async () => { promptRequested.resolve(); - return prompt.promise; + return new Promise(() => {}); }, } as unknown as ClientContext; @@ -822,11 +1896,12 @@ describe("ACP driver backend lifecycle", () => { DRIVER_TEST_IDS.runId as RunId, connection, "native-session-1", - createDriverHostIntegrationSnapshotFromBootExecution(driverBootPayload.execution), clientRequests, + ticket.signal, ); void input.catch(() => {}); await promptRequested.promise; + expect(state.claimCancellation(ticket, "test cancellation", "turn.cancel")).toBe("claimed"); const cancel = turn.cancel( harness.context, "test cancellation", @@ -834,9 +1909,31 @@ describe("ACP driver backend lifecycle", () => { "native-session-1", ); + await cancellationPushEntered.promise; await expect(cancel).resolves.toBeUndefined(); + expect( + await settlePromiseWithTimeout(barrierEntered.promise, { + label: "ACP cancellation request acknowledgement", + timeoutMs: 25, + }), + ).toMatchObject({ status: "timed_out" }); + expect(state.claimCancellation(ticket, "test shutdown", "shutdown")).toBe("already_claimed"); + releaseCancellationPush.resolve(); await expect(input).rejects.toThrow("cancelled"); + await barrierEntered.promise; + expect(resumeAllowedAtBarrier).toBe(false); + + const eventKinds = events.map(({ kind }) => kind); + expect(eventKinds.indexOf("run.cancel.requested")).toBeLessThan( + eventKinds.indexOf("run.cancelled"), + ); + expect( + eventKinds.filter( + (kind) => kind === "run.cancelled" || kind === "run.completed" || kind === "run.failed", + ), + ).toEqual(["run.cancelled"]); } finally { + releaseCancellationPush.resolve(); await harness.destroy(); } }); @@ -882,7 +1979,6 @@ describe("ACP driver backend lifecycle", () => { DRIVER_TEST_IDS.runId as RunId, connection, "native-session-1", - createDriverHostIntegrationSnapshotFromBootExecution(driverBootPayload.execution), clientRequests, ); void input.catch(() => {}); @@ -1068,6 +2164,7 @@ describe("ACP driver backend lifecycle", () => { } return { accepted: events.map((event, index) => ({ + eventId: event.sourceEventId!, seq: index + 1, type: event.kind, })), @@ -1129,6 +2226,7 @@ describe("ACP driver backend lifecycle", () => { acceptedKinds.push(...events.map((event) => event.kind)); return { accepted: events.map((event, index) => ({ + eventId: event.sourceEventId!, seq: index + 1, type: event.kind, })), @@ -1155,12 +2253,10 @@ describe("ACP driver backend lifecycle", () => { const requestId = await requestedDelivered.promise; expect(broker.resolve(requestId, "allow_once")).toBe(true); await permissionSettled.promise; - for (let attempt = 0; attempt < 100; attempt += 1) { - if ((await harness.responses()).length > 0) { - break; - } - await Bun.sleep(5); - } + await waitForAcpTestCondition( + async () => (await harness.responses()).length > 0, + "ACP nested permission response", + ); expect(await harness.responses()).toHaveLength(1); const cancel = harness.backend.cancelActiveTurn(harness.context, "test cancellation"); @@ -1258,7 +2354,15 @@ describe("ACP driver backend lifecycle", () => { releasePermission.resolve(); await expect(input).rejects.toThrow("cancelled"); - expect(harness.events).toContainEqual(expect.objectContaining({ kind: "run.cancelled" })); + expect(harness.events).toContainEqual( + expect.objectContaining({ + kind: "run.cancelled", + payload: expect.objectContaining({ requestedBy: "provider" }), + }), + ); + expect(harness.events).not.toContainEqual( + expect.objectContaining({ kind: "run.cancel.requested" }), + ); expect(harness.events).not.toContainEqual(expect.objectContaining({ kind: "run.failed" })); } finally { releasePermission.resolve(); @@ -1294,6 +2398,361 @@ describe("ACP driver backend lifecycle", () => { } }); + test("lets a cancellation request use the durable event delivery budget", async () => { + const harness = await createHarness(); + const gateEntered = Promise.withResolvers(); + const releaseGate = Promise.withResolvers(); + let gateOpen = false; + const socket = new FakeDriverRuntimeIo([ + { + commandId: "acp-durable-cancel-input", + input: { text: "hang" }, + kind: "input.start", + requestId: "acp-durable-cancel-request", + runId: DRIVER_TEST_IDS.runId, + }, + { + commandId: "acp-durable-cancel-command", + kind: "turn.cancel", + reason: "test.cancel", + runId: DRIVER_TEST_IDS.runId, + }, + ]); + const pushEvents = socket.pushEvents.bind(socket); + socket.pushEvents = async (input) => { + if (!gateOpen && input.events.some(({ kind }) => kind === "run.cancel.requested")) { + gateOpen = true; + gateEntered.resolve(); + await releaseGate.promise; + } + return pushEvents(input); + }; + const runtimeState = new DriverRuntimeStateMachine("ready"); + const { dispatcher, logger, shutdownCalls } = createDispatcher({ + backend: harness.backend, + isShuttingDown: () => socket.isDrained() && socket.currentRunId() === null, + runtimeState, + }); + + try { + const running = dispatcher.run(socket, logger); + await gateEntered.promise; + await Bun.sleep(2_100); + releaseGate.resolve(); + await running; + + const events = socket.pushedEvents.flatMap(({ events }) => events); + expect( + events + .filter(({ kind }) => ["run.cancelled", "run.completed", "run.failed"].includes(kind)) + .map(({ kind }) => kind), + ).toEqual(["run.cancelled"]); + expect(events.filter(({ kind }) => kind === "run.cancel.requested")).toHaveLength(1); + expect(runtimeState.status()).toBe("ready"); + expect(shutdownCalls).toEqual([]); + } finally { + releaseGate.resolve(); + await harness.destroy(); + } + }, 7_000); + + test("honors dispatcher cancellation before the completed terminal is selected", async () => { + const harness = await createHarness({ blockResume: true }); + const windowEntered = Promise.withResolvers(); + const releaseWindow = Promise.withResolvers(); + const cancellationClaimed = Promise.withResolvers(); + const socket = new FakeDriverRuntimeIo([ + { + commandId: "acp-terminal-race-input", + input: { text: "complete" }, + kind: "input.start", + requestId: "acp-terminal-race-request", + runId: DRIVER_TEST_IDS.runId, + }, + { + commandId: "acp-terminal-race-cancel", + kind: "turn.cancel", + reason: "test.cancel", + runId: DRIVER_TEST_IDS.runId, + }, + ]); + const nextCommand = socket.nextCommand.bind(socket); + socket.nextCommand = async (signal) => { + const command = await nextCommand(signal); + if (command?.kind === "turn.cancel") { + await windowEntered.promise; + } + return command; + }; + const registerRunTerminalBarrier = socket.registerRunTerminalBarrier.bind(socket); + socket.registerRunTerminalBarrier = (barrier) => + registerRunTerminalBarrier((events) => { + const pending = barrier(events); + if (!events.some(({ kind }) => kind === "run.completed")) { + return pending; + } + return Promise.resolve(pending).then(async () => { + windowEntered.resolve(); + await releaseWindow.promise; + }); + }); + const claimRunCancellation = socket.claimRunCancellation.bind(socket); + socket.claimRunCancellation = (ticket, reason) => { + const result = claimRunCancellation(ticket, reason); + cancellationClaimed.resolve(); + return result; + }; + const runtimeState = new DriverRuntimeStateMachine("ready"); + const { dispatcher, logger, shutdownCalls } = createDispatcher({ + backend: harness.backend, + isShuttingDown: () => socket.isDrained() && socket.currentRunId() === null, + runtimeState, + }); + + try { + const running = dispatcher.run(socket, logger); + await windowEntered.promise; + await cancellationClaimed.promise; + releaseWindow.resolve(); + await waitForAcpTestCondition( + async () => (await harness.methods()).includes("session/resume"), + "ACP session/resume request", + ); + await Bun.sleep(2_100); + await writeFile(harness.resumeGatePath, "resume"); + await running; + + const events = socket.pushedEvents.flatMap(({ events }) => events); + expect( + events + .filter( + ({ kind }) => + kind === "run.cancelled" || kind === "run.completed" || kind === "run.failed", + ) + .map(({ kind }) => kind), + ).toEqual(["run.cancelled"]); + expect(events.filter(({ kind }) => kind === "run.cancel.requested")).toHaveLength(1); + expect( + events + .filter(({ kind }) => kind === "message.completed" || kind === "message.cancelled") + .map(({ kind }) => kind), + ).toEqual(["message.completed"]); + expect(socket.updates).toEqual([ + { commandId: "acp-terminal-race-input", status: "accepted" }, + { commandId: "acp-terminal-race-cancel", status: "accepted" }, + { commandId: "acp-terminal-race-input", status: "cancelled" }, + { commandId: "acp-terminal-race-cancel", status: "completed" }, + ]); + expect(socket.failedRuns).toEqual([]); + expect(shutdownCalls).toEqual([]); + expect(runtimeState.status()).toBe("ready"); + } finally { + releaseWindow.resolve(); + await writeFile(harness.resumeGatePath, "cleanup").catch(() => {}); + await harness.destroy(); + } + }, 7_000); + + test("interrupts a provider-cancelled resume when session stop arrives", async () => { + const harness = await createHarness({ blockResume: true }); + const runtimeState = new DriverRuntimeStateMachine("ready"); + const socket = new FakeDriverRuntimeIo([ + { + commandId: "acp-provider-cancel-input", + input: { text: "provider-cancel" }, + kind: "input.start", + requestId: "acp-provider-cancel-request", + runId: DRIVER_TEST_IDS.runId, + }, + { + commandId: "acp-provider-cancel-stop", + kind: "session.stop", + reason: "test.stop", + }, + ]); + const nextCommand = socket.nextCommand.bind(socket); + socket.nextCommand = async (signal) => { + const command = await nextCommand(signal); + if (command?.kind === "session.stop") { + await waitForAcpTestCondition( + async () => (await harness.methods()).includes("session/resume"), + "blocked ACP session/resume request", + ); + } + return command; + }; + const { dispatcher, logger } = createDispatcher({ + backend: harness.backend, + isShuttingDown: () => runtimeState.isShuttingDown(), + runtimeState, + shutdown: async (_runtimeIo, reason) => + harness.backend.stop(harness.context, reason, new AbortController().signal), + }); + const running = dispatcher.run(socket, logger); + + try { + await waitForAcpTestCondition( + async () => (await harness.methods()).includes("session/resume"), + "blocked ACP session/resume request", + ); + expect( + await settlePromiseWithTimeout(running, { + label: "provider-cancelled ACP session stop", + timeoutMs: 1_500, + }), + ).toMatchObject({ status: "completed" }); + + const events = socket.pushedEvents.flatMap(({ events }) => events); + const eventKinds = events.map(({ kind }) => kind); + expect( + events + .filter(({ kind }) => ["run.cancelled", "run.completed", "run.failed"].includes(kind)) + .map(({ kind }) => kind), + ).toEqual(["run.cancelled"]); + expect(eventKinds.filter((kind) => kind === "run.cancel.requested")).toHaveLength(1); + expect(eventKinds.indexOf("run.cancel.requested")).toBeLessThan( + eventKinds.indexOf("run.cancelled"), + ); + expect(events.filter(({ kind }) => kind === "session.resumed")).toHaveLength(0); + expect(runtimeState.status()).toBe("stopped"); + } finally { + await writeFile(harness.resumeGatePath, "cleanup").catch(() => {}); + await running.catch(() => {}); + await harness.destroy(); + } + }, 7_000); + + test("interrupts a turn-cancel resume when shutdown upgrades the claim", async () => { + const harness = await createHarness({ blockResume: true }); + const shutdown = new AbortController(); + const runtimeState = new DriverRuntimeStateMachine("ready"); + const socket = new FakeDriverRuntimeIo([ + { + commandId: "acp-cancel-upgrade-input", + input: { text: "hang" }, + kind: "input.start", + requestId: "acp-cancel-upgrade-request", + runId: DRIVER_TEST_IDS.runId, + }, + { + commandId: "acp-cancel-upgrade-command", + kind: "turn.cancel", + reason: "test.cancel", + runId: DRIVER_TEST_IDS.runId, + }, + ]); + const nextCommand = socket.nextCommand.bind(socket); + socket.nextCommand = async (signal) => { + const command = await nextCommand(signal); + if (command?.kind === "turn.cancel") { + await waitForAcpTestCondition( + async () => (await harness.methods()).includes("session/prompt"), + "ACP session/prompt request", + ); + } + return command; + }; + const { dispatcher, logger } = createDispatcher({ + backend: harness.backend, + isShuttingDown: () => shutdown.signal.aborted, + runtimeState, + shutdownSignal: shutdown.signal, + }); + const running = dispatcher.run(socket, logger); + + try { + await waitForAcpTestCondition( + async () => (await harness.methods()).includes("session/resume"), + "blocked ACP session/resume after turn cancellation", + ); + shutdown.abort(new Error("test shutdown")); + expect( + await settlePromiseWithTimeout(running, { + label: "upgraded ACP turn cancellation", + timeoutMs: 1_500, + }), + ).toMatchObject({ status: "completed" }); + + const events = socket.pushedEvents.flatMap(({ events }) => events); + expect( + events + .filter(({ kind }) => ["run.cancelled", "run.completed", "run.failed"].includes(kind)) + .map(({ kind }) => kind), + ).toEqual(["run.cancelled"]); + expect(events.filter(({ kind }) => kind === "session.resumed")).toHaveLength(0); + } finally { + shutdown.abort(new Error("test cleanup")); + await writeFile(harness.resumeGatePath, "cleanup").catch(() => {}); + await running.catch(() => {}); + await harness.destroy(); + } + }, 7_000); + + test("stops an active session without resuming its cancelled provider", async () => { + const harness = await createHarness(); + const runtimeState = new DriverRuntimeStateMachine("ready"); + const socket = new FakeDriverRuntimeIo([ + { + commandId: "acp-stop-input", + input: { text: "hang" }, + kind: "input.start", + requestId: "acp-stop-request", + runId: DRIVER_TEST_IDS.runId, + }, + { + commandId: "acp-stop-session", + kind: "session.stop", + reason: "test.stop", + }, + ]); + const nextCommand = socket.nextCommand.bind(socket); + socket.nextCommand = async (signal) => { + const command = await nextCommand(signal); + if (command?.kind === "session.stop") { + await waitForAcpTestCondition( + async () => (await harness.methods()).includes("session/prompt"), + "ACP session/prompt request", + ); + } + return command; + }; + const { dispatcher, logger } = createDispatcher({ + backend: harness.backend, + isShuttingDown: () => runtimeState.isShuttingDown(), + runtimeState, + shutdown: async (_runtimeIo, reason) => + harness.backend.stop(harness.context, reason, new AbortController().signal), + }); + + try { + await dispatcher.run(socket, logger); + + const events = socket.pushedEvents.flatMap(({ events }) => events); + expect( + events + .filter( + ({ kind }) => + kind === "run.cancelled" || kind === "run.completed" || kind === "run.failed", + ) + .map(({ kind }) => kind), + ).toEqual(["run.cancelled"]); + expect(events.filter(({ kind }) => kind === "run.cancel.requested")).toHaveLength(1); + expect(await harness.methods()).not.toContain("session/resume"); + expect(socket.updates).toContainEqual({ + commandId: "acp-stop-input", + status: "cancelled", + }); + expect(socket.updates).toContainEqual({ + commandId: "acp-stop-session", + status: "completed", + }); + expect(socket.completedRunReasons).toEqual(["completed"]); + expect(runtimeState.status()).toBe("stopped"); + } finally { + await harness.destroy(); + } + }); + test("drains burst updates sent immediately before the prompt response", async () => { const harness = await createHarness(); @@ -1315,6 +2774,66 @@ describe("ACP driver backend lifecycle", () => { } }); + test("closes 32 open tools before publishing the run terminal", async () => { + const order: DriverEventInput[] = []; + const harness = await createHarness({ onEvents: (events) => order.push(...events) }); + + try { + await expect( + harness.backend.handleInput( + harness.context, + { text: "many-tools" }, + DRIVER_TEST_IDS.runId as RunId, + ), + ).resolves.toBeUndefined(); + + const completedTools = order.filter( + (event) => + event.kind === "tool.call.updated" && + (event.payload as { readonly status?: unknown }).status === "completed", + ); + const completedItems = order.filter( + (event) => + event.kind === "item.completed" && + (event.payload as { readonly status?: unknown }).status === "completed", + ); + const terminalIndex = order.findIndex((event) => event.kind === "run.completed"); + + expect(completedTools).toHaveLength(32); + expect(completedItems).toHaveLength(32); + expect(terminalIndex).toBe(order.length - 1); + expect(harness.lifecycleFailures).toEqual([]); + } finally { + await harness.destroy(); + } + }); + + test("publishes one failed terminal after a sticky update inbox failure", async () => { + const harness = await createHarness(); + + try { + harness.failNext("item.started"); + await expect( + harness.backend.handleInput( + harness.context, + { text: "sticky-update-failure" }, + DRIVER_TEST_IDS.runId as RunId, + ), + ).rejects.toThrow("event sink unavailable"); + + const terminals = harness.events.filter( + (event) => + event.kind === "run.cancelled" || + event.kind === "run.completed" || + event.kind === "run.failed", + ); + expect(terminals).toHaveLength(1); + expect(terminals[0]?.kind).toBe("run.failed"); + } finally { + await harness.destroy(); + } + }); + test("returns ACP request-cancelled for a nested terminal RPC when the turn is cancelled", async () => { const harness = await createHarness(); @@ -1326,23 +2845,19 @@ describe("ACP driver backend lifecycle", () => { ); void input.catch(() => {}); - for (let attempt = 0; attempt < 100; attempt += 1) { - if ((await harness.methods()).includes("terminal/wait_for_exit")) { - break; - } - await Bun.sleep(5); - } + await waitForAcpTestCondition( + async () => (await harness.methods()).includes("terminal/wait_for_exit"), + "ACP terminal/wait_for_exit request", + ); expect(await harness.methods()).toContain("terminal/wait_for_exit"); await harness.backend.cancelActiveTurn(harness.context, "test cancellation"); await expect(input).rejects.toThrow("cancelled"); - for (let attempt = 0; attempt < 100; attempt += 1) { - if ((await harness.responses()).length > 0) { - break; - } - await Bun.sleep(5); - } + await waitForAcpTestCondition( + async () => (await harness.responses()).length > 0, + "ACP nested terminal response", + ); expect(await harness.responses()).toContainEqual( expect.objectContaining({ @@ -1366,12 +2881,10 @@ describe("ACP driver backend lifecycle", () => { ); void input.catch(() => {}); - for (let attempt = 0; attempt < 100; attempt += 1) { - if ((await harness.methods()).includes("session/prompt")) { - break; - } - await Bun.sleep(5); - } + await waitForAcpTestCondition( + async () => (await harness.methods()).includes("session/prompt"), + "ACP session/prompt request", + ); expect(await harness.methods()).toContain("session/prompt"); await expect( @@ -1393,8 +2906,8 @@ describe("ACP driver backend lifecycle", () => { })), ).toEqual([ { kind: "tool.call.updated", status: "running" }, - { kind: "tool.call.updated", status: "failed" }, - { kind: "item.completed", status: "failed" }, + { kind: "tool.call.updated", status: "cancelled" }, + { kind: "item.completed", status: "cancelled" }, { kind: "run.cancelled", status: undefined }, ]); } finally { @@ -1439,6 +2952,61 @@ describe("ACP driver backend lifecycle", () => { } }); + test("joins a successful stop retry before settling the active turn", async () => { + const harness = await createHarness(); + + try { + const input = harness.backend.handleInput( + harness.context, + { text: "hang" }, + DRIVER_TEST_IDS.runId as RunId, + ); + void input.catch(() => {}); + await waitForAcpTestCondition( + async () => (await harness.methods()).includes("session/prompt"), + "ACP session/prompt request", + ); + + const gate = harness.blockNext("usage.updated"); + const stop = harness.backend.stop( + harness.context, + "test failed stop", + new AbortController().signal, + ); + await gate.entered; + await expect(stop).rejects.toThrow("ACP update drain"); + expect( + harness.events.some( + (event) => + event.kind === "run.cancelled" || + event.kind === "run.completed" || + event.kind === "run.failed", + ), + ).toBe(false); + + const retryStop = harness.backend.stop( + harness.context, + "test retry stop", + new AbortController().signal, + ); + gate.release(); + await expect(input).rejects.toThrow("cancelled"); + expect( + harness.events + .filter( + (event) => + event.kind === "run.cancelled" || + event.kind === "run.completed" || + event.kind === "run.failed", + ) + .map((event) => event.kind), + ).toEqual(["run.cancelled"]); + await expect(retryStop).resolves.toBeUndefined(); + } finally { + await harness.destroy(); + } + }, 10_000); + test("shares one bounded stop budget across a stuck close and update drain", async () => { const harness = await createHarness({ hangClose: true }); diff --git a/tests/acp-event-translator-lifecycle.test.ts b/tests/acp-event-translator-lifecycle.test.ts index 8d9a723..8bb0e8d 100644 --- a/tests/acp-event-translator-lifecycle.test.ts +++ b/tests/acp-event-translator-lifecycle.test.ts @@ -1,14 +1,30 @@ import { describe, expect, test } from "bun:test"; +import { RequestError } from "@agentclientprotocol/sdk"; +import type { PromptRequest, SessionNotification } from "@agentclientprotocol/sdk"; +import { createAgentDriverContext } from "../src/core/agent-driver-backend"; import { toDriverEventEnvelopes } from "../src/infrastructure/runtime/driver-instance-socket"; +import { createDisabledLogger } from "../src/observability"; import type { DriverEventInput } from "../src/protocol/events"; import type { RunId } from "../src/protocol/id"; +import { AcpAssistantTranscriptState } from "../src/runtimes/acp/acp-assistant-transcript-state"; +import { limitAcpInput } from "../src/runtimes/acp/acp-driver-backend"; +import { toPermissionRequest } from "../src/runtimes/acp/acp-permission-events"; +import { toPromptStartEvents } from "../src/runtimes/acp/acp-session-events"; import { - AcpTurnEventState, - toPermissionRequest, - toPermissionResolvedEvent, -} from "../src/runtimes/acp/acp-event-translator"; -import { DRIVER_TEST_IDS, driverBootPayload } from "./driver-boot-payload-fixture"; + MAX_RUN_TERMINAL_BATCH_BYTES, + MAX_RUN_TERMINAL_BATCH_EVENTS, + preflightDriverEventPush, +} from "../src/runtimes/driver-event-admission"; +import { DriverEventPublisher } from "../src/runtimes/driver-event-publisher"; +import { CMA_MAX_EVENT_BYTES } from "../src/stores/cma-store"; +import { createCmaMemoryStore } from "../src/stores/memory"; +import { + DRIVER_TEST_IDS, + driverBootPayload, + driverStartInput, +} from "./driver-boot-payload-fixture"; +import { beginAcpTranscript } from "./acp-test-helpers"; const RUN_ID = "run-1" as RunId; const SECOND_RUN_ID = "run-2" as RunId; @@ -17,6 +33,10 @@ function eventKinds(events: readonly DriverEventInput[]): string[] { return events.map((event) => event.kind); } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + function eventPayload(event: DriverEventInput): Record { expect(event.payload).toBeObject(); return event.payload as Record; @@ -43,13 +63,232 @@ function requireEvent(events: readonly DriverEventInput[], kind: string): Driver } describe("ACP runtime event translation", () => { + test("bounds an official ACP JSON-RPC failure across every terminal event", async () => { + const originalMessage = "x".repeat(1_100_000); + const wire = `${JSON.stringify({ + error: { code: -32_603, message: originalMessage }, + id: 1, + jsonrpc: "2.0", + })}\n`; + const decoded = JSON.parse( + await new Response(limitAcpInput(new Blob([wire]).stream())).text(), + ) as unknown; + if (!isRecord(decoded) || decoded["id"] !== 1 || decoded["jsonrpc"] !== "2.0") { + throw new Error("Expected an official ACP response envelope."); + } + const error = decoded["error"]; + + if (!isRecord(error) || error["code"] !== -32_603 || typeof error["message"] !== "string") { + throw new Error("Expected an official ACP error response."); + } + + const requestError = new RequestError(error["code"], error["message"], error["data"]); + const state = new AcpAssistantTranscriptState(); + state.begin({ + messageId: "message-1", + runId: DRIVER_TEST_IDS.runId, + }); + state.translateUpdate({ + update: { + content: { text: "partial", type: "text" }, + messageId: "native-message-1", + sessionUpdate: "agent_message_chunk", + }, + }); + state.translateUpdate({ + update: { + content: { text: "thought", type: "text" }, + messageId: "native-message-1", + sessionUpdate: "agent_thought_chunk", + }, + }); + state.translateUpdate({ + update: { + sessionUpdate: "tool_call", + status: "in_progress", + title: "Run command", + toolCallId: "tool-1", + }, + }); + const events = state.failPrompt({ code: "acp.turn_failed", message: requestError.message }); + const terminal = events.at(-1)!; + const closures = events.slice(0, -1); + const store = createCmaMemoryStore({ sessions: [{ id: DRIVER_TEST_IDS.sessionId }] }); + const canonicalEvents: DriverEventInput[] = []; + let cmaRecordCount = 0; + const logger = createDisabledLogger(); + let sequence = 0; + const context = createAgentDriverContext({ + eventSink: { + currentRunId: () => DRIVER_TEST_IDS.runId, + pushEvents: async ({ events: drafts }) => { + const envelopes = drafts.flatMap((draft) => + toDriverEventEnvelopes(driverBootPayload, draft, DRIVER_TEST_IDS.runId), + ); + + for (const envelope of envelopes) { + canonicalEvents.push(envelope.event); + cmaRecordCount += ( + await store.appendDriverEvent(DRIVER_TEST_IDS.sessionId, envelope.event) + ).length; + } + + return { + accepted: envelopes.map((envelope) => ({ + eventId: envelope.eventId, + seq: ++sequence, + type: envelope.event.kind, + })), + }; + }, + }, + logger, + payload: driverStartInput, + permission: { request: async () => "reject_once" }, + }); + + await new DriverEventPublisher("acp-fallback", () => "native-session-1").pushTerminal( + context, + "driver.acp.prompt.failed", + closures, + terminal, + ); + + const boundedMessage = + "ACP failure exceeded durable event capacity (originalMessageUtf8Bytes=1100000)."; + const closureMessages = events.flatMap((event): string[] => { + const payload = eventPayload(event); + const error = payload["error"]; + + if (typeof error === "string") { + return [error]; + } + if (typeof error === "object" && error !== null && "message" in error) { + return [String(error.message)]; + } + return typeof payload["reason"] === "string" ? [payload["reason"]] : []; + }); + const runError = eventPayload(terminal)["error"] as Record; + + expect(new Set(closureMessages)).toEqual(new Set([boundedMessage])); + expect(runError["details"]).toEqual({ originalMessageUtf8Bytes: 1_100_000 }); + expect(canonicalEvents).toHaveLength(5); + expect(cmaRecordCount).toBe(4); + expect(canonicalEvents.map((event) => event.kind).at(-1)).toBe("run.failed"); + expect( + canonicalEvents.every( + (event) => Buffer.byteLength(JSON.stringify(event), "utf8") < CMA_MAX_EVENT_BYTES, + ), + ).toBe(true); + expect(JSON.stringify(canonicalEvents)).not.toContain(originalMessage); + }); + + test("bounds official ACP prompt text before provider dispatch", async () => { + const promptText = (text: string): string => { + const prompt = { + prompt: [{ text, type: "text" }], + sessionId: "native-session-1", + } satisfies PromptRequest; + + return prompt.prompt[0]!.text; + }; + + expect(() => + toPromptStartEvents({ + messageId: "message-1", + runId: DRIVER_TEST_IDS.runId, + text: promptText("x".repeat(1_100_000)), + }), + ).toThrow("ACP message.added event exceeds 524288 UTF-8 bytes"); + + const events = toPromptStartEvents({ + messageId: "message-1", + runId: DRIVER_TEST_IDS.runId, + text: promptText("x".repeat(500_000)), + }); + const store = createCmaMemoryStore({ sessions: [{ id: DRIVER_TEST_IDS.sessionId }] }); + let recordCount = 0; + + expect(events.map((event) => event.kind)).toEqual([ + "message.added", + "run.dispatched", + "run.started", + ]); + + for (const event of events) { + for (const { event: envelope } of toDriverEventEnvelopes( + driverBootPayload, + event, + DRIVER_TEST_IDS.runId, + )) { + const records = await store.appendDriverEvent(DRIVER_TEST_IDS.sessionId, envelope); + expect(records).toBeArray(); + recordCount += records.length; + } + } + expect(recordCount).toBeGreaterThan(0); + }); + + test("normalizes official ACP empty chunks and tool titles before canonical ingress", () => { + const state = new AcpAssistantTranscriptState(); + state.begin({ + messageId: "message-1", + runId: DRIVER_TEST_IDS.runId, + }); + + for (const sessionUpdate of ["agent_message_chunk", "agent_thought_chunk"] as const) { + const notification = { + sessionId: "native-session-1", + update: { content: { text: "", type: "text" }, sessionUpdate }, + } satisfies SessionNotification; + expect(state.translateUpdate(notification)).toEqual([]); + } + + const message = state.translateUpdate({ + sessionId: "native-session-1", + update: { + content: { text: "ok", type: "text" }, + sessionUpdate: "agent_message_chunk", + }, + } satisfies SessionNotification); + expect(eventKinds(message)).toEqual(["message.started", "message.delta"]); + expect(message[1]?.sourceEventId).toBe(`acp:${DRIVER_TEST_IDS.runId}:agent-message:1`); + + for (const sessionUpdate of ["tool_call", "tool_call_update"] as const) { + const notification = + sessionUpdate === "tool_call" + ? ({ + sessionId: "native-session-1", + update: { + kind: "execute", + sessionUpdate, + title: "", + toolCallId: sessionUpdate, + }, + } satisfies SessionNotification) + : ({ + sessionId: "native-session-1", + update: { sessionUpdate, title: "", toolCallId: sessionUpdate }, + } satisfies SessionNotification); + const events = state.translateUpdate(notification); + const update = requireEvent(events, "tool.call.updated"); + + expect(eventPayload(requireEvent(events, "item.started"))["title"]).toBe( + sessionUpdate === "tool_call" ? "execute" : "tool", + ); + expect(eventPayload(update)).not.toHaveProperty("title"); + expect(() => + toDriverEventEnvelopes(driverBootPayload, update, DRIVER_TEST_IDS.runId), + ).not.toThrow(); + } + }); + test("keeps native assistant messages separate across tools and projects only the final one", () => { - const state = new AcpTurnEventState(); + const state = new AcpAssistantTranscriptState(); state.begin({ messageId: "prompt-message-1", runId: RUN_ID, - sessionId: "session-1", }); const progressOne = "进度 1:正在读取上游报告。"; @@ -139,23 +378,158 @@ describe("ACP runtime event translation", () => { expect(eventPayload(toolStarted)).toMatchObject({ parentMessageId: progressOneId, }); - expect(eventPayload(completed)).toMatchObject({ - finalMessageId, - finalMessageText: finalText, + expect(eventPayload(completed)).toEqual({ finalMessageId, stopReason: "end_turn" }); + expect( + events.find( + (event) => + event.kind === "message.added" && eventPayload(event)["messageId"] === finalMessageId, + )?.payload, + ).toMatchObject({ content: finalText }); + const finalSnapshotIndex = events.findIndex( + (event) => + event.kind === "message.added" && eventPayload(event)["messageId"] === finalMessageId, + ); + const finalSealIndex = events.findIndex( + (event) => + event.kind === "message.completed" && eventPayload(event)["messageId"] === finalMessageId, + ); + expect(finalSealIndex).toBeGreaterThan(finalSnapshotIndex); + expect(events.indexOf(completed)).toBeGreaterThan(finalSealIndex); + }); + + test("evicts bounded settled assistant IDs without suppressing the oldest message", () => { + const state = beginAcpTranscript(); + + const first = state.translateUpdate({ + update: { + content: { text: "chunk-0", type: "text" }, + messageId: "native-0", + sessionUpdate: "agent_message_chunk", + }, + }); + const firstRuntimeMessageId = eventPayloadString( + requireEvent(first, "message.started"), + "messageId", + ); + + for (let index = 1; index < 1_026; index += 1) { + state.translateUpdate({ + update: { + content: { text: `chunk-${index}`, type: "text" }, + messageId: `native-${index}`, + sessionUpdate: "agent_message_chunk", + }, + }); + } + + const replayAfterEviction = state.translateUpdate({ + update: { + content: { text: "oldest accepted again", type: "text" }, + messageId: "native-0", + sessionUpdate: "agent_message_chunk", + }, + }); + + expect(eventKinds(replayAfterEviction)).toEqual([ + "message.added", + "message.completed", + "message.started", + "message.delta", + ]); + expect( + eventPayloadString(requireEvent(replayAfterEviction, "message.started"), "messageId"), + ).not.toBe(firstRuntimeMessageId); + expect( + eventPayloadString(requireEvent(replayAfterEviction, "message.delta"), "contentDelta"), + ).toBe("oldest accepted again"); + }); + + test("keeps the maximum admitted assistant text inside the shared terminal budget", () => { + const state = beginAcpTranscript(); + const content = "x".repeat(8 * 1_024); + + for (let index = 0; index < 47; index += 1) { + state.translateUpdate({ + update: { + content: { text: content, type: "text" }, + messageId: "native-final", + sessionUpdate: "agent_message_chunk", + }, + }); + } + const terminal = state.completePrompt("end_turn", null); + + expect(() => preflightDriverEventPush(terminal, RUN_ID)).not.toThrow(); + expect(Buffer.byteLength(JSON.stringify(terminal), "utf8")).toBeLessThan( + MAX_RUN_TERMINAL_BATCH_BYTES, + ); + }); + + test("keeps the maximum retained item closures inside the shared terminal budget", () => { + const state = beginAcpTranscript(); + + for (let index = 0; index < 509; index += 1) { + state.translateUpdate({ + sessionId: "native-session-1", + update: { + sessionUpdate: "tool_call", + status: "running", + title: "tool", + toolCallId: `tool-${index}-${"x".repeat(642)}`, + }, + }); + } + state.translateUpdate({ + update: { + content: { text: "final", type: "text" }, + messageId: "native-final", + sessionUpdate: "agent_thought_chunk", + }, + }); + const terminal = state.completePrompt("end_turn", { totalTokens: 1 }); + + expect(terminal).toHaveLength(MAX_RUN_TERMINAL_BATCH_EVENTS); + expect(() => preflightDriverEventPush(terminal, RUN_ID)).not.toThrow(); + }); + + test("keeps prompt usage extensions out of the lossless terminal budget", () => { + const state = beginAcpTranscript(); + const fallbackToolId = "request-".repeat(40_000); + state.translatePermission({ + params: { + options: [{ kind: "allow_once", name: "Allow", optionId: "allow" }], + toolCall: { status: "in_progress" }, + }, + requestId: fallbackToolId, + }); + + const terminal = state.completePrompt("end_turn", { + _meta: { padding: "x".repeat(410_000) }, + inputTokens: 1, + outputTokens: 1, + totalTokens: 2, }); - expect(eventPayloadString(completed, "finalMessageText")).not.toContain(progressOne); - expect(new TextEncoder().encode(eventPayloadString(completed, "finalMessageText"))).toEqual( - new TextEncoder().encode(finalText), + const usage = eventPayload(requireEvent(terminal, "usage.updated")); + + expect(usage).toEqual({ + inputTokens: 1, + outputTokens: 1, + source: "prompt_response", + totalTokens: 2, + usageContract: "anthropic_bucketed", + }); + expect(() => preflightDriverEventPush(terminal, RUN_ID)).not.toThrow(); + expect(Buffer.byteLength(JSON.stringify(terminal), "utf8")).toBeLessThan( + MAX_RUN_TERMINAL_BATCH_BYTES, ); }); test("uses a later identified final after anonymous progress, but fails closed for an anonymous final", () => { - const state = new AcpTurnEventState(); + const state = new AcpAssistantTranscriptState(); state.begin({ messageId: "prompt-message-1", runId: RUN_ID, - sessionId: "session-1", }); const identifiedFinalText = "最终回答:native identity 使它可安全成为 canonical final。"; @@ -185,17 +559,20 @@ describe("ACP runtime event translation", () => { "message.delta", ); - expect(eventPayload(completed)).toMatchObject({ - finalMessageId: eventPayloadString(finalDelta, "messageId"), - finalMessageText: identifiedFinalText, - }); + const finalMessageId = eventPayloadString(finalDelta, "messageId"); + expect(eventPayload(completed)).toEqual({ finalMessageId, stopReason: "end_turn" }); + expect( + events.find( + (event) => + event.kind === "message.added" && eventPayload(event)["messageId"] === finalMessageId, + )?.payload, + ).toMatchObject({ content: identifiedFinalText }); - const anonymousFinalState = new AcpTurnEventState(); + const anonymousFinalState = new AcpAssistantTranscriptState(); anonymousFinalState.begin({ messageId: "prompt-message-2", runId: SECOND_RUN_ID, - sessionId: "session-1", }); const anonymousFinalEvents = [ ...anonymousFinalState.translateUpdate({ @@ -243,6 +620,7 @@ describe("ACP runtime event translation", () => { expect(eventPayload(anonymousFailed)).toMatchObject({ error: { code: "acp.empty_turn", + retryable: true, }, recoverable: true, stopReason: "end_turn", @@ -250,13 +628,7 @@ describe("ACP runtime event translation", () => { }); test("maps ACP turn updates onto canonical runtime events with one tool lifecycle", () => { - const state = new AcpTurnEventState(); - - state.begin({ - messageId: "message-1", - runId: RUN_ID, - sessionId: "session-1", - }); + const state = beginAcpTranscript(); const events = [ ...state.translateUpdate({ @@ -306,6 +678,7 @@ describe("ACP runtime event translation", () => { "tool.call.updated", "tool.call.updated", "item.completed", + "message.added", "message.completed", "usage.updated", "run.completed", @@ -316,8 +689,7 @@ describe("ACP runtime event translation", () => { }); test("projects an execute tool with a nonzero raw exit as failed", () => { - const state = new AcpTurnEventState(); - state.begin({ messageId: "message-1", runId: RUN_ID, sessionId: "session-1" }); + const state = beginAcpTranscript(); state.translateUpdate({ update: { kind: "execute", @@ -346,14 +718,107 @@ describe("ACP runtime event translation", () => { }); expect(eventPayload(requireEvent(events, "item.completed"))).toMatchObject({ itemId: "tool-1", - result: { metadata: { exit: 7 } }, status: "failed", }); + expect(eventPayload(requireEvent(events, "item.completed"))).not.toHaveProperty("result"); + }); + + test("deduplicates identical ACP tool content and fails closed on oversized output", () => { + const state = beginAcpTranscript(); + state.translateUpdate({ + update: { + kind: "execute", + sessionUpdate: "tool_call", + status: "in_progress", + toolCallId: "tool-1", + }, + }); + + expect(() => + state.translateUpdate({ + update: { + rawOutput: "x".repeat(400_000), + sessionUpdate: "tool_call_update", + status: "in_progress", + toolCallId: "tool-1", + }, + }), + ).toThrow("ACP turn state exceeds 393216 retained UTF-8 bytes"); + + const events = state.translateUpdate({ + update: { + content: { text: "done", type: "text" }, + rawOutput: "done", + sessionUpdate: "tool_call_update", + status: "completed", + toolCallId: "tool-1", + }, + }); + const update = eventPayload(requireEvent(events, "tool.call.updated")); + + expect(update).toMatchObject({ content: "done", status: "completed" }); + expect(update).not.toHaveProperty("rawOutput"); + expect(eventPayload(requireEvent(events, "item.completed"))).not.toHaveProperty("result"); + }); + + test("fails closed on an oversized permission tool payload", () => { + const state = beginAcpTranscript(); + + expect(() => + state.translatePermission({ + params: { + options: [{ kind: "allow_once", name: "Allow", optionId: "allow" }], + toolCall: { + rawInput: "x".repeat(400_000), + status: "in_progress", + toolCallId: "tool-1", + }, + }, + requestId: "request-1", + }), + ).toThrow("ACP turn state exceeds 393216 retained UTF-8 bytes"); + + expect( + state + .translatePermission({ + params: { + options: [{ kind: "allow_once", name: "Allow", optionId: "allow" }], + toolCall: { status: "in_progress", toolCallId: "tool-1" }, + }, + requestId: "request-1", + }) + .events.map((event) => event.kind), + ).toEqual(["message.started", "item.started", "tool.call.updated"]); + }); + + test("accounts for a permission request ID retained as the fallback tool ID", () => { + const state = beginAcpTranscript(); + + expect(() => + state.translatePermission({ + params: { + options: [{ kind: "allow_once", name: "Allow", optionId: "allow" }], + toolCall: { status: "in_progress", title: "Run command" }, + }, + requestId: "r".repeat(400_000), + }), + ).toThrow("ACP turn state exceeds 393216 retained UTF-8 bytes"); + + expect( + state + .translatePermission({ + params: { + options: [{ kind: "allow_once", name: "Allow", optionId: "allow" }], + toolCall: { status: "in_progress", title: "Run command" }, + }, + requestId: "request-1", + }) + .events.map((event) => event.kind), + ).toEqual(["message.started", "item.started", "tool.call.updated"]); }); test("keeps a nonzero execute exit across partial updates", () => { - const state = new AcpTurnEventState(); - state.begin({ messageId: "message-1", runId: RUN_ID, sessionId: "session-1" }); + const state = beginAcpTranscript(); state.translateUpdate({ update: { kind: "execute", @@ -394,12 +859,12 @@ describe("ACP runtime event translation", () => { }); test("does not reset tool identity fields omitted by a partial update", () => { - const state = new AcpTurnEventState(); - state.begin({ messageId: "message-1", runId: RUN_ID, sessionId: "session-1" }); + const state = beginAcpTranscript(); const started = state.translateUpdate({ update: { kind: "shell", + name: "Bash", sessionUpdate: "tool_call", status: "running", title: "Run command", @@ -408,6 +873,7 @@ describe("ACP runtime event translation", () => { }); const patched = state.translateUpdate({ update: { + name: "Shell", rawOutput: { text: "done" }, sessionUpdate: "tool_call_update", status: "completed", @@ -417,8 +883,8 @@ describe("ACP runtime event translation", () => { const initialPayload = eventPayload(requireEvent(started, "tool.call.updated")); const patchPayload = eventPayload(requireEvent(patched, "tool.call.updated")); - expect(initialPayload).toMatchObject({ kind: "shell", title: "Run command" }); - expect(patchPayload).toMatchObject({ kind: "shell", title: "Run command" }); + expect(initialPayload).toMatchObject({ kind: "shell", name: "Bash", title: "Run command" }); + expect(patchPayload).toMatchObject({ kind: "shell", name: "Shell", title: "Run command" }); }); test.each([ @@ -437,8 +903,7 @@ describe("ACP runtime event translation", () => { ] as const)( "keeps a terminal tool completed while merging a later %s patch", (_name, patch, expected) => { - const state = new AcpTurnEventState(); - state.begin({ messageId: "message-1", runId: RUN_ID, sessionId: "session-1" }); + const state = beginAcpTranscript(); const initial = state.translateUpdate({ update: { kind: "shell", @@ -482,6 +947,36 @@ describe("ACP runtime event translation", () => { }, ); + test("evicts completed replay history only after projecting its late update", () => { + const state = beginAcpTranscript(); + + for (const toolCallId of ["tool-0", "tool-1"]) { + state.translateUpdate({ + update: { + rawInput: "x".repeat(200_000), + sessionUpdate: "tool_call", + status: "completed", + toolCallId, + }, + }); + } + + const events = state.translateUpdate({ + update: { + rawOutput: "y".repeat(200_000), + sessionUpdate: "tool_call_update", + status: "running", + toolCallId: "tool-0", + }, + }); + + expect(eventKinds(events)).toEqual(["tool.call.updated"]); + expect(eventPayload(events[0]!)).toMatchObject({ + status: "completed", + toolCallId: "tool-0", + }); + }); + test.each([ ["completed", "running", "without content"], ["completed", "running", "with content"], @@ -494,8 +989,7 @@ describe("ACP runtime event translation", () => { ] as const)( "keeps the first %s status across a late %s update %s", (initialStatus, lateStatus, contentCase) => { - const state = new AcpTurnEventState(); - state.begin({ messageId: "message-1", runId: RUN_ID, sessionId: "session-1" }); + const state = beginAcpTranscript(); state.translateUpdate({ update: { sessionUpdate: "tool_call", @@ -530,8 +1024,7 @@ describe("ACP runtime event translation", () => { ); test("emits an empty plan as a full replacement", () => { - const state = new AcpTurnEventState(); - state.begin({ messageId: "message-1", runId: RUN_ID, sessionId: "session-1" }); + const state = beginAcpTranscript(); expect( state.translateUpdate({ @@ -546,12 +1039,11 @@ describe("ACP runtime event translation", () => { }); test("omits empty ACP tool input before runtime event ingress", () => { - const state = new AcpTurnEventState(); + const state = new AcpAssistantTranscriptState(); state.begin({ messageId: "message-1", runId: DRIVER_TEST_IDS.runId, - sessionId: DRIVER_TEST_IDS.sessionId, }); const events = state.translateUpdate({ @@ -586,7 +1078,7 @@ describe("ACP runtime event translation", () => { expect(eventPayload(canonicalToolEvent as DriverEventInput)).not.toHaveProperty("rawInput"); }); - test("preserves the ACP request id across permission request and resolution events", () => { + test("translates ACP permission metadata without duplicating host lifecycle events", () => { const translation = toPermissionRequest({ params: { options: [ @@ -604,32 +1096,25 @@ describe("ACP runtime event translation", () => { runId: RUN_ID, }); - const permissionEvent = translation.events.find( - (event) => event.kind === "permission.requested", - ); - - expect(permissionEvent).toBeDefined(); - expect(translation.requestId).toBe("rpc-42"); - expect(translation.defaultOptionId).toBe("allow"); - expect(eventPayload(permissionEvent as DriverEventInput)).toMatchObject({ - defaultOptionId: "allow", + expect(translation.events.map((event) => event.kind)).toEqual(["tool.call.updated"]); + expect(translation.request).toEqual({ + rawInput: '{"command":"pwd"}', requestId: "rpc-42", - targetItemId: "tool-1", title: "Run command", + toolCallId: "tool-1", + toolKind: "shell", }); + expect(translation.options).toEqual([ + { kind: "allow_once", name: "Allow once", optionId: "allow" }, + { kind: "reject_once", name: "Reject once", optionId: "reject" }, + ]); - const resolved = toPermissionResolvedEvent({ - option: translation.options[0] ?? null, - requestId: translation.requestId, - runId: RUN_ID, - }); - - expect(resolved.kind).toBe("permission.resolved"); - expect(eventPayload(resolved)).toMatchObject({ - optionId: "allow", - optionKind: "allow_once", - outcome: "selected", - requestId: "rpc-42", - }); + expect( + toPermissionRequest({ + params: { toolCall: { kind: "shell", title: "Run command" } }, + requestId: "rpc-fallback", + runId: RUN_ID, + }).request.toolCallId, + ).toBe("rpc-fallback"); }); }); diff --git a/tests/acp-event-translator-session-update.test.ts b/tests/acp-event-translator-session-update.test.ts index 88095fa..0090297 100644 --- a/tests/acp-event-translator-session-update.test.ts +++ b/tests/acp-event-translator-session-update.test.ts @@ -2,7 +2,13 @@ import { describe, expect, test } from "bun:test"; import type { DriverEventInput } from "../src/protocol/events"; import type { RunId } from "../src/protocol/id"; -import { AcpTurnEventState, toSessionReadyEvents } from "../src/runtimes/acp/acp-event-translator"; +import { AcpAssistantTranscriptState } from "../src/runtimes/acp/acp-assistant-transcript-state"; +import { + toAuthEvent, + toInitializeEvents, + toSessionReadyEvents, +} from "../src/runtimes/acp/acp-session-events"; +import { beginAcpTranscript } from "./acp-test-helpers"; const RUN_ID = "run-1" as RunId; @@ -37,13 +43,7 @@ function requireEvent(events: readonly DriverEventInput[], kind: string): Driver describe("ACP runtime event translation", () => { test("starts permission tool calls through the turn event state", () => { - const state = new AcpTurnEventState(); - - state.begin({ - messageId: "message-1", - runId: RUN_ID, - sessionId: "session-1", - }); + const state = beginAcpTranscript({ runId: RUN_ID }); const translation = state.translatePermission({ params: { @@ -64,7 +64,6 @@ describe("ACP runtime event translation", () => { "message.started", "item.started", "tool.call.updated", - "permission.requested", "message.completed", "tool.call.updated", "item.completed", @@ -73,13 +72,7 @@ describe("ACP runtime event translation", () => { }); test("closes unfinished tool calls when a turn completes", () => { - const state = new AcpTurnEventState(); - - state.begin({ - messageId: "message-1", - runId: RUN_ID, - sessionId: "session-1", - }); + const state = beginAcpTranscript({ runId: RUN_ID }); const events = [ ...state.translateUpdate({ @@ -112,14 +105,8 @@ describe("ACP runtime event translation", () => { }); }); - test("maps max turn request stops to completed limited runs", () => { - const state = new AcpTurnEventState(); - - state.begin({ - messageId: "message-1", - runId: RUN_ID, - sessionId: "session-1", - }); + test("fails open items when max turn requests stops a prompt", () => { + const state = beginAcpTranscript({ runId: RUN_ID }); const events = [ ...state.translateUpdate({ @@ -137,32 +124,79 @@ describe("ACP runtime event translation", () => { "message.started", "item.started", "tool.call.updated", - "message.completed", + "message.failed", "tool.call.updated", "item.completed", - "run.completed", + "run.failed", ]); expect(eventPayload(events[4]!)).toMatchObject({ - status: "completed", + error: "ACP prompt stopped with max_turn_requests.", + status: "failed", toolCallId: "tool-1", }); expect(eventPayload(events[5]!)).toMatchObject({ + error: "ACP prompt stopped with max_turn_requests.", itemId: "tool-1", - status: "completed", + status: "failed", }); expect(eventPayload(events[6]!)).toMatchObject({ + error: { + code: "acp.max_turn_requests", + message: "ACP prompt stopped with max_turn_requests.", + retryable: false, + }, + recoverable: false, stopReason: "max_turn_requests", }); }); - test("fails an empty end turn instead of reporting a blank completed run", () => { - const state = new AcpTurnEventState(); + test("cancels every open item when the prompt is cancelled", () => { + const state = beginAcpTranscript({ runId: RUN_ID }); + const events = [ + ...state.translateUpdate({ + update: { + content: { text: "partial answer", type: "text" }, + messageId: "native-message-1", + sessionUpdate: "agent_message_chunk", + }, + }), + ...state.translateUpdate({ + update: { + content: { text: "partial thought", type: "text" }, + messageId: "native-message-1", + sessionUpdate: "agent_thought_chunk", + }, + }), + ...state.translateUpdate({ + update: { + sessionUpdate: "tool_call", + status: "running", + title: "Run command", + toolCallId: "tool-1", + }, + }), + ...state.completePrompt("cancelled", null), + ]; - state.begin({ - messageId: "message-1", - runId: RUN_ID, - sessionId: "session-1", - }); + expect(eventKinds(events)).toContain("message.cancelled"); + expect(eventKinds(events)).toContain("thought.cancelled"); + expect(eventKinds(events)).toContain("run.cancelled"); + expect( + events.some( + (event) => + event.kind === "tool.call.updated" && eventPayload(event)["status"] === "cancelled", + ), + ).toBe(true); + expect( + events.some( + (event) => event.kind === "item.completed" && eventPayload(event)["status"] === "cancelled", + ), + ).toBe(true); + expect(eventKinds(events)).not.toContain("message.completed"); + }); + + test("fails an empty end turn instead of reporting a blank completed run", () => { + const state = beginAcpTranscript({ runId: RUN_ID }); const events = state.completePrompt("end_turn", { inputTokens: 0, @@ -175,6 +209,7 @@ describe("ACP runtime event translation", () => { error: { code: "acp.empty_turn", message: "ACP prompt ended without assistant output or tool activity.", + retryable: true, }, recoverable: true, stopReason: "end_turn", @@ -182,13 +217,7 @@ describe("ACP runtime event translation", () => { }); test("ignores ACP user message echo chunks because driver input is the source of truth", () => { - const state = new AcpTurnEventState(); - - state.begin({ - messageId: "message-1", - runId: RUN_ID, - sessionId: "session-1", - }); + const state = beginAcpTranscript({ runId: RUN_ID }); expect( state.translateUpdate({ @@ -204,15 +233,9 @@ describe("ACP runtime event translation", () => { }); test("promotes message-scoped thought-only ACP output into an assistant message", () => { - const state = new AcpTurnEventState(); - - state.begin({ - messageId: "message-1", - runId: RUN_ID, - sessionId: "session-1", - }); + const state = beginAcpTranscript({ runId: RUN_ID }); - const events = [ + const streamed = [ ...state.translateUpdate({ update: { content: { @@ -233,15 +256,16 @@ describe("ACP runtime event translation", () => { sessionUpdate: "agent_thought_chunk", }, }), - ...state.completePrompt("end_turn", null), ]; + const terminal = state.completePrompt("end_turn", null); + const events = [...streamed, ...terminal]; expect(eventKinds(events)).toEqual([ "thought.started", "thought.delta", "thought.delta", "message.started", - "message.delta", + "message.added", "message.completed", "thought.completed", "run.completed", @@ -249,24 +273,18 @@ describe("ACP runtime event translation", () => { const fallbackMessageId = eventPayloadString(events[3]!, "messageId"); expect(eventPayload(events[4]!)).toMatchObject({ - contentDelta: "\npong\n", + content: "\npong\n", messageId: fallbackMessageId, - role: "agent", }); - expect(eventPayload(events[7]!)).toMatchObject({ + expect(eventPayload(events[7]!)).toEqual({ finalMessageId: fallbackMessageId, - finalMessageText: "\npong\n", + stopReason: "end_turn", }); + expect(terminal.every((event) => event.delivery !== "best_effort")).toBe(true); }); test("treats a different native thought as the final assistant message boundary", () => { - const state = new AcpTurnEventState(); - - state.begin({ - messageId: "message-1", - runId: RUN_ID, - sessionId: "session-1", - }); + const state = beginAcpTranscript({ runId: RUN_ID }); const progressText = "PROGRESS:正在整理资料。"; const finalText = "FINAL:中文表格与结论均已完成。"; @@ -287,39 +305,43 @@ describe("ACP runtime event translation", () => { }), ...state.completePrompt("end_turn", null), ]; - const messageDeltas = events.filter((event) => event.kind === "message.delta"); - const progressMessageId = eventPayloadString(messageDeltas[0]!, "messageId"); - const finalMessageId = eventPayloadString(messageDeltas[1]!, "messageId"); + const progressMessageId = eventPayloadString( + requireEvent(events, "message.delta"), + "messageId", + ); + const finalMessageId = eventPayloadString( + events.find( + (event) => event.kind === "message.added" && eventPayload(event)["content"] === finalText, + )!, + "messageId", + ); const completed = requireEvent(events, "run.completed"); expect(eventKinds(events)).toEqual([ "message.started", "message.delta", + "message.added", "message.completed", "thought.started", "thought.delta", "message.started", - "message.delta", + "message.added", "message.completed", "thought.completed", "run.completed", ]); expect(progressMessageId).not.toBe(finalMessageId); - expect(eventPayload(completed)).toMatchObject({ - finalMessageId, - finalMessageText: finalText, - }); - expect(eventPayloadString(completed, "finalMessageText")).not.toContain(progressText); + expect(eventPayload(completed)).toEqual({ finalMessageId, stopReason: "end_turn" }); + expect( + events.find( + (event) => + event.kind === "message.added" && eventPayload(event)["messageId"] === finalMessageId, + )?.payload, + ).toMatchObject({ content: finalText }); }); test("fails closed when an anonymous thought follows identified progress", () => { - const state = new AcpTurnEventState(); - - state.begin({ - messageId: "message-1", - runId: RUN_ID, - sessionId: "session-1", - }); + const state = beginAcpTranscript({ runId: RUN_ID }); const events = [ ...state.translateUpdate({ @@ -342,6 +364,7 @@ describe("ACP runtime event translation", () => { expect(eventKinds(events)).toEqual([ "message.started", "message.delta", + "message.added", "message.completed", "thought.started", "thought.delta", @@ -358,13 +381,7 @@ describe("ACP runtime event translation", () => { }); test("closes open stream items before a failed turn event", () => { - const state = new AcpTurnEventState(); - - state.begin({ - messageId: "message-1", - runId: RUN_ID, - sessionId: "session-1", - }); + const state = beginAcpTranscript({ runId: RUN_ID }); const events = [ ...state.translateUpdate({ @@ -395,11 +412,16 @@ describe("ACP runtime event translation", () => { "message.delta", "item.started", "tool.call.updated", - "message.completed", + "message.failed", "tool.call.updated", "item.completed", "run.failed", ]); + expect(eventPayload(requireEvent(events, "run.failed"))).toMatchObject({ + error: { message: "transport closed", retryable: false }, + }); + expect(eventPayload(events[5]!)).not.toHaveProperty("error"); + expect(eventPayload(events[6]!)).not.toHaveProperty("error"); }); test("emits native resume state from ACP session setup", () => { @@ -407,7 +429,10 @@ describe("ACP runtime event translation", () => { mode: "created", nativeSessionId: "native-session-1", setup: { - currentModeId: "default", + modes: { + availableModes: [{ id: "default", name: "Default" }], + currentModeId: "default", + }, }, }); @@ -420,6 +445,11 @@ describe("ACP runtime event translation", () => { expect(events[1]).toMatchObject({ visibility: "owner_debug", }); + expect(events[2]?.delivery).toBe("best_effort"); + expect(eventPayload(events[2]!)).toEqual({ + availableModes: [{ id: "default", name: "Default" }], + currentMode: "default", + }); }); test("normalizes ACP config options to the mosoo session config contract", () => { @@ -447,6 +477,7 @@ describe("ACP runtime event translation", () => { const configEvent = events.find((event) => event.kind === "session.config.updated"); const payload = eventPayload(configEvent as DriverEventInput); + expect(configEvent?.delivery).toBe("best_effort"); expect(payload).toEqual({ options: [ { @@ -523,7 +554,7 @@ describe("ACP runtime event translation", () => { }); test("normalizes ACP commands to the mosoo session commands contract", () => { - const state = new AcpTurnEventState(); + const state = new AcpAssistantTranscriptState(); const events = state.translateUpdate({ update: { availableCommands: [ @@ -551,15 +582,104 @@ describe("ACP runtime event translation", () => { ]); }); - test("normalizes ACP usage sources to the mosoo usage contract", () => { - const state = new AcpTurnEventState(); + test("fails closed on an oversized lossless session snapshot", () => { + const state = new AcpAssistantTranscriptState(); - state.begin({ - messageId: "message-1", - runId: RUN_ID, - sessionId: "session-1", + expect(() => + state.translateUpdate({ + update: { + availableCommands: [{ description: "x".repeat(600_000), name: "huge" }], + sessionUpdate: "available_commands_update", + }, + }), + ).toThrow("ACP session.commands.updated event exceeds 524288 UTF-8 bytes"); + }); + + test("bounds provider IDs without copying the native session ID into source IDs", () => { + const nativeSessionId = "s".repeat(600_000); + + expect(() => toSessionReadyEvents({ mode: "created", nativeSessionId, setup: {} })).toThrow( + "ACP runtime.resume.updated event exceeds 524288 UTF-8 bytes", + ); + expect(() => toAuthEvent({ methodId: nativeSessionId, status: "authenticated" })).toThrow( + "ACP auth.session.updated event exceeds 524288 UTF-8 bytes", + ); + + const state = beginAcpTranscript({ runId: RUN_ID }); + const sourceEventId = state.translateUpdate({ + update: { + content: { text: "hello", type: "text" }, + sessionUpdate: "agent_message_chunk", + }, + })[1]?.sourceEventId; + + expect(sourceEventId).toBe("acp:run-1:agent-message:1"); + }); + + test("keeps unbounded initialize telemetry best effort", () => { + const value = "x".repeat(600_000); + const events = toInitializeEvents({ + agentCapabilities: { _meta: { value } }, + authMethods: [{ id: value, name: value }], + protocolVersion: 1, + } as never); + + expect(events.map((event) => event.delivery)).toEqual(["best_effort", "best_effort"]); + }); + + test("maps ACP 1.3 session timestamps and ignores removed setup aliases", () => { + const state = new AcpAssistantTranscriptState(); + const info = state.translateUpdate({ + update: { + sessionUpdate: "session_info_update", + title: "Session title", + updatedAt: "2026-08-13T00:00:00.000Z", + }, + }); + const legacyReady = toSessionReadyEvents({ + mode: "created", + nativeSessionId: "native-session-1", + setup: { + capabilities: { fileSystem: true }, + currentModeId: "legacy-mode", + currentModel: "legacy-model", + models: ["legacy-model"], + options: [], + providers: ["legacy-provider"], + sessionCapabilities: { legacy: true }, + visibleModes: [], + }, }); + expect(info).toEqual([ + { + kind: "session.info.updated", + payload: { + title: "Session title", + updatedAt: "2026-08-13T00:00:00.000Z", + }, + }, + ]); + expect( + [ + { sessionUpdate: "session_info_update", title: "" }, + { sessionUpdate: "session_info_update", updatedAt: "not-a-time" }, + ].flatMap((update) => state.translateUpdate({ update })), + ).toEqual([]); + expect(eventKinds(legacyReady)).toEqual(["session.created", "runtime.resume.updated"]); + expect( + state.translateUpdate({ + update: { + commands: [{ name: "legacy" }], + sessionUpdate: "available_commands_update", + }, + }), + ).toEqual([]); + }); + + test("normalizes ACP usage sources to the mosoo usage contract", () => { + const state = beginAcpTranscript({ runId: RUN_ID }); + const sessionUsage = state.translateUpdate({ update: { cost: { @@ -600,14 +720,6 @@ describe("ACP runtime event translation", () => { cachedWriteTokens: 7, inputTokens: 10, outputTokens: 2, - raw: { - cachedReadTokens: 90, - cachedWriteTokens: 7, - inputTokens: 10, - outputTokens: 2, - thoughtTokens: 3, - totalTokens: 112, - }, source: "prompt_response", thoughtTokens: 3, totalTokens: 112, @@ -623,8 +735,7 @@ describe("ACP runtime event translation", () => { ["unsafe", Number.MAX_SAFE_INTEGER + 1], ["non-finite", Number.POSITIVE_INFINITY], ] as const)("drops wholly invalid %s usage without blocking Run completion", (_name, value) => { - const state = new AcpTurnEventState(); - state.begin({ messageId: "message-1", runId: RUN_ID, sessionId: "session-1" }); + const state = beginAcpTranscript({ runId: RUN_ID }); state.translateUpdate({ update: { content: { text: "done", type: "text" }, @@ -654,8 +765,7 @@ describe("ACP runtime event translation", () => { }); test("keeps valid usage fields while omitting malformed siblings", () => { - const state = new AcpTurnEventState(); - state.begin({ messageId: "message-1", runId: RUN_ID, sessionId: "session-1" }); + const state = beginAcpTranscript({ runId: RUN_ID }); const [sessionUsage] = state.translateUpdate({ update: { diff --git a/tests/acp-file-system.test.ts b/tests/acp-file-system.test.ts index 3a91aa5..bf555c5 100644 --- a/tests/acp-file-system.test.ts +++ b/tests/acp-file-system.test.ts @@ -1,55 +1,78 @@ -import { describe, expect, test } from "bun:test"; -import { mkdtemp, readFile, realpath, rm, symlink, truncate, writeFile } from "node:fs/promises"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import { truncateSync } from "node:fs"; +import { + chmod, + lstat, + mkdir, + mkdtemp, + readFile, + readlink, + readdir, + realpath, + rename, + rm, + stat, + symlink, + truncate, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { AgentDriverContext } from "../src/core/agent-driver-backend"; import { createAgentDriverContext } from "../src/core/agent-driver-backend"; -import { createBufferedSinkLogger } from "../src/observability"; -import type { Logger } from "../src/observability"; +import type { AgentDriverFilePort } from "../src/host-ports"; +import { createDisabledLogger } from "../src/observability"; import type { DriverEventInput } from "../src/protocol/events"; import { isDriverId } from "../src/protocol/id"; import { AcpFileSystem } from "../src/runtimes/acp/acp-file-system"; +import { AcpPathScope } from "../src/runtimes/acp/acp-path-scope"; +import { settlePromiseWithTimeout } from "../src/utils/async"; import { driverStartInput } from "./driver-boot-payload-fixture"; -function createFileSystem(cwd = process.cwd()): AcpFileSystem { - return new AcpFileSystem({ +const pathScopes = new Set(); + +function createFileSystem(cwd = process.cwd(), pathScope?: AcpPathScope): AcpFileSystem { + const scope = pathScope ?? new AcpPathScope({ allowedRoots: [], cwd }); + const fileSystem = new AcpFileSystem({ allowedRoots: [], cwd, + pathScope: scope, }); + pathScopes.add(scope); + return fileSystem; } -function createContext(events: DriverEventInput[]): { - context: AgentDriverContext; - logger: Logger; -} { - const logger = createBufferedSinkLogger({ - level: "debug", - service: "acp-file-system-test", - sink: async () => {}, - }); +afterEach(async () => { + await Promise.all([...pathScopes].map((pathScope) => pathScope.close())); + pathScopes.clear(); +}); - return { - context: createAgentDriverContext({ - eventSink: { - pushEvents: async (input) => { - events.push(...input.events); - return { - accepted: input.events.map((event, index) => ({ - seq: index + 1, - type: event.kind, - })), - }; - }, - }, - logger, - payload: driverStartInput, - permission: { - request: async () => "reject_once", +function createContext( + events: DriverEventInput[], + reportChanged?: AgentDriverFilePort["reportChanged"], +): AgentDriverContext { + return createAgentDriverContext({ + eventSink: { + currentRunId: () => null, + pushEvents: async (input) => { + events.push(...input.events); + return { + accepted: input.events.map((event, index) => ({ + eventId: event.sourceEventId!, + seq: index + 1, + type: event.kind, + })), + }; }, - }), - logger, - }; + }, + logger: createDisabledLogger(), + payload: driverStartInput, + permission: { + request: async () => "reject_once", + }, + ...(reportChanged === undefined ? {} : { ports: { file: { reportChanged } } }), + }); } describe("ACP file system bridge", () => { @@ -91,7 +114,7 @@ describe("ACP file system bridge", () => { const root = await mkdtemp(join(tmpdir(), "driver-acp-fs-")); const path = join(root, "nested", "note.txt"); const events: DriverEventInput[] = []; - const { context, logger } = createContext(events); + const context = createContext(events); try { const fileSystem = createFileSystem(root); @@ -116,7 +139,61 @@ describe("ACP file system bridge", () => { }); expect(isDriverId(events[0]?.sourceEventId)).toBe(true); } finally { - await logger.destroy(); + await rm(root, { force: true, recursive: true }); + } + }); + + test("closes the write directory before bounded committed-file reporting", async () => { + const root = await mkdtemp(join(tmpdir(), "driver-acp-fs-report-")); + const directory = join(root, "nested"); + const path = join(directory, "note.txt"); + const reportEntered = Promise.withResolvers(); + const releaseReport = Promise.withResolvers(); + const reportDeadline = new AbortController(); + const timeout = spyOn(AbortSignal, "timeout").mockReturnValue(reportDeadline.signal); + const request = new AbortController(); + let directoryHandleOpen = true; + let receivedSignal: AbortSignal | undefined; + const context = createContext([], async (_change, signal) => { + receivedSignal = signal; + const directoryPath = await realpath(directory); + const openPaths = await Promise.all( + (await readdir(`/proc/${process.pid}/fd`)).map((fd) => + readlink(`/proc/${process.pid}/fd/${fd}`).catch(() => ""), + ), + ); + directoryHandleOpen = openPaths.includes(directoryPath); + reportEntered.resolve(); + await releaseReport.promise; + }); + const reportAbort = new Error("file report deadline"); + + try { + const write = createFileSystem(root).writeTextFile( + context, + { content: "committed", path }, + request.signal, + ); + await reportEntered.promise; + + expect(directoryHandleOpen).toBe(false); + expect(receivedSignal).toBe(reportDeadline.signal); + expect(receivedSignal).not.toBe(request.signal); + request.abort(new Error("late turn cancellation")); + expect( + await settlePromiseWithTimeout(write, { + label: "blocked ACP file report", + timeoutMs: 10, + }), + ).toMatchObject({ status: "timed_out" }); + + reportDeadline.abort(reportAbort); + await expect(write).rejects.toBe(reportAbort); + expect(await readFile(path, "utf8")).toBe("committed"); + } finally { + reportDeadline.abort(reportAbort); + releaseReport.resolve(); + timeout.mockRestore(); await rm(root, { force: true, recursive: true }); } }); @@ -135,4 +212,223 @@ describe("ACP file system bridge", () => { await rm(root, { force: true, recursive: true }); } }); + + test("reads from the opened capability when an ancestor is exchanged", async () => { + const root = await mkdtemp(join(tmpdir(), "driver-acp-fs-read-race-")); + const outside = await mkdtemp(join(tmpdir(), "driver-acp-fs-read-race-outside-")); + const workspace = join(root, "workspace"); + const retained = join(root, "retained"); + const requestedPath = join(workspace, "note.txt"); + + class ExchangingPathScope extends AcpPathScope { + override async openFile(path: string, label: string) { + const capability = await super.openFile(path, label); + await rename(workspace, retained); + await symlink(outside, workspace); + return capability; + } + } + + try { + await Promise.all([mkdir(workspace), writeFile(join(outside, "note.txt"), "outside")]); + await writeFile(requestedPath, "inside"); + const pathScope = new ExchangingPathScope({ allowedRoots: [], cwd: root }); + const fileSystem = createFileSystem(root, pathScope); + + await expect(fileSystem.readTextFile({ path: requestedPath })).resolves.toEqual({ + content: "inside", + }); + } finally { + await rm(root, { force: true, recursive: true }); + await rm(outside, { force: true, recursive: true }); + } + }); + + test("retains configured root identity after the root path is exchanged", async () => { + const root = await mkdtemp(join(tmpdir(), "driver-acp-fs-root-race-")); + const retained = `${root}-retained`; + const outside = await mkdtemp(join(tmpdir(), "driver-acp-fs-root-race-outside-")); + const requestedRead = join(root, "read.txt"); + const requestedWrite = join(root, "write.txt"); + const events: DriverEventInput[] = []; + const context = createContext(events); + const pathScope = new AcpPathScope({ allowedRoots: [], cwd: root }); + const fileSystem = createFileSystem(root, pathScope); + + try { + await Promise.all([ + writeFile(requestedRead, "inside"), + writeFile(join(outside, "read.txt"), "outside"), + writeFile(join(outside, "write.txt"), "outside"), + ]); + await pathScope.initialize(); + await rename(root, retained); + await symlink(outside, root); + + await expect(fileSystem.readTextFile({ path: requestedRead })).resolves.toEqual({ + content: "inside", + }); + await expect( + fileSystem.writeTextFile(context, { content: "written", path: requestedWrite }), + ).resolves.toEqual({}); + expect(await readFile(join(retained, "write.txt"), "utf8")).toBe("written"); + expect(await readFile(join(outside, "write.txt"), "utf8")).toBe("outside"); + expect(events[0]).toMatchObject({ payload: { path: join(retained, "write.txt") } }); + } finally { + await rm(root, { force: true, recursive: true }); + await rm(retained, { force: true, recursive: true }); + await rm(outside, { force: true, recursive: true }); + } + }); + + test("releases partial root acquisition and can initialize again", async () => { + const base = await mkdtemp(join(tmpdir(), "driver-acp-fs-init-retry-")); + const cwd = join(base, "workspace-long"); + const retained = join(base, "retained"); + const missing = join(base, "x"); + await mkdir(cwd); + const pathScope = new AcpPathScope({ allowedRoots: [missing], cwd }); + + try { + await expect(pathScope.initialize()).rejects.toThrow("ENOENT"); + await rename(cwd, retained); + const targets = await Promise.all( + (await readdir(`/proc/${process.pid}/fd`)).map((fd) => + readlink(`/proc/${process.pid}/fd/${fd}`).catch(() => ""), + ), + ); + expect(targets).not.toContain(retained); + + await Promise.all([mkdir(cwd), mkdir(missing)]); + await writeFile(join(cwd, "note.txt"), "replacement"); + await pathScope.initialize(); + const file = await pathScope.openFile(join(cwd, "note.txt"), "test file"); + try { + expect(await file.file.readFile("utf8")).toBe("replacement"); + } finally { + await file.file.close(); + } + } finally { + await pathScope.close(); + await rm(base, { force: true, recursive: true }); + } + }); + + test("enforces the byte limit when a file grows after fstat", async () => { + const root = await mkdtemp(join(tmpdir(), "driver-acp-fs-grow-race-")); + const path = join(root, "growing.txt"); + let checkpoints = 0; + const growthCheckpoint = { + throwIfAborted() { + checkpoints += 1; + if (checkpoints === 3) { + truncateSync(path, 8 * 1_024 * 1_024 + 1); + } + }, + } as unknown as AbortSignal; + + try { + await writeFile(path, "small"); + await expect(createFileSystem(root).readTextFile({ path }, growthCheckpoint)).rejects.toThrow( + "file exceeds 8388608 bytes", + ); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + test("atomically replaces a leaf symlink through the retained parent capability", async () => { + const root = await mkdtemp(join(tmpdir(), "driver-acp-fs-write-race-")); + const outside = await mkdtemp(join(tmpdir(), "driver-acp-fs-write-race-outside-")); + const workspace = join(root, "workspace"); + const retained = join(root, "retained"); + const requestedPath = join(workspace, "note.txt"); + const outsideLeaf = join(outside, "leaf.txt"); + const outsideAncestor = join(outside, "note.txt"); + const events: DriverEventInput[] = []; + const context = createContext(events); + + class ExchangingPathScope extends AcpPathScope { + override async openWritable(path: string, label: string) { + const capability = await super.openWritable(path, label); + await rename(workspace, retained); + await symlink(outside, workspace); + return capability; + } + } + + try { + await mkdir(workspace); + await Promise.all([ + writeFile(outsideLeaf, "outside leaf"), + writeFile(outsideAncestor, "outside ancestor"), + ]); + await symlink(outsideLeaf, requestedPath); + const pathScope = new ExchangingPathScope({ allowedRoots: [], cwd: root }); + const fileSystem = createFileSystem(root, pathScope); + + await expect( + fileSystem.writeTextFile(context, { content: "inside", path: requestedPath }), + ).resolves.toEqual({}); + expect(await readFile(join(retained, "note.txt"), "utf8")).toBe("inside"); + expect((await lstat(join(retained, "note.txt"))).isSymbolicLink()).toBe(false); + expect(await readFile(outsideLeaf, "utf8")).toBe("outside leaf"); + expect(await readFile(outsideAncestor, "utf8")).toBe("outside ancestor"); + expect(events[0]).toMatchObject({ payload: { path: join(retained, "note.txt") } }); + } finally { + await rm(root, { force: true, recursive: true }); + await rm(outside, { force: true, recursive: true }); + } + }); + + test("preserves an existing regular file mode during atomic replacement", async () => { + const root = await mkdtemp(join(tmpdir(), "driver-acp-fs-mode-")); + const path = join(root, "script.sh"); + const events: DriverEventInput[] = []; + const context = createContext(events); + + try { + await writeFile(path, "old"); + await chmod(path, 0o751); + await createFileSystem(root).writeTextFile(context, { content: "new", path }); + + expect(await readFile(path, "utf8")).toBe("new"); + expect((await stat(path)).mode & 0o777).toBe(0o751); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + test("removes a partial temporary file when writing is aborted", async () => { + const root = await mkdtemp(join(tmpdir(), "driver-acp-fs-abort-")); + const path = join(root, "note.txt"); + const events: DriverEventInput[] = []; + const context = createContext(events); + const aborted = new DOMException("test abort", "AbortError"); + let checkpoints = 0; + const signal = { + throwIfAborted() { + checkpoints += 1; + if (checkpoints === 5) { + throw aborted; + } + }, + } as unknown as AbortSignal; + + try { + await writeFile(path, "before"); + await expect( + createFileSystem(root).writeTextFile( + context, + { content: "x".repeat(512 * 1_024), path }, + signal, + ), + ).rejects.toBe(aborted); + expect(await readFile(path, "utf8")).toBe("before"); + expect((await readdir(root)).filter((entry) => entry.endsWith(".tmp"))).toEqual([]); + expect(events).toEqual([]); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); }); diff --git a/tests/acp-process-supervision.test.ts b/tests/acp-process-supervision.test.ts index d7a1b81..c566c41 100644 --- a/tests/acp-process-supervision.test.ts +++ b/tests/acp-process-supervision.test.ts @@ -81,12 +81,14 @@ const payload = { }; process.env.MOSOO_ACP_FALLBACK_COMMAND = "/bin/sh"; process.env.MOSOO_ACP_FALLBACK_ARGS = JSON.stringify(["-c", ${JSON.stringify(agentSource)}]); -const agent = await startAcpAgentProcess( +const startedAgent = await startAcpAgentProcess( context, payload, { MOSOO_ACP_HOME: ${JSON.stringify(join(directory, "acp-home"))}, PATH: process.env.PATH ?? "" }, new AbortController().signal, ); +const agent = startedAgent.process; +await startedAgent.ready; writeFileSync(${JSON.stringify(paths.agentRoot)}, String(agent.pid)); const terminals = new AcpTerminalManager({ allowedRoots: [], diff --git a/tests/acp-provider-fixtures.test.ts b/tests/acp-provider-fixtures.test.ts index 0c91f7c..8a1378a 100644 --- a/tests/acp-provider-fixtures.test.ts +++ b/tests/acp-provider-fixtures.test.ts @@ -1,40 +1,29 @@ import { describe, expect, test } from "bun:test"; import type { StopReason } from "@agentclientprotocol/sdk"; -import { readFileSync } from "node:fs"; import type { DriverEventInput } from "../src/protocol/events"; -import { AcpTurnEventState, toSessionReadyEvents } from "../src/runtimes/acp/acp-event-translator"; -import type { AcpTurnEventStateInput } from "../src/runtimes/acp/acp-event-translator"; - -interface CompletePromptFixture { - readonly stopReason: StopReason; - readonly usage: unknown; -} - -interface FailPromptFixture { - readonly code: string; - readonly message: string; - readonly recoverable?: boolean | undefined; -} - -interface PermissionRequestFixture { - readonly params: unknown; - readonly requestId: string; -} - -interface SessionReadyFixture { - readonly mode: "created" | "loaded" | "resumed"; - readonly nativeSessionId: string; - readonly setup: Record; -} +import { + AcpAssistantTranscriptState, + type AcpAssistantTranscriptStateInput, +} from "../src/runtimes/acp/acp-assistant-transcript-state"; +import { toSessionReadyEvents } from "../src/runtimes/acp/acp-session-events"; +import { normalizeAcpProviderEvents, readProviderFixture } from "./provider-fixture-test-helpers"; interface AcpProviderFixtureCase { - readonly begin?: AcpTurnEventStateInput | undefined; - readonly completePrompt?: CompletePromptFixture | undefined; + readonly begin?: AcpAssistantTranscriptStateInput | undefined; + readonly completePrompt?: + | { readonly stopReason: StopReason; readonly usage: unknown } + | undefined; readonly expectedEvents: readonly unknown[]; - readonly failPrompt?: FailPromptFixture | undefined; - readonly permissionRequest?: PermissionRequestFixture | undefined; - readonly sessionReady?: SessionReadyFixture | undefined; + readonly failPrompt?: Parameters[0] | undefined; + readonly permissionRequest?: { readonly params: unknown; readonly requestId: string } | undefined; + readonly sessionReady?: + | { + readonly mode: "created" | "loaded" | "resumed"; + readonly nativeSessionId: string; + readonly setup: Record; + } + | undefined; readonly updates: readonly unknown[]; } @@ -46,238 +35,8 @@ const acpFixtureNames = [ "turn-text-tool-usage", ] as const; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readJsonFixture(path: string): unknown { - return JSON.parse(readFileSync(new URL(path, import.meta.url), "utf8")); -} - -function readStringField(record: Record, field: string): string { - const value = record[field]; - - if (typeof value !== "string") { - throw new Error(`ACP provider fixture field ${field} must be a string.`); - } - - return value; -} - -function readRecordField(record: Record, field: string): Record { - const value = record[field]; - - if (!isRecord(value)) { - throw new Error(`ACP provider fixture field ${field} must be an object.`); - } - - return value; -} - -function readBeginFixture(value: unknown): AcpTurnEventStateInput | undefined { - if (value === undefined) { - return undefined; - } - - if (!isRecord(value)) { - throw new Error("ACP provider fixture begin must be an object."); - } - - return { - messageId: readStringField(value, "messageId"), - runId: readStringField(value, "runId") as AcpTurnEventStateInput["runId"], - sessionId: readStringField(value, "sessionId"), - }; -} - -function readCompletePromptFixture(value: unknown): CompletePromptFixture | undefined { - if (value === undefined) { - return undefined; - } - - if (!isRecord(value)) { - throw new Error("ACP provider fixture completePrompt must be an object."); - } - - const stopReason = readStringField(value, "stopReason"); - - if ( - stopReason !== "cancelled" && - stopReason !== "end_turn" && - stopReason !== "max_tokens" && - stopReason !== "max_turn_requests" && - stopReason !== "refusal" - ) { - throw new Error("ACP provider fixture completePrompt stopReason is unsupported."); - } - - return { - stopReason, - usage: value["usage"], - }; -} - -function readFailPromptFixture(value: unknown): FailPromptFixture | undefined { - if (value === undefined) { - return undefined; - } - - if (!isRecord(value)) { - throw new Error("ACP provider fixture failPrompt must be an object."); - } - - const recoverable = value["recoverable"]; - - if (recoverable !== undefined && typeof recoverable !== "boolean") { - throw new Error("ACP provider fixture failPrompt recoverable must be a boolean."); - } - - return { - code: readStringField(value, "code"), - message: readStringField(value, "message"), - ...(recoverable === undefined ? {} : { recoverable }), - }; -} - -function readPermissionRequestFixture(value: unknown): PermissionRequestFixture | undefined { - if (value === undefined) { - return undefined; - } - - if (!isRecord(value)) { - throw new Error("ACP provider fixture permissionRequest must be an object."); - } - - return { - params: value["params"], - requestId: readStringField(value, "requestId"), - }; -} - -function readSessionReadyFixture(value: unknown): SessionReadyFixture | undefined { - if (value === undefined) { - return undefined; - } - - if (!isRecord(value)) { - throw new Error("ACP provider fixture sessionReady must be an object."); - } - - const mode = readStringField(value, "mode"); - - if (mode !== "created" && mode !== "loaded" && mode !== "resumed") { - throw new Error("ACP provider fixture sessionReady mode is unsupported."); - } - - return { - mode, - nativeSessionId: readStringField(value, "nativeSessionId"), - setup: readRecordField(value, "setup"), - }; -} - -function readAcpProviderFixtureCase(path: string): AcpProviderFixtureCase { - const fixture = readJsonFixture(path); - - if (!isRecord(fixture)) { - throw new Error("ACP provider fixture must be an object."); - } - - const updates = fixture["updates"] ?? []; - const expectedEvents = fixture["expectedEvents"]; - - if (!Array.isArray(updates) || !Array.isArray(expectedEvents)) { - throw new Error("ACP provider fixture updates and expectedEvents must be arrays."); - } - - return { - begin: readBeginFixture(fixture["begin"]), - completePrompt: readCompletePromptFixture(fixture["completePrompt"]), - expectedEvents, - failPrompt: readFailPromptFixture(fixture["failPrompt"]), - permissionRequest: readPermissionRequestFixture(fixture["permissionRequest"]), - sessionReady: readSessionReadyFixture(fixture["sessionReady"]), - updates, - }; -} - -function stripUndefined(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(stripUndefined); - } - - if (!isRecord(value)) { - return value; - } - - const entries = Object.entries(value).flatMap(([key, entry]): [string, unknown][] => - entry === undefined ? [] : [[key, stripUndefined(entry)]], - ); - - return Object.fromEntries(entries); -} - -function readAssistantMessageId(event: DriverEventInput): string | null { - if (event.kind !== "message.started" || !isRecord(event.payload)) { - return null; - } - - return event.payload["role"] === "agent" ? readStringField(event.payload, "messageId") : null; -} - -function replaceAssistantMessageIds(value: unknown, aliases: ReadonlyMap): unknown { - if (typeof value === "string") { - return aliases.get(value) ?? value; - } - - if (Array.isArray(value)) { - return value.map((entry) => replaceAssistantMessageIds(entry, aliases)); - } - - if (!isRecord(value)) { - return value; - } - - return Object.fromEntries( - Object.entries(value).map(([key, entry]) => [key, replaceAssistantMessageIds(entry, aliases)]), - ); -} - -function normalizeAcpEvent( - event: DriverEventInput, - aliases: ReadonlyMap, -): Record { - const eventRecord = stripUndefined(event); - - if (!isRecord(eventRecord)) { - throw new Error("ACP translator event must be an object."); - } - - const normalized = replaceAssistantMessageIds(eventRecord, aliases); - - if (!isRecord(normalized)) { - throw new Error("ACP normalized event must be an object."); - } - - return normalized; -} - -function normalizeAcpEvents(events: readonly DriverEventInput[]): Record[] { - const aliases = new Map(); - - for (const event of events) { - const messageId = readAssistantMessageId(event); - - if (messageId !== null && !aliases.has(messageId)) { - aliases.set(messageId, `assistant-message-${aliases.size + 1}`); - } - } - - return events.map((event) => normalizeAcpEvent(event, aliases)); -} - function appAcpFixture(fixture: AcpProviderFixtureCase): DriverEventInput[] { - const state = new AcpTurnEventState(); + const state = new AcpAssistantTranscriptState(); const events: DriverEventInput[] = []; if (fixture.begin !== undefined) { @@ -297,15 +56,7 @@ function appAcpFixture(fixture: AcpProviderFixtureCase): DriverEventInput[] { } if (fixture.failPrompt !== undefined) { - events.push( - ...state.failPrompt({ - code: fixture.failPrompt.code, - message: fixture.failPrompt.message, - ...(fixture.failPrompt.recoverable === undefined - ? {} - : { recoverable: fixture.failPrompt.recoverable }), - }), - ); + events.push(...state.failPrompt(fixture.failPrompt)); } if (fixture.completePrompt !== undefined) { @@ -319,8 +70,11 @@ function appAcpFixture(fixture: AcpProviderFixtureCase): DriverEventInput[] { describe("ACP provider fixtures", () => { test.each(acpFixtureNames)("apps provider-native fixture %s", (name) => { - const fixture = readAcpProviderFixtureCase(`./fixtures/providers/acp/cases/${name}.json`); + const fixture = readProviderFixture( + `./fixtures/providers/acp/cases/${name}.json`, + { arrays: ["expectedEvents", "updates"] }, + ); - expect(normalizeAcpEvents(appAcpFixture(fixture))).toEqual(fixture.expectedEvents); + expect(normalizeAcpProviderEvents(appAcpFixture(fixture))).toEqual(fixture.expectedEvents); }); }); diff --git a/tests/acp-session-setup.test.ts b/tests/acp-session-setup.test.ts index 7da0525..41ce147 100644 --- a/tests/acp-session-setup.test.ts +++ b/tests/acp-session-setup.test.ts @@ -3,7 +3,7 @@ import type { AgentCapabilities, ClientContext } from "@agentclientprotocol/sdk" import { describe, expect, test } from "bun:test"; import { setupAcpSession } from "../src/runtimes/acp/acp-session-setup"; -import { driverBootPayload, driverStartInput } from "./driver-boot-payload-fixture"; +import { driverStartInput } from "./driver-boot-payload-fixture"; const EXISTING_SESSION_ID = "native-session-existing"; @@ -49,39 +49,34 @@ function setupInput(input: { return { ...input, payload: input.payload ?? driverStartInput, - sessionContext: driverBootPayload.execution.session.context, }; } describe("ACP session setup", () => { - test("drops additional directories when the agent does not advertise support", async () => { + test("rejects additional directories when the agent does not advertise support", async () => { const { connection, requests } = createRecordingConnection(); - const setup = await setupAcpSession( - setupInput({ - agentCapabilities: { - loadSession: true, - sessionCapabilities: { close: {}, resume: {} }, - }, - connection, - currentSessionId: null, - payload: withAdditionalDirectories(["/workspace/extra"]), - replaySession: async (operation) => operation(), - }), - ); - - expect(setup.mode).toBe("created"); - expect(setup.sessionId).toBe("acp-session-1"); - expect(setup.droppedAdditionalDirectories).toEqual(["/workspace/extra"]); - expect(requests).toHaveLength(1); - expect(requests[0]?.method).toBe(acpMethods.agent.session.new); - expect("additionalDirectories" in (requests[0]?.params ?? {})).toBe(false); + await expect( + setupAcpSession( + setupInput({ + agentCapabilities: { + loadSession: true, + sessionCapabilities: { close: {}, resume: {} }, + }, + connection, + currentSessionId: null, + payload: withAdditionalDirectories(["/workspace/extra"]), + replaySession: async (operation) => operation(), + }), + ), + ).rejects.toThrow("does not advertise additionalDirectories support"); + expect(requests).toHaveLength(0); }); test("passes additional directories through when the agent advertises support", async () => { const { connection, requests } = createRecordingConnection(); - const setup = await setupAcpSession( + await setupAcpSession( setupInput({ agentCapabilities: { loadSession: true, @@ -94,8 +89,11 @@ describe("ACP session setup", () => { }), ); - expect(setup.droppedAdditionalDirectories).toEqual([]); expect(requests[0]?.params["additionalDirectories"]).toEqual(["/workspace/extra"]); + expect(requests[0]?.params["_meta"]).toEqual({ + "mosoo.ai/origin": driverStartInput.execution.session.context.origin, + "mosoo.ai/sessionContext": driverStartInput.execution.session.context, + }); }); test("prefers resume without entering the load replay scope", async () => { @@ -210,24 +208,21 @@ describe("ACP session setup", () => { }); }); - test("creates a new session when native restoration is unavailable", async () => { + test("rejects a native session when restoration is unavailable", async () => { const methods: string[] = []; - const result = await setupAcpSession( - setupInput({ - agentCapabilities: {}, - connection: connectionWith(async (method) => { - methods.push(method); - return { sessionId: "native-session-new" }; + await expect( + setupAcpSession( + setupInput({ + agentCapabilities: {}, + connection: connectionWith(async (method) => { + methods.push(method); + return { sessionId: "native-session-new" }; + }), + currentSessionId: EXISTING_SESSION_ID, + replaySession: async (operation) => operation(), }), - currentSessionId: EXISTING_SESSION_ID, - replaySession: async (operation) => operation(), - }), - ); - - expect(result).toMatchObject({ - mode: "created", - sessionId: "native-session-new", - }); - expect(methods).toEqual([acpMethods.agent.session.new]); + ), + ).rejects.toThrow("cannot restore the requested native session"); + expect(methods).toEqual([]); }); }); diff --git a/tests/acp-terminal-manager.test.ts b/tests/acp-terminal-manager.test.ts index 8d4d795..c25d6b2 100644 --- a/tests/acp-terminal-manager.test.ts +++ b/tests/acp-terminal-manager.test.ts @@ -1,14 +1,16 @@ import { describe, expect, test } from "bun:test"; import type { ChildProcess } from "node:child_process"; import { existsSync, readFileSync } from "node:fs"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rename, rm, symlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { createBufferedSinkLogger } from "../src/observability"; +import { createDisabledLogger } from "../src/observability"; import type { DriverEventInput } from "../src/protocol/events"; import { createDriverId, isDriverId } from "../src/protocol/id"; +import { DriverEventRejectedError } from "../src/core/driver-runtime-io"; import { spawnLinuxProcessTreeWatchdog } from "../src/runtimes/child-process"; +import { AcpPathScope } from "../src/runtimes/acp/acp-path-scope"; import { AcpTerminalManager } from "../src/runtimes/acp/acp-terminal-manager"; import { createAgentDriverContext } from "../src/core/agent-driver-backend"; import { settlePromiseWithTimeout } from "../src/utils/async"; @@ -19,24 +21,25 @@ function createHarness( maxTerminals?: number, recordAfterPush = false, spawnWatchdog?: typeof spawnLinuxProcessTreeWatchdog, + pathScope?: AcpPathScope, + cwd = process.cwd(), ) { const events: DriverEventInput[] = []; - const logger = createBufferedSinkLogger({ - level: "debug", - service: "acp-terminal-manager-test", - sink: async () => {}, - }); const context = createAgentDriverContext({ - eventSink: { pushEvents: async () => ({ accepted: [] }) }, - logger, + eventSink: { + currentRunId: () => null, + pushEvents: async () => ({ accepted: [] }), + }, + logger: createDisabledLogger(), payload: driverStartInput, permission: { request: async () => "reject_once" }, }); const manager = new AcpTerminalManager({ allowedRoots: [], - cwd: process.cwd(), + cwd, env: {}, maxTerminals, + pathScope, push: async (_context, _reason, next) => { if (recordAfterPush) { await onPush?.(_reason, next); @@ -49,7 +52,27 @@ function createHarness( ...(spawnWatchdog === undefined ? {} : { spawnWatchdog }), }); - return { context, events, logger, manager }; + return { context, events, manager }; +} + +async function waitForTerminalOutput( + manager: AcpTerminalManager, + terminal: unknown, + expected: "non-empty" | "ready" = "ready", +): Promise { + const deadline = Date.now() + 3_000; + + for (;;) { + const output = manager.output(terminal).output; + + if (expected === "non-empty" ? output.length > 0 : output === expected) { + return; + } + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for terminal output ${expected}.`); + } + await Bun.sleep(10); + } } function isProcessRunning(pid: number): boolean { @@ -91,6 +114,46 @@ async function waitForProcessExit(pid: number): Promise { } describe("ACP terminal manager", () => { + test("binds terminal cwd to the opened directory when its ancestor is exchanged", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-acp-terminal-cwd-race-")); + const outside = await mkdtemp(join(tmpdir(), "mosoo-acp-terminal-cwd-race-outside-")); + const workspace = join(root, "workspace"); + const retained = join(root, "retained"); + + class ExchangingPathScope extends AcpPathScope { + override async openDirectory(path: string, label: string) { + const capability = await super.openDirectory(path, label); + await rename(workspace, retained); + await symlink(outside, workspace); + return capability; + } + } + + await mkdir(workspace); + const pathScope = new ExchangingPathScope({ allowedRoots: [], cwd: root }); + const harness = createHarness(undefined, undefined, false, undefined, pathScope, root); + + try { + const terminal = await harness.manager.create(harness.context, { + args: ["-e", 'require("node:fs").writeFileSync("marker.txt", process.cwd())'], + command: process.execPath, + cwd: workspace, + }); + await harness.manager.waitForExit(terminal); + await harness.manager.release(harness.context, terminal); + + expect(await readFile(join(retained, "marker.txt"), "utf8")).toBe(retained); + expect(existsSync(join(outside, "marker.txt"))).toBe(false); + expect(harness.events.find((event) => event.kind === "terminal.created")).toMatchObject({ + payload: { cwd: retained }, + }); + } finally { + await harness.manager.stopAll(harness.context).catch(() => {}); + await rm(root, { force: true, recursive: true }); + await rm(outside, { force: true, recursive: true }); + } + }); + test("retains a valid UTF-8 suffix and exposes exit only after output closes", async () => { const harness = createHarness(); @@ -112,7 +175,7 @@ describe("ACP terminal manager", () => { }); await harness.manager.release(harness.context, { terminalId }); } finally { - await harness.logger.destroy(); + await harness.manager.stopAll(harness.context).catch(() => {}); } }); @@ -125,9 +188,7 @@ describe("ACP terminal manager", () => { args: ["-e", 'process.stdout.write("ready");setInterval(() => {}, 1000)'], command: process.execPath, }); - while (harness.manager.output(terminal).output !== "ready") { - await Bun.sleep(10); - } + await waitForTerminalOutput(harness.manager, terminal); const killed = harness.manager.kill(harness.context, terminal); let settled = false; void killed.then(() => { @@ -149,7 +210,6 @@ describe("ACP terminal manager", () => { } finally { cleanup.resolve(); await harness.manager.stopAll(harness.context).catch(() => {}); - await harness.logger.destroy(); } }); @@ -162,9 +222,7 @@ describe("ACP terminal manager", () => { args: ["-e", 'process.stdout.write("ready");setInterval(() => {}, 1000)'], command: process.execPath, }); - while (harness.manager.output(terminal).output !== "ready") { - await Bun.sleep(10); - } + await waitForTerminalOutput(harness.manager, terminal); const released = harness.manager.release(harness.context, terminal); const pending = await settlePromiseWithTimeout(released, { label: "terminal release cleanup barrier", @@ -184,7 +242,6 @@ describe("ACP terminal manager", () => { } finally { cleanup.resolve(); await harness.manager.stopAll(harness.context).catch(() => {}); - await harness.logger.destroy(); } }); @@ -203,9 +260,7 @@ describe("ACP terminal manager", () => { args: ["-e", "process.stdout.write(String(process.pid));setInterval(() => {}, 1000)"], command: process.execPath, }); - while (harness.manager.output(terminal).output === "") { - await Bun.sleep(10); - } + await waitForTerminalOutput(harness.manager, terminal, "non-empty"); const exited = harness.manager.waitForExit(terminal); void exited.catch(() => {}); process.kill(Number.parseInt(harness.manager.output(terminal).output, 10), "SIGTERM"); @@ -232,7 +287,6 @@ describe("ACP terminal manager", () => { } finally { allowExit.resolve(); await harness.manager.stopAll(harness.context).catch(() => {}); - await harness.logger.destroy(); } }); @@ -253,9 +307,7 @@ describe("ACP terminal manager", () => { ], command: process.execPath, }); - while (harness.manager.output(terminal).output !== "ready") { - await Bun.sleep(10); - } + await waitForTerminalOutput(harness.manager, terminal); pid = await waitForPidFile(pidPath); if (outcome === "completed") { @@ -273,7 +325,6 @@ describe("ACP terminal manager", () => { if (pid > 0 && isProcessRunning(pid)) { process.kill(pid, "SIGKILL"); } - await harness.logger.destroy(); await rm(directory, { force: true, recursive: true }); } }, @@ -290,9 +341,7 @@ describe("ACP terminal manager", () => { args: ["-e", 'process.stdout.write("ready");setInterval(() => {}, 1000)'], command: process.execPath, }); - while (harness.manager.output(terminal).output !== "ready") { - await Bun.sleep(10); - } + await waitForTerminalOutput(harness.manager, terminal); cleanup.reject(new Error("test watchdog cleanup failed")); await expect(harness.manager.waitForExit(terminal)).rejects.toThrow("watchdog failed"); @@ -310,7 +359,6 @@ describe("ACP terminal manager", () => { expect(() => harness.manager.output(terminal)).toThrow("does not exist"); } finally { await harness.manager.stopAll(harness.context).catch(() => {}); - await harness.logger.destroy(); } }, ); @@ -378,7 +426,6 @@ setInterval(() => {}, 1_000); process.kill(pid, "SIGKILL"); } } - await harness.logger.destroy(); await rm(directory, { force: true, recursive: true }); } }, 7_000); @@ -424,57 +471,45 @@ while (!existsSync(${JSON.stringify(workerPidPath)})) Atomics.wait(sleeper, 0, 0 process.kill(pid, "SIGKILL"); } } - await harness.logger.destroy(); await rm(directory, { force: true, recursive: true }); } }); - test("reuses one supervisor across the full terminal capacity", async () => { + test("reuses one supervisor for concurrent terminals", async () => { const supervisors = new Set(); - let leaseCount = 0; - const harness = createHarness(undefined, 32, false, (pid, marker) => { + const harness = createHarness(undefined, 2, false, (pid, marker) => { const lease = spawnLinuxProcessTreeWatchdog(pid, marker); if (lease === null) { throw new Error("Test process supervision could not start."); } - leaseCount += 1; supervisors.add(lease.process); return lease; }); try { await Promise.all( - Array.from({ length: 32 }, () => + Array.from({ length: 2 }, () => harness.manager.create(harness.context, { args: ["-e", "setInterval(() => {}, 1_000)"], command: process.execPath, }), ), ); - - expect(leaseCount).toBe(32); expect(supervisors.size).toBe(1); - expect([...supervisors][0]?.pid).toBeGreaterThan(1); - await harness.manager.stopAll(harness.context); } finally { await harness.manager.stopAll(harness.context).catch(() => {}); - await harness.logger.destroy(); } - }, 10_000); + }); test("rejects terminal buffers above the deployment hard limit", async () => { const harness = createHarness(); - try { - await expect( - harness.manager.create(harness.context, { - command: process.execPath, - outputByteLimit: 1_024 * 1_024 + 1, - }), - ).rejects.toThrow("exceeds 1048576 bytes"); - } finally { - await harness.logger.destroy(); - } + await expect( + harness.manager.create(harness.context, { + command: process.execPath, + outputByteLimit: 1_024 * 1_024 + 1, + }), + ).rejects.toThrow("exceeds 1048576 bytes"); }); test("bounds retained terminals and restores capacity after release", async () => { @@ -498,7 +533,65 @@ while (!existsSync(${JSON.stringify(workerPidPath)})) Atomics.wait(sleeper, 0, 0 await harness.manager.release(harness.context, second); } finally { await harness.manager.stopAll(harness.context); - await harness.logger.destroy(); + } + }); + + test("reserves capacity before concurrent terminal creation can yield", async () => { + const harness = createHarness(undefined, 1); + + try { + const creations = await Promise.allSettled( + Array.from({ length: 8 }, () => + harness.manager.create(harness.context, { + args: ["-e", "setInterval(() => {}, 1000)"], + command: process.execPath, + }), + ), + ); + const created = creations.flatMap((result) => + result.status === "fulfilled" ? [result.value] : [], + ); + + expect(created).toHaveLength(1); + expect(creations.filter((result) => result.status === "rejected")).toHaveLength(7); + expect(harness.events.filter((event) => event.kind === "terminal.created")).toHaveLength(1); + + await harness.manager.release(harness.context, created[0]); + const next = await harness.manager.create(harness.context, { + args: ["-e", "process.exit(0)"], + command: process.execPath, + }); + await harness.manager.waitForExit(next); + await harness.manager.release(harness.context, next); + } finally { + await harness.manager.stopAll(harness.context); + } + }); + + test("stops only one turn and accepts terminals in the next turn", async () => { + const harness = createHarness(undefined, 1); + + try { + const firstTurn = harness.manager.beginTurn(); + const first = await harness.manager.create(harness.context, { + args: ["-e", 'process.stdout.write("ready");setInterval(() => {}, 1000)'], + command: process.execPath, + }); + await waitForTerminalOutput(harness.manager, first); + + await harness.manager.stopTurn(harness.context, firstTurn); + expect(() => harness.manager.output(first)).toThrow("does not exist"); + + const secondTurn = harness.manager.beginTurn(); + const second = await harness.manager.create(harness.context, { + args: ["-e", "process.exit(0)"], + command: process.execPath, + }); + await harness.manager.waitForExit(second); + await harness.manager.stopTurn(harness.context, secondTurn); + expect(() => harness.manager.output(second)).toThrow("does not exist"); + } finally { + await harness.manager.stopAll(harness.context); } }); @@ -523,7 +616,50 @@ while (!existsSync(${JSON.stringify(workerPidPath)})) Atomics.wait(sleeper, 0, 0 await harness.manager.release(harness.context, terminal); } finally { await harness.manager.stopAll(harness.context); - await harness.logger.destroy(); + } + }); + + test("rejects a synchronous spawn failure without retaining terminal capacity", async () => { + const harness = createHarness(undefined, 1); + + try { + await expect( + harness.manager.create(harness.context, { + command: "invalid\0command", + }), + ).rejects.toThrow("null bytes"); + const terminal = await harness.manager.create(harness.context, { + args: ["-e", "process.exit(0)"], + command: process.execPath, + }); + await harness.manager.waitForExit(terminal); + await harness.manager.release(harness.context, terminal); + } finally { + await harness.manager.stopAll(harness.context); + } + }); + + test("rejects repeated invalid cwd requests before reserving process ownership", async () => { + const harness = createHarness(undefined, 1); + + try { + for (let attempt = 0; attempt < 64; attempt += 1) { + await expect( + harness.manager.create(harness.context, { + command: process.execPath, + cwd: "/", + }), + ).rejects.toThrow("outside the allowed roots"); + } + + const terminal = await harness.manager.create(harness.context, { + args: ["-e", "process.exit(0)"], + command: process.execPath, + }); + await harness.manager.waitForExit(terminal); + await harness.manager.release(harness.context, terminal); + } finally { + await harness.manager.stopAll(harness.context); } }); @@ -540,7 +676,6 @@ while (!existsSync(${JSON.stringify(workerPidPath)})) Atomics.wait(sleeper, 0, 0 ).rejects.toThrow("stopping"); } finally { await harness.manager.stopAll(harness.context); - await harness.logger.destroy(); } }); @@ -572,7 +707,6 @@ while (!existsSync(${JSON.stringify(workerPidPath)})) Atomics.wait(sleeper, 0, 0 } finally { release.resolve(); await harness.manager.stopAll(harness.context); - await harness.logger.destroy(); } }); @@ -617,7 +751,6 @@ while (!existsSync(${JSON.stringify(workerPidPath)})) Atomics.wait(sleeper, 0, 0 } finally { release.resolve(); await harness.manager.stopAll(harness.context).catch(() => {}); - await harness.logger.destroy(); } }); @@ -671,7 +804,6 @@ while (!existsSync(${JSON.stringify(workerPidPath)})) Atomics.wait(sleeper, 0, 0 } finally { release.resolve(); await harness.manager.stopAll(harness.context).catch(() => {}); - await harness.logger.destroy(); } }); @@ -729,7 +861,6 @@ while (!existsSync(${JSON.stringify(workerPidPath)})) Atomics.wait(sleeper, 0, 0 failRelease = false; releaseCreated.resolve(); await harness.manager.stopAll(harness.context).catch(() => {}); - await harness.logger.destroy(); } }); @@ -758,7 +889,258 @@ while (!existsSync(${JSON.stringify(workerPidPath)})) Atomics.wait(sleeper, 0, 0 await expect(operation()).rejects.toThrow("does not exist"); } } finally { - await harness.logger.destroy(); + await harness.manager.stopAll(harness.context).catch(() => {}); + } + }); + + test("coalesces concurrent and repeated terminal kills into one durable event", async () => { + const killPublishing = Promise.withResolvers(); + const allowKill = Promise.withResolvers(); + let killPushes = 0; + const harness = createHarness(async (reason) => { + if (reason === "driver.acp.terminal.killed") { + killPushes += 1; + killPublishing.resolve(); + await allowKill.promise; + } + }); + + try { + const terminal = await harness.manager.create(harness.context, { + args: ["-e", 'process.stdout.write("ready");setInterval(() => {}, 1000)'], + command: process.execPath, + }); + await waitForTerminalOutput(harness.manager, terminal); + + const first = harness.manager.kill(harness.context, terminal); + await killPublishing.promise; + const second = harness.manager.kill(harness.context, terminal); + allowKill.resolve(); + + await expect(Promise.all([first, second])).resolves.toEqual([{}, {}]); + await expect(harness.manager.kill(harness.context, terminal)).resolves.toEqual({}); + expect(killPushes).toBe(1); + expect(harness.events.filter((event) => event.kind === "terminal.killed")).toHaveLength(1); + + await harness.manager.release(harness.context, terminal); + } finally { + allowKill.resolve(); + await harness.manager.stopAll(harness.context).catch(() => {}); + } + }); + + test.each(["release", "stopAll"] as const)( + "retains a terminal when a concurrent %s observes failed kill publication", + async (action) => { + let killedDraft: DriverEventInput | undefined; + let killPushes = 0; + const harness = createHarness( + async (reason, events) => { + if (reason !== "driver.acp.terminal.killed") { + return; + } + + const draft = events[0]!; + killedDraft ??= draft; + expect(draft).toBe(killedDraft); + killPushes += 1; + if (killPushes === 1) { + throw new Error("kill publication failed"); + } + }, + 1, + true, + ); + + try { + const terminal = await harness.manager.create(harness.context, { + args: ["-e", "process.exit(0)"], + command: process.execPath, + }); + await harness.manager.waitForExit(terminal); + const firstKill = harness.manager.kill(harness.context, terminal); + const secondKill = harness.manager.kill(harness.context, terminal); + const cleanup = + action === "release" + ? harness.manager.release(harness.context, terminal) + : harness.manager.stopAll(harness.context); + const results = await Promise.allSettled([firstKill, secondKill, cleanup]); + + expect(results.map((result) => result.status)).toEqual([ + "rejected", + "rejected", + "rejected", + ]); + expect(killPushes).toBe(1); + expect(() => harness.manager.output(terminal)).not.toThrow(); + expect(harness.events.some((event) => event.kind === "terminal.killed")).toBe(false); + expect(harness.events.some((event) => event.kind === "terminal.released")).toBe(false); + if (action === "release") { + await expect( + harness.manager.create(harness.context, { + args: ["-e", "process.exit(0)"], + command: process.execPath, + }), + ).rejects.toThrow("terminal limit of 1 is exhausted"); + } + + await expect( + action === "release" + ? harness.manager.release(harness.context, terminal).then(() => {}) + : harness.manager.stopAll(harness.context), + ).resolves.toBeUndefined(); + expect(killPushes).toBe(2); + expect( + harness.events + .filter((event) => + [ + "terminal.created", + "terminal.exited", + "terminal.killed", + "terminal.released", + ].includes(event.kind), + ) + .map((event) => event.kind), + ).toEqual(["terminal.created", "terminal.exited", "terminal.killed", "terminal.released"]); + expect(() => harness.manager.output(terminal)).toThrow("does not exist"); + } finally { + await harness.manager.stopAll(harness.context).catch(() => {}); + } + }, + ); + + test("retains a terminal until its exit event is durably published", async () => { + let exitDraft: DriverEventInput | undefined; + let exitPushes = 0; + let failExit = true; + const harness = createHarness( + async (reason, events) => { + if (reason !== "driver.acp.terminal.exited") { + return; + } + + const draft = events[0]!; + exitDraft ??= draft; + expect(draft).toBe(exitDraft); + exitPushes += 1; + if (failExit) { + throw new Error("exit publication failed"); + } + }, + 1, + true, + ); + + try { + const terminal = await harness.manager.create(harness.context, { + args: ["-e", "process.stdout.write(String(process.pid));setInterval(() => {}, 1000)"], + command: process.execPath, + }); + await waitForTerminalOutput(harness.manager, terminal, "non-empty"); + process.kill(Number.parseInt(harness.manager.output(terminal).output, 10), "SIGTERM"); + await harness.manager.waitForExit(terminal); + await Bun.sleep(0); + + await expect(harness.manager.release(harness.context, terminal)).rejects.toThrow( + "exit publication failed", + ); + expect(exitPushes).toBe(2); + expect(() => harness.manager.output(terminal)).not.toThrow(); + expect(harness.events.some((event) => event.kind === "terminal.exited")).toBe(false); + expect(harness.events.some((event) => event.kind === "terminal.released")).toBe(false); + + failExit = false; + await expect(harness.manager.release(harness.context, terminal)).resolves.toEqual({}); + expect(exitPushes).toBe(3); + expect( + harness.events + .filter((event) => + ["terminal.created", "terminal.exited", "terminal.released"].includes(event.kind), + ) + .map((event) => event.kind), + ).toEqual(["terminal.created", "terminal.exited", "terminal.released"]); + expect(() => harness.manager.output(terminal)).toThrow("does not exist"); + } finally { + failExit = false; + await harness.manager.stopAll(harness.context).catch(() => {}); + } + }); + + test("keeps a kill admitted before release ahead of the released event", async () => { + const killPublishing = Promise.withResolvers(); + const allowKill = Promise.withResolvers(); + const harness = createHarness( + async (reason) => { + if (reason === "driver.acp.terminal.killed") { + killPublishing.resolve(); + await allowKill.promise; + } + }, + undefined, + true, + ); + + try { + const terminal = await harness.manager.create(harness.context, { + args: ["-e", 'process.stdout.write("ready");setInterval(() => {}, 1000)'], + command: process.execPath, + }); + await waitForTerminalOutput(harness.manager, terminal); + + const kill = harness.manager.kill(harness.context, terminal); + await killPublishing.promise; + const release = harness.manager.release(harness.context, terminal); + expect(() => harness.manager.output(terminal)).toThrow("does not exist"); + await Bun.sleep(0); + expect(harness.events.some((event) => event.kind === "terminal.released")).toBe(false); + + allowKill.resolve(); + await expect(Promise.all([kill, release])).resolves.toEqual([{}, {}]); + expect( + harness.events + .filter((event) => event.kind === "terminal.killed" || event.kind === "terminal.released") + .map((event) => event.kind), + ).toEqual(["terminal.killed", "terminal.released"]); + } finally { + allowKill.resolve(); + await harness.manager.stopAll(harness.context).catch(() => {}); + } + }); + + test("invalidates every public terminal operation while release is in flight", async () => { + const releasePublishing = Promise.withResolvers(); + const allowRelease = Promise.withResolvers(); + const harness = createHarness(async (reason) => { + if (reason === "driver.acp.terminal.released") { + releasePublishing.resolve(); + await allowRelease.promise; + } + }); + + try { + const terminal = await harness.manager.create(harness.context, { + args: ["-e", "process.exit(0)"], + command: process.execPath, + }); + await harness.manager.waitForExit(terminal); + const release = harness.manager.release(harness.context, terminal); + await releasePublishing.promise; + + expect(() => harness.manager.output(terminal)).toThrow("does not exist"); + for (const operation of [ + () => harness.manager.waitForExit(terminal), + () => harness.manager.kill(harness.context, terminal), + () => harness.manager.release(harness.context, terminal), + ]) { + await expect(operation()).rejects.toThrow("does not exist"); + } + + allowRelease.resolve(); + await expect(release).resolves.toEqual({}); + expect(harness.events.some((event) => event.kind === "terminal.killed")).toBe(false); + } finally { + allowRelease.resolve(); + await harness.manager.stopAll(harness.context).catch(() => {}); } }); @@ -779,10 +1161,60 @@ while (!existsSync(${JSON.stringify(workerPidPath)})) Atomics.wait(sleeper, 0, 0 await harness.manager.release(harness.context, terminal); } finally { await harness.manager.stopAll(harness.context); - await harness.logger.destroy(); } }); + test("aborts an SDK terminal kill without abandoning cleanup ownership", async () => { + const cleanup = Promise.withResolvers(); + const harness = createHarness(undefined, undefined, false, fakeWatchdog(cleanup.promise)); + + try { + const terminal = await harness.manager.create(harness.context, { + args: ["-e", 'process.stdout.write("ready");setInterval(() => {}, 1000)'], + command: process.execPath, + }); + await waitForTerminalOutput(harness.manager, terminal); + const controller = new AbortController(); + const killed = harness.manager.kill(harness.context, terminal, controller.signal); + controller.abort(); + + await expect(killed).rejects.toBeInstanceOf(DOMException); + expect(() => harness.manager.output(terminal)).not.toThrow(); + expect(harness.events.some((event) => event.kind === "terminal.killed")).toBe(false); + + await expect(harness.manager.release(harness.context, terminal)).resolves.toEqual({}); + expect(() => harness.manager.output(terminal)).toThrow("does not exist"); + } finally { + cleanup.resolve(); + await harness.manager.stopAll(harness.context).catch(() => {}); + } + }); + + test("bounds an SDK terminal kill and retains cleanup ownership after timeout", async () => { + const cleanup = Promise.withResolvers(); + const harness = createHarness(undefined, undefined, false, fakeWatchdog(cleanup.promise)); + + try { + const terminal = await harness.manager.create(harness.context, { + args: ["-e", 'process.stdout.write("ready");setInterval(() => {}, 1000)'], + command: process.execPath, + }); + await waitForTerminalOutput(harness.manager, terminal); + + await expect(harness.manager.kill(harness.context, terminal)).rejects.toThrow( + "cleanup did not finish within 4000ms after force kill", + ); + expect(() => harness.manager.output(terminal)).not.toThrow(); + expect(harness.events.some((event) => event.kind === "terminal.killed")).toBe(false); + + await expect(harness.manager.release(harness.context, terminal)).resolves.toEqual({}); + expect(() => harness.manager.output(terminal)).toThrow("does not exist"); + } finally { + cleanup.resolve(); + await harness.manager.stopAll(harness.context).catch(() => {}); + } + }, 8_000); + test.each([ ["cooperative", "", "SIGTERM"], ["TERM-resistant", 'process.on("SIGTERM", () => {});', "SIGKILL"], @@ -823,7 +1255,6 @@ while (!existsSync(${JSON.stringify(workerPidPath)})) Atomics.wait(sleeper, 0, 0 expect(() => harness.manager.output(terminal)).toThrow("does not exist"); } finally { await harness.manager.stopAll(harness.context); - await harness.logger.destroy(); } }, ); @@ -867,47 +1298,169 @@ while (!existsSync(${JSON.stringify(workerPidPath)})) Atomics.wait(sleeper, 0, 0 }); } finally { await harness.manager.stopAll(harness.context).catch(() => {}); - await harness.logger.destroy(); } }); - test("retains a terminal for shutdown retry when failed creation cleanup cannot confirm exit", async () => { - const cleanup = Promise.withResolvers(); + test("closes an ACK-unknown creation with the same durable identity", async () => { + const directory = await mkdtemp(join(tmpdir(), "mosoo-acp-created-ack-")); + const pidPath = join(directory, "terminal.pid"); + let createdDraft: DriverEventInput | undefined; + let createdPushes = 0; + let releaseDraft: DriverEventInput | undefined; + let releasePushes = 0; const harness = createHarness( - async (reason) => { + async (reason, events) => { if (reason === "driver.acp.terminal.created") { - throw new Error("event sink unavailable"); + if (createdDraft === undefined) { + createdDraft = events[0]; + createdPushes += 1; + throw new Error("creation receipt lost after commit"); + } + if (events[0]?.sourceEventId === createdDraft.sourceEventId) { + expect(events[0]).toBe(createdDraft); + createdPushes += 1; + } + } + if (reason === "driver.acp.terminal.released") { + releaseDraft ??= events[0]; + expect(events[0]).toBe(releaseDraft); + if (releasePushes++ === 0) { + throw new Error("release receipt lost after commit"); + } } }, - undefined, + 1, false, - fakeWatchdog(cleanup.promise), ); try { await expect( harness.manager.create(harness.context, { - args: ["-e", "setInterval(() => {}, 1000);"], + args: [ + "-e", + `require("node:fs").writeFileSync(${JSON.stringify(pidPath)}, String(process.pid));setInterval(() => {}, 1000);`, + ], command: process.execPath, }), - ).rejects.toThrow(); - const terminalId = ( - harness.events.find((event) => event.kind === "terminal.created")?.payload as - | Record - | undefined - )?.["terminalId"]; + ).rejects.toThrow("creation cleanup failed"); + const pid = await waitForPidFile(pidPath); + await waitForProcessExit(pid); - expect(terminalId).toBeString(); + expect(createdPushes).toBe(2); + expect(createdDraft?.sourceEventId).toMatch(/^acp\.terminal\.created:/); + expect(releasePushes).toBe(1); + const terminalId = (createdDraft!.payload as { terminalId: string }).terminalId; expect(() => harness.manager.output({ terminalId })).not.toThrow(); - + await expect( + harness.manager.create(harness.context, { + args: ["-e", "process.exit(0)"], + command: process.execPath, + }), + ).rejects.toThrow("terminal limit of 1 is exhausted"); + expect( + [...new Map(harness.events.map((event) => [event.sourceEventId, event])).values()].map( + (event) => event.kind, + ), + ).toEqual(["terminal.created", "terminal.exited", "terminal.released"]); await expect(harness.manager.stopAll(harness.context)).resolves.toBeUndefined(); + expect(releasePushes).toBe(2); expect(() => harness.manager.output({ terminalId })).toThrow("does not exist"); } finally { - cleanup.resolve(); await harness.manager.stopAll(harness.context).catch(() => {}); - await harness.logger.destroy(); + await rm(directory, { force: true, recursive: true }); + } + }); + + test("cleans a creation that the durable sink rejects before commit", async () => { + let rejectCreated = true; + const harness = createHarness( + async (reason, events) => { + if (rejectCreated && reason === "driver.acp.terminal.created") { + throw new DriverEventRejectedError( + events[0]!.sourceEventId!, + new Error("creation rejected before commit"), + ); + } + }, + 1, + true, + ); + + try { + await expect( + harness.manager.create(harness.context, { + args: ["-e", "setInterval(() => {}, 1000)"], + command: process.execPath, + }), + ).rejects.toThrow("creation rejected before commit"); + expect(harness.events).toHaveLength(0); + + rejectCreated = false; + const next = await harness.manager.create(harness.context, { + args: ["-e", "process.exit(0)"], + command: process.execPath, + }); + await harness.manager.waitForExit(next); + await harness.manager.release(harness.context, next); + } finally { + rejectCreated = false; + await harness.manager.stopAll(harness.context).catch(() => {}); } - }, 5_000); + }); + + test("treats a mismatched rejection identity as an ACK-unknown creation", async () => { + let createdDraft: DriverEventInput | undefined; + let createdPushes = 0; + const harness = createHarness( + async (reason, events) => { + if (reason !== "driver.acp.terminal.created") { + return; + } + if (createdPushes >= 2) { + return; + } + + createdDraft ??= events[0]; + expect(events[0]).toBe(createdDraft); + createdPushes += 1; + if (createdPushes === 1) { + throw new DriverEventRejectedError( + "different-source-event", + new Error("different creation rejected"), + ); + } + }, + 1, + false, + ); + + try { + await expect( + harness.manager.create(harness.context, { + args: ["-e", "setInterval(() => {}, 1000)"], + command: process.execPath, + }), + ).rejects.toThrow("different creation rejected"); + + expect(createdPushes).toBe(2); + expect( + [...new Map(harness.events.map((event) => [event.sourceEventId, event])).values()].map( + (event) => event.kind, + ), + ).toEqual(["terminal.created", "terminal.exited", "terminal.released"]); + const terminalId = (createdDraft!.payload as { terminalId: string }).terminalId; + expect(() => harness.manager.output({ terminalId })).toThrow("does not exist"); + + const next = await harness.manager.create(harness.context, { + args: ["-e", "process.exit(0)"], + command: process.execPath, + }); + await harness.manager.waitForExit(next); + await harness.manager.release(harness.context, next); + } finally { + await harness.manager.stopAll(harness.context).catch(() => {}); + } + }); test("stopAll joins an in-progress release and emits it once", async () => { const releasePublishing = Promise.withResolvers(); @@ -947,7 +1500,6 @@ while (!existsSync(${JSON.stringify(workerPidPath)})) Atomics.wait(sleeper, 0, 0 } finally { allowRelease.resolve(); await harness.manager.stopAll(harness.context); - await harness.logger.destroy(); } }); }); diff --git a/tests/acp-test-helpers.ts b/tests/acp-test-helpers.ts new file mode 100644 index 0000000..1ef0acb --- /dev/null +++ b/tests/acp-test-helpers.ts @@ -0,0 +1,27 @@ +import type { RunId } from "../src/protocol/id"; +import { + AcpAssistantTranscriptState, + type AcpAssistantTranscriptStateInput, +} from "../src/runtimes/acp/acp-assistant-transcript-state"; + +export function beginAcpTranscript( + input: Partial = {}, +): AcpAssistantTranscriptState { + const state = new AcpAssistantTranscriptState(); + state.begin({ messageId: "message-1", runId: "run-1" as RunId, ...input }); + return state; +} + +export async function waitForAcpTestCondition( + condition: () => boolean | Promise, + description: string, +): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (await condition()) { + return; + } + await Bun.sleep(5); + } + + throw new Error(`Timed out waiting for ${description}.`); +} diff --git a/tests/acp-v1-contract-adapter-mapping.test.ts b/tests/acp-v1-contract-adapter-mapping.test.ts deleted file mode 100644 index ac91a69..0000000 --- a/tests/acp-v1-contract-adapter-mapping.test.ts +++ /dev/null @@ -1,506 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { RequestPermissionRequest, SessionNotification } from "@agentclientprotocol/sdk"; - -import { - AuthorityOutcomeUnknownError, - applyCommittedMutation, - interactionSchema, - validateSessionSnapshot, -} from "../src/contract"; -import type { - AuthorityOperation, - CommittedMutation, - InteractionResolution, - Run, - SessionSnapshot, -} from "../src/contract"; -import { AcpV1ContractAdapter } from "../src/runtimes/acp/v1-contract-adapter"; -import { - type ContractAuthorityUpdate, - type ContractPreviewUpdate, -} from "../src/runtimes/contract-projection"; - -const SESSION_ID = protocolId(1); -const RUN_ID = protocolId(2); -const NATIVE_SESSION_ID = "native-session-1"; - -function protocolId(value: number): string { - return value.toString().padStart(26, "0"); -} - -function activeRun(startedAt: string): Run { - return { - id: RUN_ID, - input: [{ text: "hello", type: "text" }], - origin: "user", - startedAt, - status: "active", - }; -} - -function createInitialSnapshot(capturedAt: string): SessionSnapshot { - return validateSessionSnapshot({ - capturedAt, - interactions: [], - items: [], - protocolVersion: 2, - revision: 0, - runs: [activeRun(capturedAt)], - session: { - capabilities: { - "interaction.permission": {}, - "item.artifact": {}, - "item.change": {}, - "item.plan": {}, - "item.reasoning": {}, - "item.terminal": {}, - }, - config: [], - createdAt: capturedAt, - id: SESSION_ID, - status: "open", - updatedAt: capturedAt, - }, - }); -} - -function createHarness( - interactionTimeoutMs = 5 * 60 * 1_000, - previewCheckpointBytes?: number, - maxPendingPermissionBytes?: number, - beforeAuthority?: (update: ContractAuthorityUpdate) => Promise, - afterAuthority?: (update: ContractAuthorityUpdate) => Promise, -) { - let nowMs = Date.parse("2026-07-16T08:00:00.000Z"); - let snapshot = createInitialSnapshot(new Date(nowMs).toISOString()); - let nextId = 100; - const authority: ContractAuthorityUpdate[] = []; - const committedMutationIds = new Set(); - const previews: ContractPreviewUpdate[] = []; - const commit = (cause: CommittedMutation["cause"], operations: AuthorityOperation[]): void => { - const revision = snapshot.revision + 1; - const mutation: CommittedMutation = { - baseRevision: snapshot.revision, - cause, - committedAt: new Date(nowMs).toISOString(), - mutationId: protocolId(1_000 + revision), - operations, - revision, - sessionId: SESSION_ID, - }; - snapshot = applyCommittedMutation(snapshot, mutation); - }; - const adapter = new AcpV1ContractAdapter({ - authority: async (update) => { - await beforeAuthority?.(update); - authority.push(update); - if (!committedMutationIds.has(update.mutationId)) { - committedMutationIds.add(update.mutationId); - commit(update.cause, [...update.operations] as AuthorityOperation[]); - } - try { - await afterAuthority?.(update); - } catch (cause) { - throw new AuthorityOutcomeUnknownError( - cause instanceof Error ? cause.message : "Authority outcome is unknown.", - { cause }, - ); - } - }, - createId: () => protocolId(nextId++), - interactionTimeoutMs, - maxPendingPermissionBytes, - nativeSessionId: NATIVE_SESSION_ID, - now: () => new Date(nowMs), - preview: (update) => previews.push(update), - previewCheckpointBytes, - sessionId: SESSION_ID, - }); - - return { - adapter, - advance(milliseconds: number) { - nowMs += milliseconds; - }, - authority, - previews, - settleInteraction(interactionId: string, resolution?: InteractionResolution) { - const interaction = snapshot.interactions.find((entry) => entry.id === interactionId); - - if (interaction === undefined || interaction.status !== "open") { - throw new Error("The test interaction must be open."); - } - - if (resolution !== undefined && resolution.kind !== interaction.kind) { - throw new Error("The test resolution kind must match the interaction kind."); - } - - const endedAt = new Date(nowMs).toISOString(); - commit({ commandId: protocolId(2_000 + snapshot.revision + 1), type: "command" }, [ - { - entity: "interaction", - op: "put", - value: interactionSchema.parse( - resolution === undefined - ? { ...interaction, endedAt, status: "expired" } - : { - ...interaction, - endedAt, - resolution: resolution.value, - status: "resolved", - }, - ), - }, - ]); - }, - snapshot: () => snapshot, - }; -} - -async function registerRun(adapter: AcpV1ContractAdapter): Promise { - adapter.attachRun(activeRun("2026-07-16T08:00:00.000Z")); -} - -function notification(update: SessionNotification["update"]): SessionNotification { - return { sessionId: NATIVE_SESSION_ID, update }; -} - -describe("ACP V1 Contract adapter", () => { - test("preserves streamed rich-content order and repairs Preview at prompt completion", async () => { - const harness = createHarness(); - await registerRun(harness.adapter); - await harness.adapter.handleSessionUpdate( - RUN_ID, - notification({ - content: { text: "hello ", type: "text" }, - messageId: "message-1", - sessionUpdate: "agent_message_chunk", - }), - ); - await harness.adapter.handleSessionUpdate( - RUN_ID, - notification({ - content: { data: "aA==", mimeType: "image/png", type: "image" }, - messageId: "message-1", - sessionUpdate: "agent_message_chunk", - }), - ); - await harness.adapter.handleSessionUpdate( - RUN_ID, - notification({ - content: { text: "world", type: "text" }, - messageId: "message-1", - sessionUpdate: "agent_message_chunk", - }), - ); - await harness.adapter.completePrompt(RUN_ID, { - stopReason: "end_turn", - usage: { - cachedReadTokens: 2, - inputTokens: 3, - outputTokens: 5, - thoughtTokens: 1, - totalTokens: 8, - }, - }); - - expect(harness.previews.map((entry) => entry.update)).toMatchObject([ - { op: "append", segment: 0, text: "hello " }, - { op: "append", segment: 2, text: "world" }, - ]); - expect(harness.snapshot().items).toContainEqual( - expect.objectContaining({ - content: [ - { text: "hello ", type: "text" }, - { data: "aA==", mediaType: "image/png", type: "inline_blob" }, - { text: "world", type: "text" }, - ], - id: "message:message-1", - kind: "message", - status: "completed", - }), - ); - expect(harness.snapshot().runs[0]).toMatchObject({ - finishReason: "success", - status: "completed", - usage: { - cachedInput: 2, - input: 3, - output: 5, - reasoning: 1, - total: 8, - }, - }); - }); - - test.each([ - ["negative", -1], - ["fractional", 1.5], - ["unsafe", Number.MAX_SAFE_INTEGER + 1], - ["non-finite", Number.POSITIVE_INFINITY], - ] as const)("ignores %s prompt usage without blocking the Run terminal", async (_name, value) => { - const harness = createHarness(); - await registerRun(harness.adapter); - - await harness.adapter.completePrompt(RUN_ID, { - stopReason: "end_turn", - usage: { - cachedReadTokens: value, - inputTokens: value, - outputTokens: value, - thoughtTokens: value, - totalTokens: value, - }, - }); - - expect(harness.snapshot().runs[0]).toMatchObject({ - finishReason: "success", - status: "completed", - }); - expect(harness.snapshot().runs[0]).not.toHaveProperty("usage"); - }); - - test("retains valid prompt usage while omitting malformed fields", async () => { - const harness = createHarness(); - await registerRun(harness.adapter); - - await harness.adapter.completePrompt(RUN_ID, { - stopReason: "end_turn", - usage: { - cachedReadTokens: 1.5, - inputTokens: -1, - outputTokens: 2, - thoughtTokens: Number.MAX_SAFE_INTEGER + 1, - totalTokens: 2, - }, - }); - - expect(harness.snapshot().runs[0]).toMatchObject({ - finishReason: "success", - status: "completed", - usage: { output: 2, total: 2 }, - }); - }); - - test("projects tool diffs and terminal snapshots, then treats request limits as limits", async () => { - const harness = createHarness(); - await registerRun(harness.adapter); - await harness.adapter.handleSessionUpdate( - RUN_ID, - notification({ - content: [ - { - newText: "new\n", - oldText: "old\n", - path: "/workspace/file.txt", - type: "diff", - }, - { terminalId: "terminal-1", type: "terminal" }, - ], - kind: "execute", - rawInput: { command: "printf new" }, - sessionUpdate: "tool_call", - status: "in_progress", - title: "Update file", - toolCallId: "tool-1", - }), - ); - await harness.adapter.handleTerminalOutput(RUN_ID, "terminal-1", { - exitStatus: { exitCode: 0, signal: null }, - output: "new\n", - truncated: false, - }); - await harness.adapter.handleSessionUpdate( - RUN_ID, - notification({ - rawOutput: { changed: true }, - sessionUpdate: "tool_call_update", - status: "completed", - toolCallId: "tool-1", - }), - ); - await harness.adapter.completePrompt(RUN_ID, { stopReason: "max_turn_requests" }); - - expect(harness.snapshot().items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: "tool:tool-1", - kind: "tool", - status: "completed", - structuredOutput: { changed: true }, - terminalItemId: "terminal:terminal-1", - }), - expect.objectContaining({ - changes: [ - expect.objectContaining({ - operation: "update", - path: "/workspace/file.txt", - }), - ], - id: "change:tool-1", - kind: "change", - status: "completed", - }), - expect.objectContaining({ - exitCode: 0, - id: "terminal:terminal-1", - kind: "terminal", - status: "completed", - stdout: [{ text: "new\n", type: "text" }], - }), - ]), - ); - expect(harness.snapshot().runs[0]).toMatchObject({ - finishReason: "limit", - status: "completed", - }); - }); - - test("translates accepted permission decisions and leaves deadline ownership to the Coordinator", async () => { - const harness = createHarness(1_000); - await registerRun(harness.adapter); - const request = { - options: [ - { kind: "allow_once", name: "Allow", optionId: "yes" }, - { kind: "reject_once", name: "Deny", optionId: "no" }, - ], - sessionId: NATIVE_SESSION_ID, - toolCall: { - title: "Run command", - toolCallId: "tool-1", - }, - } satisfies RequestPermissionRequest; - const interactionId = await harness.adapter.openPermission(RUN_ID, request); - expect(await harness.adapter.openPermission(RUN_ID, request)).toBe(interactionId); - expect(harness.snapshot().interactions).toHaveLength(1); - const interaction = harness.snapshot().interactions.find((entry) => entry.id === interactionId); - const allowId = - interaction?.kind === "permission" - ? interaction.request.options.find((option) => option.effect === "allow")?.id - : undefined; - const resolution = { - kind: "permission", - value: { optionId: allowId!, type: "selected" }, - } satisfies InteractionResolution; - - harness.settleInteraction(interactionId, resolution); - await expect(harness.adapter.resolveInteraction(interactionId, resolution)).resolves.toEqual({ - outcome: { optionId: "yes", outcome: "selected" }, - }); - expect( - harness.snapshot().interactions.find((entry) => entry.id === interactionId), - ).toMatchObject({ status: "resolved" }); - - const lateId = await harness.adapter.openPermission(RUN_ID, { - ...request, - toolCall: { title: "Read file", toolCallId: "tool-2" }, - }); - harness.advance(1_001); - harness.settleInteraction(lateId); - await expect( - harness.adapter.resolveInteraction(lateId, { - kind: "permission", - value: { type: "cancelled" }, - }), - ).resolves.toEqual({ outcome: { outcome: "cancelled" } }); - expect(harness.snapshot().interactions.find((entry) => entry.id === lateId)).toMatchObject({ - status: "expired", - }); - - await harness.adapter.completePrompt(RUN_ID, { stopReason: "cancelled" }); - expect(harness.snapshot().runs[0]?.status).toBe("cancelled"); - expect(harness.snapshot().interactions.every((entry) => entry.status !== "open")).toBe(true); - }); - - test.each([ - [ - "tool payload", - (request: RequestPermissionRequest): RequestPermissionRequest => ({ - ...request, - toolCall: { ...request.toolCall, title: "Changed command" }, - }), - ], - [ - "permission options", - (request: RequestPermissionRequest): RequestPermissionRequest => ({ - ...request, - options: [{ ...request.options[0]!, name: "Changed choice" }], - }), - ], - ] as const)("rejects a permission replay with changed %s", async (_name, change) => { - const harness = createHarness(); - await registerRun(harness.adapter); - const request = { - options: [{ kind: "allow_once", name: "Allow", optionId: "yes" }], - sessionId: NATIVE_SESSION_ID, - toolCall: { title: "Run command", toolCallId: "tool-replay" }, - } satisfies RequestPermissionRequest; - await harness.adapter.openPermission(RUN_ID, request); - const before = harness.authority.length; - - await expect(harness.adapter.openPermission(RUN_ID, change(request))).rejects.toThrow( - "changed identity", - ); - expect(harness.authority).toHaveLength(before); - expect(harness.snapshot().interactions).toHaveLength(1); - }); - - test("bounds pending permission payloads and restores budget after resolution", async () => { - const request = (toolCallId: string) => - ({ - options: [{ kind: "allow_once", name: "Allow", optionId: "yes" }], - sessionId: NATIVE_SESSION_ID, - toolCall: { title: "Run command", toolCallId }, - }) satisfies RequestPermissionRequest; - const firstRequest = request("tool-1"); - const secondRequest = request("tool-2"); - const maxPendingPermissionBytes = new TextEncoder().encode( - JSON.stringify(firstRequest), - ).byteLength; - const harness = createHarness(5 * 60 * 1_000, undefined, maxPendingPermissionBytes); - await registerRun(harness.adapter); - const interactionId = await harness.adapter.openPermission(RUN_ID, firstRequest); - const authorityCount = harness.authority.length; - - await expect(harness.adapter.openPermission(RUN_ID, secondRequest)).rejects.toThrow( - "pending permission budget", - ); - expect(harness.authority).toHaveLength(authorityCount); - - const resolution = { - kind: "permission", - value: { type: "cancelled" }, - } satisfies InteractionResolution; - harness.settleInteraction(interactionId, resolution); - await harness.adapter.resolveInteraction(interactionId, resolution); - await expect(harness.adapter.openPermission(RUN_ID, secondRequest)).resolves.toBeDefined(); - }); - - test("coalesces concurrent permission replays within one byte reservation", async () => { - const blocked = Promise.withResolvers(); - const release = Promise.withResolvers(); - const request = { - options: [{ kind: "allow_once", name: "Allow", optionId: "yes" }], - sessionId: NATIVE_SESSION_ID, - toolCall: { title: "Run command", toolCallId: "tool-concurrent" }, - } satisfies RequestPermissionRequest; - const limit = new TextEncoder().encode(JSON.stringify(request)).byteLength; - let first = true; - const harness = createHarness(5 * 60 * 1_000, undefined, limit, async () => { - if (first) { - first = false; - blocked.resolve(); - await release.promise; - } - }); - await registerRun(harness.adapter); - const firstPermission = harness.adapter.openPermission(RUN_ID, request); - await blocked.promise; - const secondPermission = harness.adapter.openPermission(RUN_ID, request); - - release.resolve(); - const [firstId, secondId] = await Promise.all([firstPermission, secondPermission]); - expect(secondId).toBe(firstId); - expect(harness.snapshot().interactions).toHaveLength(1); - }); -}); diff --git a/tests/acp-v1-contract-adapter-permission.test.ts b/tests/acp-v1-contract-adapter-permission.test.ts deleted file mode 100644 index 24723d3..0000000 --- a/tests/acp-v1-contract-adapter-permission.test.ts +++ /dev/null @@ -1,464 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { RequestPermissionRequest, SessionNotification } from "@agentclientprotocol/sdk"; - -import { - AuthorityOutcomeUnknownError, - applyCommittedMutation, - interactionSchema, - validateSessionSnapshot, -} from "../src/contract"; -import type { - AuthorityOperation, - CommittedMutation, - InteractionResolution, - Run, - SessionSnapshot, -} from "../src/contract"; -import { AcpV1ContractAdapter } from "../src/runtimes/acp/v1-contract-adapter"; -import { - type ContractAuthorityUpdate, - type ContractPreviewUpdate, -} from "../src/runtimes/contract-projection"; - -const SESSION_ID = protocolId(1); -const RUN_ID = protocolId(2); -const NATIVE_SESSION_ID = "native-session-1"; - -function protocolId(value: number): string { - return value.toString().padStart(26, "0"); -} - -function activeRun(startedAt: string): Run { - return { - id: RUN_ID, - input: [{ text: "hello", type: "text" }], - origin: "user", - startedAt, - status: "active", - }; -} - -function createInitialSnapshot(capturedAt: string): SessionSnapshot { - return validateSessionSnapshot({ - capturedAt, - interactions: [], - items: [], - protocolVersion: 2, - revision: 0, - runs: [activeRun(capturedAt)], - session: { - capabilities: { - "interaction.permission": {}, - "item.artifact": {}, - "item.change": {}, - "item.plan": {}, - "item.reasoning": {}, - "item.terminal": {}, - }, - config: [], - createdAt: capturedAt, - id: SESSION_ID, - status: "open", - updatedAt: capturedAt, - }, - }); -} - -function createHarness( - interactionTimeoutMs = 5 * 60 * 1_000, - previewCheckpointBytes?: number, - maxPendingPermissionBytes?: number, - beforeAuthority?: (update: ContractAuthorityUpdate) => Promise, - afterAuthority?: (update: ContractAuthorityUpdate) => Promise, -) { - let nowMs = Date.parse("2026-07-16T08:00:00.000Z"); - let snapshot = createInitialSnapshot(new Date(nowMs).toISOString()); - let nextId = 100; - const authority: ContractAuthorityUpdate[] = []; - const committedMutationIds = new Set(); - const previews: ContractPreviewUpdate[] = []; - const commit = (cause: CommittedMutation["cause"], operations: AuthorityOperation[]): void => { - const revision = snapshot.revision + 1; - const mutation: CommittedMutation = { - baseRevision: snapshot.revision, - cause, - committedAt: new Date(nowMs).toISOString(), - mutationId: protocolId(1_000 + revision), - operations, - revision, - sessionId: SESSION_ID, - }; - snapshot = applyCommittedMutation(snapshot, mutation); - }; - const adapter = new AcpV1ContractAdapter({ - authority: async (update) => { - await beforeAuthority?.(update); - authority.push(update); - if (!committedMutationIds.has(update.mutationId)) { - committedMutationIds.add(update.mutationId); - commit(update.cause, [...update.operations] as AuthorityOperation[]); - } - try { - await afterAuthority?.(update); - } catch (cause) { - throw new AuthorityOutcomeUnknownError( - cause instanceof Error ? cause.message : "Authority outcome is unknown.", - { cause }, - ); - } - }, - createId: () => protocolId(nextId++), - interactionTimeoutMs, - maxPendingPermissionBytes, - nativeSessionId: NATIVE_SESSION_ID, - now: () => new Date(nowMs), - preview: (update) => previews.push(update), - previewCheckpointBytes, - sessionId: SESSION_ID, - }); - - return { - adapter, - advance(milliseconds: number) { - nowMs += milliseconds; - }, - authority, - previews, - settleInteraction(interactionId: string, resolution?: InteractionResolution) { - const interaction = snapshot.interactions.find((entry) => entry.id === interactionId); - - if (interaction === undefined || interaction.status !== "open") { - throw new Error("The test interaction must be open."); - } - - if (resolution !== undefined && resolution.kind !== interaction.kind) { - throw new Error("The test resolution kind must match the interaction kind."); - } - - const endedAt = new Date(nowMs).toISOString(); - commit({ commandId: protocolId(2_000 + snapshot.revision + 1), type: "command" }, [ - { - entity: "interaction", - op: "put", - value: interactionSchema.parse( - resolution === undefined - ? { ...interaction, endedAt, status: "expired" } - : { - ...interaction, - endedAt, - resolution: resolution.value, - status: "resolved", - }, - ), - }, - ]); - }, - snapshot: () => snapshot, - }; -} - -async function registerRun(adapter: AcpV1ContractAdapter): Promise { - adapter.attachRun(activeRun("2026-07-16T08:00:00.000Z")); -} - -function notification(update: SessionNotification["update"]): SessionNotification { - return { sessionId: NATIVE_SESSION_ID, update }; -} - -describe("ACP V1 Contract adapter", () => { - test("retries the exact permission intent after an ambiguous authority result", async () => { - let failAfterCommit = true; - const harness = createHarness( - 5 * 60 * 1_000, - undefined, - undefined, - undefined, - async (update) => { - if (failAfterCommit && update.event === "permission/requested") { - failAfterCommit = false; - throw new Error("ambiguous authority result"); - } - }, - ); - await registerRun(harness.adapter); - const request = { - options: [{ kind: "allow_once", name: "Allow", optionId: "yes" }], - sessionId: NATIVE_SESSION_ID, - toolCall: { title: "Run command", toolCallId: "tool-ambiguous" }, - } satisfies RequestPermissionRequest; - - await expect(harness.adapter.openPermission(RUN_ID, request)).rejects.toThrow("ambiguous"); - const first = harness.authority.filter((update) => update.event === "permission/requested")[0]!; - harness.advance(1_000); - await expect( - harness.adapter.openPermission(RUN_ID, { - ...request, - toolCall: { ...request.toolCall, toolCallId: "tool-other" }, - }), - ).rejects.toBeInstanceOf(AuthorityOutcomeUnknownError); - await expect(harness.adapter.openPermission(RUN_ID, request)).resolves.toBe( - (first.operations[0] as { value: { id: string } }).value.id, - ); - const retries = harness.authority.filter((update) => update.event === "permission/requested"); - - expect(retries).toHaveLength(2); - expect(retries[1]).toEqual(first); - }); - - test.each(["cancelled", "selected"] as const)( - "coordinates an unknown permission replay after the Interaction is %s", - async (outcome) => { - let loseResult = true; - const request = { - options: [{ kind: "allow_once", name: "Allow", optionId: "yes" }], - sessionId: NATIVE_SESSION_ID, - toolCall: { title: "Run command", toolCallId: "tool-resolved-unknown" }, - } satisfies RequestPermissionRequest; - const limit = new TextEncoder().encode(JSON.stringify(request)).byteLength; - const harness = createHarness(5 * 60 * 1_000, undefined, limit, undefined, async (update) => { - if (loseResult && update.event === "permission/requested") { - loseResult = false; - throw new Error("authority result lost"); - } - }); - await registerRun(harness.adapter); - - await expect(harness.adapter.openPermission(RUN_ID, request)).rejects.toBeInstanceOf( - AuthorityOutcomeUnknownError, - ); - const first = harness.authority.find((update) => update.event === "permission/requested")!; - const interactionId = (first.operations[0] as { value: { id: string } }).value.id; - const resolution = - outcome === "selected" - ? ({ - kind: "permission", - value: { optionId: "permission-option:yes", type: "selected" }, - } satisfies InteractionResolution) - : ({ - kind: "permission", - value: { type: "cancelled" }, - } satisfies InteractionResolution); - harness.settleInteraction(interactionId, resolution); - await expect(harness.adapter.resolveInteraction(interactionId, resolution)).resolves.toEqual( - outcome === "selected" - ? { outcome: { optionId: "yes", outcome: "selected" } } - : { outcome: { outcome: "cancelled" } }, - ); - await expect( - harness.adapter.openPermission(RUN_ID, { - ...request, - toolCall: { ...request.toolCall, toolCallId: "tool-changed" }, - }), - ).rejects.toBeInstanceOf(AuthorityOutcomeUnknownError); - - harness.advance(1_000); - await expect(harness.adapter.openPermission(RUN_ID, request)).resolves.toBe(interactionId); - const retries = harness.authority.filter((update) => update.event === "permission/requested"); - expect(retries).toEqual([first, first]); - expect(harness.snapshot().interactions.find(({ id }) => id === interactionId)?.status).toBe( - "resolved", - ); - await expect( - harness.adapter.openPermission(RUN_ID, { - ...request, - toolCall: { ...request.toolCall, toolCallId: "tool-next-permission" }, - }), - ).resolves.toBeDefined(); - }, - ); - - test("releases a retained permission budget when its unknown retry is definitely rejected", async () => { - const request = (toolCallId: string) => - ({ - options: [{ kind: "allow_once", name: "Allow", optionId: "yes" }], - sessionId: NATIVE_SESSION_ID, - toolCall: { title: "Run command", toolCallId }, - }) satisfies RequestPermissionRequest; - const first = request("tool-a"); - const second = request("tool-b"); - const limit = new TextEncoder().encode(JSON.stringify(first)).byteLength; - let rejectRetry = false; - let loseFirstResult = true; - const harness = createHarness( - 5 * 60 * 1_000, - undefined, - limit, - async (update) => { - if (rejectRetry && update.event === "permission/requested") { - rejectRetry = false; - throw new Error("authority unavailable"); - } - }, - async (update) => { - if (loseFirstResult && update.event === "permission/requested") { - loseFirstResult = false; - rejectRetry = true; - throw new Error("authority result lost"); - } - }, - ); - await registerRun(harness.adapter); - - await expect(harness.adapter.openPermission(RUN_ID, first)).rejects.toBeInstanceOf( - AuthorityOutcomeUnknownError, - ); - await expect(harness.adapter.openPermission(RUN_ID, first)).rejects.toThrow( - "authority unavailable", - ); - await expect(harness.adapter.openPermission(RUN_ID, second)).resolves.toBeDefined(); - }); - - test("applies the permission byte budget atomically", async () => { - const request = (toolCallId: string) => - ({ - options: [{ kind: "allow_once", name: "Allow", optionId: "yes" }], - sessionId: NATIVE_SESSION_ID, - toolCall: { title: "Run command", toolCallId }, - }) satisfies RequestPermissionRequest; - const firstRequest = request("tool-aaa"); - const secondRequest = request("tool-bbb"); - const limit = new TextEncoder().encode(JSON.stringify(firstRequest)).byteLength; - const blocked = Promise.withResolvers(); - const release = Promise.withResolvers(); - let first = true; - const harness = createHarness(5 * 60 * 1_000, undefined, limit, async () => { - if (first) { - first = false; - blocked.resolve(); - await release.promise; - } - }); - await registerRun(harness.adapter); - const firstPermission = harness.adapter.openPermission(RUN_ID, firstRequest); - await blocked.promise; - const secondPermission = harness.adapter.openPermission(RUN_ID, secondRequest); - let secondState: "pending" | "rejected" | "resolved" = "pending"; - void secondPermission.then( - () => { - secondState = "resolved"; - }, - () => { - secondState = "rejected"; - }, - ); - await Promise.resolve(); - await Promise.resolve(); - - expect(secondState).toBe("rejected"); - - release.resolve(); - await expect(firstPermission).resolves.toBeDefined(); - await expect(secondPermission).rejects.toThrow("pending permission budget"); - expect(harness.snapshot().interactions).toHaveLength(1); - }); - - test.each([ - ["tool", "permission/requested.tool"], - ["interaction", "permission/requested"], - ] as const)( - "releases permission budget after a definite %s Authority rejection", - async (_stage, failedEvent) => { - const request = (toolCallId: string) => - ({ - options: [{ kind: "allow_once", name: "Allow", optionId: "yes" }], - sessionId: NATIVE_SESSION_ID, - toolCall: { title: "Run command", toolCallId }, - }) satisfies RequestPermissionRequest; - const firstRequest = request("tool-aaa"); - const secondRequest = request("tool-bbb"); - const limit = new TextEncoder().encode(JSON.stringify(firstRequest)).byteLength; - let fail = true; - const harness = createHarness(5 * 60 * 1_000, undefined, limit, async (update) => { - if (fail && update.event === failedEvent) { - fail = false; - throw new Error("authority unavailable"); - } - }); - await registerRun(harness.adapter); - - await expect(harness.adapter.openPermission(RUN_ID, firstRequest)).rejects.toThrow( - "authority unavailable", - ); - const interactionId = await harness.adapter.openPermission(RUN_ID, secondRequest); - const resolution = { - kind: "permission", - value: { type: "cancelled" }, - } satisfies InteractionResolution; - harness.settleInteraction(interactionId, resolution); - await harness.adapter.resolveInteraction(interactionId, resolution); - await expect(harness.adapter.openPermission(RUN_ID, firstRequest)).resolves.toBeDefined(); - }, - ); - - test("fails queued updates after the first projection failure", async () => { - let attempts = 0; - const harness = createHarness(5 * 60 * 1_000, undefined, undefined, async (update) => { - if (update.event === "session/agent_message_chunk") { - attempts += 1; - - if (attempts === 1) { - throw new Error("authority unavailable"); - } - } - }); - await registerRun(harness.adapter); - const update = (messageId: string) => - harness.adapter.handleSessionUpdate( - RUN_ID, - notification({ - content: { text: messageId, type: "text" }, - messageId, - sessionUpdate: "agent_message_chunk", - }), - ); - const settled = await Promise.allSettled([update("first"), update("second")]); - - expect(settled.map((result) => result.status)).toEqual(["rejected", "rejected"]); - expect(attempts).toBe(1); - await expect( - harness.adapter.completePrompt(RUN_ID, { stopReason: "end_turn" }), - ).rejects.toThrow("authority unavailable"); - }); - - test("recovers an ambiguous session update after rejecting a changed retry", async () => { - const mutationIds: string[] = []; - const harness = createHarness(5 * 60 * 1_000, undefined, undefined, async (update) => { - if (update.event === "session/tool_call") { - mutationIds.push(update.mutationId); - - if (mutationIds.length === 1) { - throw new AuthorityOutcomeUnknownError("authority result lost"); - } - } - }); - await registerRun(harness.adapter); - const tool = { - content: [], - kind: "execute", - rawInput: { command: "true" }, - sessionUpdate: "tool_call", - status: "in_progress", - title: "Run command", - toolCallId: "tool-1", - } satisfies SessionNotification["update"]; - const update = notification(tool); - await expect(harness.adapter.handleSessionUpdate(RUN_ID, update)).rejects.toThrow( - "authority result lost", - ); - harness.advance(1_000); - - await expect( - harness.adapter.handleSessionUpdate( - RUN_ID, - notification({ ...tool, title: "Changed command" }), - ), - ).rejects.toThrow("changed while its outcome was unknown"); - expect(mutationIds).toHaveLength(1); - - await harness.adapter.handleSessionUpdate(RUN_ID, update); - await harness.adapter.completePrompt(RUN_ID, { stopReason: "end_turn" }); - - expect(mutationIds).toEqual([mutationIds[0], mutationIds[0]]); - expect(harness.snapshot().runs[0]?.status).toBe("completed"); - }); -}); diff --git a/tests/acp-v1-contract-adapter-run-lifecycle.test.ts b/tests/acp-v1-contract-adapter-run-lifecycle.test.ts deleted file mode 100644 index c4433ea..0000000 --- a/tests/acp-v1-contract-adapter-run-lifecycle.test.ts +++ /dev/null @@ -1,514 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { SessionNotification } from "@agentclientprotocol/sdk"; - -import { - AuthorityOutcomeUnknownError, - applyCommittedMutation, - interactionSchema, - validateSessionSnapshot, -} from "../src/contract"; -import type { - AuthorityOperation, - CommittedMutation, - InteractionResolution, - Run, - SessionSnapshot, -} from "../src/contract"; -import { AcpV1ContractAdapter } from "../src/runtimes/acp/v1-contract-adapter"; -import { - type ContractAuthorityUpdate, - type ContractPreviewUpdate, -} from "../src/runtimes/contract-projection"; - -const SESSION_ID = protocolId(1); -const RUN_ID = protocolId(2); -const NATIVE_SESSION_ID = "native-session-1"; - -function protocolId(value: number): string { - return value.toString().padStart(26, "0"); -} - -function activeRun(startedAt: string): Run { - return { - id: RUN_ID, - input: [{ text: "hello", type: "text" }], - origin: "user", - startedAt, - status: "active", - }; -} - -function createInitialSnapshot(capturedAt: string): SessionSnapshot { - return validateSessionSnapshot({ - capturedAt, - interactions: [], - items: [], - protocolVersion: 2, - revision: 0, - runs: [activeRun(capturedAt)], - session: { - capabilities: { - "interaction.permission": {}, - "item.artifact": {}, - "item.change": {}, - "item.plan": {}, - "item.reasoning": {}, - "item.terminal": {}, - }, - config: [], - createdAt: capturedAt, - id: SESSION_ID, - status: "open", - updatedAt: capturedAt, - }, - }); -} - -function createHarness( - interactionTimeoutMs = 5 * 60 * 1_000, - previewCheckpointBytes?: number, - maxPendingPermissionBytes?: number, - beforeAuthority?: (update: ContractAuthorityUpdate) => Promise, - afterAuthority?: (update: ContractAuthorityUpdate) => Promise, -) { - let nowMs = Date.parse("2026-07-16T08:00:00.000Z"); - let snapshot = createInitialSnapshot(new Date(nowMs).toISOString()); - let nextId = 100; - const authority: ContractAuthorityUpdate[] = []; - const committedMutationIds = new Set(); - const previews: ContractPreviewUpdate[] = []; - const commit = (cause: CommittedMutation["cause"], operations: AuthorityOperation[]): void => { - const revision = snapshot.revision + 1; - const mutation: CommittedMutation = { - baseRevision: snapshot.revision, - cause, - committedAt: new Date(nowMs).toISOString(), - mutationId: protocolId(1_000 + revision), - operations, - revision, - sessionId: SESSION_ID, - }; - snapshot = applyCommittedMutation(snapshot, mutation); - }; - const adapter = new AcpV1ContractAdapter({ - authority: async (update) => { - await beforeAuthority?.(update); - authority.push(update); - if (!committedMutationIds.has(update.mutationId)) { - committedMutationIds.add(update.mutationId); - commit(update.cause, [...update.operations] as AuthorityOperation[]); - } - try { - await afterAuthority?.(update); - } catch (cause) { - throw new AuthorityOutcomeUnknownError( - cause instanceof Error ? cause.message : "Authority outcome is unknown.", - { cause }, - ); - } - }, - createId: () => protocolId(nextId++), - interactionTimeoutMs, - maxPendingPermissionBytes, - nativeSessionId: NATIVE_SESSION_ID, - now: () => new Date(nowMs), - preview: (update) => previews.push(update), - previewCheckpointBytes, - sessionId: SESSION_ID, - }); - - return { - adapter, - advance(milliseconds: number) { - nowMs += milliseconds; - }, - authority, - previews, - settleInteraction(interactionId: string, resolution?: InteractionResolution) { - const interaction = snapshot.interactions.find((entry) => entry.id === interactionId); - - if (interaction === undefined || interaction.status !== "open") { - throw new Error("The test interaction must be open."); - } - - if (resolution !== undefined && resolution.kind !== interaction.kind) { - throw new Error("The test resolution kind must match the interaction kind."); - } - - const endedAt = new Date(nowMs).toISOString(); - commit({ commandId: protocolId(2_000 + snapshot.revision + 1), type: "command" }, [ - { - entity: "interaction", - op: "put", - value: interactionSchema.parse( - resolution === undefined - ? { ...interaction, endedAt, status: "expired" } - : { - ...interaction, - endedAt, - resolution: resolution.value, - status: "resolved", - }, - ), - }, - ]); - }, - snapshot: () => snapshot, - }; -} - -async function registerRun(adapter: AcpV1ContractAdapter): Promise { - adapter.attachRun(activeRun("2026-07-16T08:00:00.000Z")); -} - -function notification(update: SessionNotification["update"]): SessionNotification { - return { sessionId: NATIVE_SESSION_ID, update }; -} - -describe("ACP V1 Contract adapter", () => { - test("checkpoints full terminal snapshots without retaining or duplicating large Preview text", async () => { - const harness = createHarness(5 * 60 * 1_000, 5); - await registerRun(harness.adapter); - await harness.adapter.registerTerminal(RUN_ID, "terminal-1"); - const beforeCheckpoint = harness.authority.length; - - await harness.adapter.handleTerminalOutput(RUN_ID, "terminal-1", { - exitStatus: null, - output: "abcdef", - truncated: true, - }); - expect(harness.authority).toHaveLength(beforeCheckpoint + 1); - expect(harness.previews).toHaveLength(0); - expect(harness.snapshot().items[0]).toMatchObject({ - kind: "terminal", - status: "active", - stdout: [{ text: "abcdef", type: "text" }], - }); - - await harness.adapter.handleTerminalOutput(RUN_ID, "terminal-1", { - exitStatus: null, - output: "abcdef", - truncated: false, - }); - expect(harness.authority).toHaveLength(beforeCheckpoint + 1); - - await harness.adapter.handleTerminalExit(RUN_ID, "terminal-1", { - exitCode: 0, - signal: null, - }); - expect(harness.snapshot().items[0]).toMatchObject({ kind: "terminal", status: "active" }); - - await harness.adapter.completePrompt(RUN_ID, { stopReason: "end_turn" }); - expect(harness.snapshot().items[0]).toMatchObject({ - extensions: { - "agentclientprotocol.v1/terminal-output": { truncated: true }, - }, - exitCode: 0, - kind: "terminal", - status: "completed", - stdout: [{ text: "abcdef", type: "text" }], - }); - }); - - test("accepts the final terminal snapshot after wait-for-exit resolves", async () => { - const harness = createHarness(); - await registerRun(harness.adapter); - await harness.adapter.registerTerminal(RUN_ID, "terminal-tail"); - await harness.adapter.handleTerminalOutput(RUN_ID, "terminal-tail", { - exitStatus: null, - output: "prefix", - truncated: false, - }); - await harness.adapter.handleTerminalExit(RUN_ID, "terminal-tail", { - exitCode: 0, - signal: null, - }); - await harness.adapter.handleTerminalOutput(RUN_ID, "terminal-tail", { - exitStatus: { exitCode: 0, signal: null }, - output: "prefix-tail", - truncated: false, - }); - - expect(harness.snapshot().items[0]).toMatchObject({ - exitCode: 0, - kind: "terminal", - status: "completed", - stdout: [{ text: "prefix-tail", type: "text" }], - }); - }); - - test("ignores draft v1 plan operations that were not negotiated", async () => { - const harness = createHarness(); - await registerRun(harness.adapter); - - await harness.adapter.handleSessionUpdate( - RUN_ID, - notification({ - plan: { - entries: [], - planId: "draft-plan", - type: "items", - }, - sessionUpdate: "plan_update", - }), - ); - - expect(harness.snapshot().items).toHaveLength(0); - }); - - test("honors explicit empty collection replacements in tool updates", async () => { - const harness = createHarness(); - await registerRun(harness.adapter); - await harness.adapter.handleSessionUpdate( - RUN_ID, - notification({ - content: [ - { content: { text: "temporary", type: "text" }, type: "content" }, - { - newText: "new", - oldText: "old", - path: "/workspace/file.txt", - type: "diff", - }, - { terminalId: "terminal-1", type: "terminal" }, - ], - sessionUpdate: "tool_call", - title: "Read", - toolCallId: "tool-1", - }), - ); - await harness.adapter.handleSessionUpdate( - RUN_ID, - notification({ - content: [], - sessionUpdate: "tool_call_update", - toolCallId: "tool-1", - }), - ); - - const tool = harness.snapshot().items.find((item) => item.kind === "tool"); - expect(tool).toMatchObject({ - kind: "tool", - output: [], - }); - expect(tool).not.toHaveProperty("terminalItemId"); - expect(harness.snapshot().items.find((item) => item.kind === "change")).toMatchObject({ - changes: [], - status: "active", - }); - }); - - test("treats nullable v1 tool patch fields as omitted", async () => { - const harness = createHarness(); - await registerRun(harness.adapter); - await harness.adapter.handleSessionUpdate( - RUN_ID, - notification({ - content: [ - { content: { text: "kept", type: "text" }, type: "content" }, - { - newText: "new", - oldText: "old", - path: "/workspace/file.txt", - type: "diff", - }, - { terminalId: "terminal-1", type: "terminal" }, - ], - kind: "edit", - locations: [{ line: 2, path: "/workspace/file.txt" }], - rawInput: { path: "/workspace/file.txt" }, - rawOutput: { changed: true }, - sessionUpdate: "tool_call", - title: "Edit", - toolCallId: "tool-1", - }), - ); - await harness.adapter.handleSessionUpdate( - RUN_ID, - notification({ - content: null, - kind: null, - locations: null, - rawInput: null, - rawOutput: null, - sessionUpdate: "tool_call_update", - status: null, - title: null, - toolCallId: "tool-1", - }), - ); - - expect(harness.snapshot().items.find((item) => item.kind === "tool")).toMatchObject({ - category: "edit", - input: { path: "/workspace/file.txt" }, - locations: [{ line: 2, path: "/workspace/file.txt" }], - output: [{ text: "kept", type: "text" }], - status: "active", - structuredOutput: { changed: true }, - terminalItemId: "terminal:terminal-1", - title: "Edit", - }); - expect(harness.snapshot().items.find((item) => item.kind === "change")).toMatchObject({ - changes: [expect.objectContaining({ path: "/workspace/file.txt" })], - status: "active", - }); - }); - - test.each([ - ["raw output", { rawOutput: { late: true } }, { structuredOutput: { late: true } }], - [ - "locations", - { locations: [{ line: 7, path: "/workspace/late.txt" }] }, - { locations: [{ line: 7, path: "/workspace/late.txt" }] }, - ], - [ - "content", - { content: [{ content: { text: "late", type: "text" }, type: "content" }] }, - { output: [{ text: "late", type: "text" }] }, - ], - [ - "terminal reference", - { content: [{ terminalId: "terminal-late", type: "terminal" }] }, - { terminalItemId: "terminal:terminal-late" }, - ], - ] as const)("enriches a completed tool with a later %s patch", async (_name, patch, expected) => { - const harness = createHarness(); - await registerRun(harness.adapter); - await harness.adapter.handleSessionUpdate( - RUN_ID, - notification({ - kind: "execute", - rawInput: { command: "true" }, - sessionUpdate: "tool_call", - status: "completed", - title: "Run command", - toolCallId: "tool-late", - }), - ); - const endedAt = harness.snapshot().items.find((item) => item.kind === "tool")?.endedAt; - - await harness.adapter.handleSessionUpdate( - RUN_ID, - notification({ - ...patch, - sessionUpdate: "tool_call_update", - toolCallId: "tool-late", - } as SessionNotification["update"]), - ); - - expect(harness.snapshot().items.find((item) => item.kind === "tool")).toMatchObject({ - category: "execute", - endedAt, - input: { command: "true" }, - status: "completed", - title: "Run command", - ...expected, - }); - }); - - test("ignores a completed tool replay that carries no new content", async () => { - const harness = createHarness(); - await registerRun(harness.adapter); - await harness.adapter.handleSessionUpdate( - RUN_ID, - notification({ - kind: "execute", - rawInput: { command: "true" }, - sessionUpdate: "tool_call", - status: "in_progress", - title: "Run command", - toolCallId: "tool-replay", - }), - ); - const completed = notification({ - rawOutput: { exitCode: 0 }, - sessionUpdate: "tool_call_update", - status: "completed", - toolCallId: "tool-replay", - }); - await harness.adapter.handleSessionUpdate(RUN_ID, completed); - const authorityCount = harness.authority.length; - - harness.advance(1_000); - await expect(harness.adapter.handleSessionUpdate(RUN_ID, completed)).resolves.toBeNull(); - - expect(harness.authority).toHaveLength(authorityCount); - expect(harness.snapshot().items.find((item) => item.kind === "tool")).toMatchObject({ - input: { command: "true" }, - status: "completed", - structuredOutput: { exitCode: 0 }, - }); - }); - - test.each(["completed", "failed"] as const)( - "propagates status-only %s tool updates to existing file changes", - async (status) => { - const harness = createHarness(); - await registerRun(harness.adapter); - await harness.adapter.handleSessionUpdate( - RUN_ID, - notification({ - content: [ - { - newText: "new", - oldText: "old", - path: "/workspace/file.txt", - type: "diff", - }, - ], - sessionUpdate: "tool_call", - title: "Edit", - toolCallId: "tool-1", - }), - ); - await harness.adapter.handleSessionUpdate( - RUN_ID, - notification({ - sessionUpdate: "tool_call_update", - status, - toolCallId: "tool-1", - }), - ); - - expect(harness.snapshot().items.find((item) => item.kind === "change")).toMatchObject({ - changes: [ - { - diff: { - type: "json", - value: { newText: "new", oldText: "old" }, - }, - operation: "update", - path: "/workspace/file.txt", - }, - ], - status, - }); - }, - ); - - test("rejects empty permission choices before creating authority state", async () => { - const harness = createHarness(); - await registerRun(harness.adapter); - - await expect( - harness.adapter.openPermission(RUN_ID, { - options: [], - sessionId: NATIVE_SESSION_ID, - toolCall: { title: "Read", toolCallId: "tool-1" }, - }), - ).rejects.toThrow("at least one option"); - expect(harness.snapshot().items).toHaveLength(0); - expect(harness.snapshot().interactions).toHaveLength(0); - }); - - test.each([Number.POSITIVE_INFINITY, 1.5])("rejects invalid timeout %p", (value) => { - expect(() => createHarness(value)).toThrow("finite and positive"); - }); - - test.each([Number.POSITIVE_INFINITY, 1.5])( - "rejects invalid pending permission limit %p", - (value) => { - expect(() => createHarness(5 * 60 * 1_000, undefined, value)).toThrow("finite and positive"); - }, - ); -}); diff --git a/tests/acp-v1-contract-adapter-terminal.test.ts b/tests/acp-v1-contract-adapter-terminal.test.ts deleted file mode 100644 index 90ee4ab..0000000 --- a/tests/acp-v1-contract-adapter-terminal.test.ts +++ /dev/null @@ -1,522 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { - CreateTerminalRequest, - RequestPermissionRequest, - SessionConfigOption, - SessionNotification, -} from "@agentclientprotocol/sdk"; - -import { - AuthorityOutcomeUnknownError, - applyCommittedMutation, - interactionSchema, - validateSessionSnapshot, -} from "../src/contract"; -import type { - AuthorityOperation, - CommittedMutation, - InteractionResolution, - Run, - SessionSnapshot, -} from "../src/contract"; -import { AcpV1ContractAdapter, toConfigOptions } from "../src/runtimes/acp/v1-contract-adapter"; -import { - type ContractAuthorityUpdate, - type ContractPreviewUpdate, -} from "../src/runtimes/contract-projection"; - -const SESSION_ID = protocolId(1); -const RUN_ID = protocolId(2); -const NATIVE_SESSION_ID = "native-session-1"; - -function protocolId(value: number): string { - return value.toString().padStart(26, "0"); -} - -function activeRun(startedAt: string): Run { - return { - id: RUN_ID, - input: [{ text: "hello", type: "text" }], - origin: "user", - startedAt, - status: "active", - }; -} - -function createInitialSnapshot(capturedAt: string): SessionSnapshot { - return validateSessionSnapshot({ - capturedAt, - interactions: [], - items: [], - protocolVersion: 2, - revision: 0, - runs: [activeRun(capturedAt)], - session: { - capabilities: { - "interaction.permission": {}, - "item.artifact": {}, - "item.change": {}, - "item.plan": {}, - "item.reasoning": {}, - "item.terminal": {}, - }, - config: [], - createdAt: capturedAt, - id: SESSION_ID, - status: "open", - updatedAt: capturedAt, - }, - }); -} - -function createHarness( - interactionTimeoutMs = 5 * 60 * 1_000, - previewCheckpointBytes?: number, - maxPendingPermissionBytes?: number, - beforeAuthority?: (update: ContractAuthorityUpdate) => Promise, - afterAuthority?: (update: ContractAuthorityUpdate) => Promise, -) { - let nowMs = Date.parse("2026-07-16T08:00:00.000Z"); - let snapshot = createInitialSnapshot(new Date(nowMs).toISOString()); - let nextId = 100; - const authority: ContractAuthorityUpdate[] = []; - const committedMutationIds = new Set(); - const previews: ContractPreviewUpdate[] = []; - const commit = (cause: CommittedMutation["cause"], operations: AuthorityOperation[]): void => { - const revision = snapshot.revision + 1; - const mutation: CommittedMutation = { - baseRevision: snapshot.revision, - cause, - committedAt: new Date(nowMs).toISOString(), - mutationId: protocolId(1_000 + revision), - operations, - revision, - sessionId: SESSION_ID, - }; - snapshot = applyCommittedMutation(snapshot, mutation); - }; - const adapter = new AcpV1ContractAdapter({ - authority: async (update) => { - await beforeAuthority?.(update); - authority.push(update); - if (!committedMutationIds.has(update.mutationId)) { - committedMutationIds.add(update.mutationId); - commit(update.cause, [...update.operations] as AuthorityOperation[]); - } - try { - await afterAuthority?.(update); - } catch (cause) { - throw new AuthorityOutcomeUnknownError( - cause instanceof Error ? cause.message : "Authority outcome is unknown.", - { cause }, - ); - } - }, - createId: () => protocolId(nextId++), - interactionTimeoutMs, - maxPendingPermissionBytes, - nativeSessionId: NATIVE_SESSION_ID, - now: () => new Date(nowMs), - preview: (update) => previews.push(update), - previewCheckpointBytes, - sessionId: SESSION_ID, - }); - - return { - adapter, - advance(milliseconds: number) { - nowMs += milliseconds; - }, - authority, - previews, - settleInteraction(interactionId: string, resolution?: InteractionResolution) { - const interaction = snapshot.interactions.find((entry) => entry.id === interactionId); - - if (interaction === undefined || interaction.status !== "open") { - throw new Error("The test interaction must be open."); - } - - if (resolution !== undefined && resolution.kind !== interaction.kind) { - throw new Error("The test resolution kind must match the interaction kind."); - } - - const endedAt = new Date(nowMs).toISOString(); - commit({ commandId: protocolId(2_000 + snapshot.revision + 1), type: "command" }, [ - { - entity: "interaction", - op: "put", - value: interactionSchema.parse( - resolution === undefined - ? { ...interaction, endedAt, status: "expired" } - : { - ...interaction, - endedAt, - resolution: resolution.value, - status: "resolved", - }, - ), - }, - ]); - }, - snapshot: () => snapshot, - }; -} - -async function registerRun(adapter: AcpV1ContractAdapter): Promise { - adapter.attachRun(activeRun("2026-07-16T08:00:00.000Z")); -} - -function notification(update: SessionNotification["update"]): SessionNotification { - return { sessionId: NATIVE_SESSION_ID, update }; -} - -describe("ACP V1 Contract adapter", () => { - test("rejects a pre-admitted follower without locking out an explicit retry", async () => { - const entered = Promise.withResolvers(); - const release = Promise.withResolvers(); - const mutationIds: string[] = []; - let first = true; - const harness = createHarness(5 * 60 * 1_000, undefined, undefined, async (update) => { - if (update.event !== "session/tool_call") { - return; - } - - mutationIds.push(update.mutationId); - if (first) { - first = false; - entered.resolve(); - await release.promise; - throw new AuthorityOutcomeUnknownError("authority result lost"); - } - }); - await registerRun(harness.adapter); - const update = notification({ - sessionUpdate: "tool_call", - status: "in_progress", - title: "Run command", - toolCallId: "tool-follower", - }); - const leader = harness.adapter.handleSessionUpdate(RUN_ID, update); - await entered.promise; - const follower = harness.adapter.handleSessionUpdate(RUN_ID, update); - release.resolve(); - - const settled = await Promise.allSettled([leader, follower]); - expect(settled.map((result) => result.status)).toEqual(["rejected", "rejected"]); - expect( - settled.every( - (result) => - result.status === "rejected" && result.reason instanceof AuthorityOutcomeUnknownError, - ), - ).toBe(true); - expect(mutationIds).toHaveLength(1); - - await harness.adapter.handleSessionUpdate(RUN_ID, update); - expect(mutationIds).toEqual([mutationIds[0], mutationIds[0]]); - }); - - test("coalesces concurrent exact retries of one unknown session update", async () => { - let first = true; - const harness = createHarness(5 * 60 * 1_000, undefined, undefined, async (update) => { - if (first && update.event === "session/agent_message_chunk") { - first = false; - throw new AuthorityOutcomeUnknownError("authority result lost"); - } - }); - await registerRun(harness.adapter); - const update = notification({ - content: { text: "x", type: "text" }, - messageId: "message-retry", - sessionUpdate: "agent_message_chunk", - }); - await expect(harness.adapter.handleSessionUpdate(RUN_ID, update)).rejects.toBeInstanceOf( - AuthorityOutcomeUnknownError, - ); - - await Promise.all([ - harness.adapter.handleSessionUpdate(RUN_ID, update), - harness.adapter.handleSessionUpdate(RUN_ID, update), - ]); - await harness.adapter.completePrompt(RUN_ID, { stopReason: "end_turn" }); - - expect(harness.snapshot().items).toContainEqual( - expect.objectContaining({ - content: [{ text: "x", type: "text" }], - id: "message:message-retry", - }), - ); - }); - - test("freezes a permission tool mutation across an unknown exact retry", async () => { - const mutationIds: string[] = []; - let first = true; - const harness = createHarness(5 * 60 * 1_000, undefined, undefined, async (update) => { - if (update.event !== "permission/requested.tool") { - return; - } - - mutationIds.push(update.mutationId); - if (first) { - first = false; - throw new AuthorityOutcomeUnknownError("authority result lost"); - } - }); - await registerRun(harness.adapter); - const request = { - options: [{ kind: "allow_once", name: "Allow", optionId: "yes" }], - sessionId: NATIVE_SESSION_ID, - toolCall: { title: "Run command", toolCallId: "tool-time" }, - } satisfies RequestPermissionRequest; - - await expect(harness.adapter.openPermission(RUN_ID, request)).rejects.toBeInstanceOf( - AuthorityOutcomeUnknownError, - ); - harness.advance(1_000); - await expect(harness.adapter.openPermission(RUN_ID, request)).resolves.toBeDefined(); - - expect(mutationIds).toEqual([mutationIds[0], mutationIds[0]]); - }); - - test("freezes terminal registration across an unknown exact retry", async () => { - const mutationIds: string[] = []; - let first = true; - const harness = createHarness(5 * 60 * 1_000, undefined, undefined, async (update) => { - if (update.event !== "terminal/created") { - return; - } - - mutationIds.push(update.mutationId); - if (first) { - first = false; - throw new AuthorityOutcomeUnknownError("authority result lost"); - } - }); - await registerRun(harness.adapter); - const request = { - command: "true", - sessionId: NATIVE_SESSION_ID, - } satisfies CreateTerminalRequest; - - await expect( - harness.adapter.registerTerminal(RUN_ID, "terminal-time", request), - ).rejects.toBeInstanceOf(AuthorityOutcomeUnknownError); - harness.advance(1_000); - await expect(harness.adapter.registerTerminal(RUN_ID, "terminal-time", request)).resolves.toBe( - "terminal:terminal-time", - ); - - expect(mutationIds).toEqual([mutationIds[0], mutationIds[0]]); - }); - - test("drains streamed updates before completing the Run", async () => { - const blocked = Promise.withResolvers(); - const release = Promise.withResolvers(); - const harness = createHarness(5 * 60 * 1_000, undefined, undefined, async (update) => { - if (update.event === "session/agent_message_chunk") { - blocked.resolve(); - await release.promise; - } - }); - await registerRun(harness.adapter); - const update = harness.adapter.handleSessionUpdate( - RUN_ID, - notification({ - content: { text: "last", type: "text" }, - messageId: "message-last", - sessionUpdate: "agent_message_chunk", - }), - ); - await blocked.promise; - let completed = false; - const completion = harness.adapter - .completePrompt(RUN_ID, { stopReason: "end_turn" }) - .then(() => { - completed = true; - }); - await Bun.sleep(0); - const completedBeforeUpdate = completed; - - release.resolve(); - await Promise.all([update, completion]); - expect(completedBeforeUpdate).toBe(false); - expect(harness.snapshot().runs[0]?.status).toBe("completed"); - expect(harness.snapshot().items).toContainEqual( - expect.objectContaining({ - content: [{ text: "last", type: "text" }], - kind: "message", - status: "completed", - }), - ); - }); - - test("drains terminal authority writes before completing the Run", async () => { - const blocked = Promise.withResolvers(); - const release = Promise.withResolvers(); - const harness = createHarness(5 * 60 * 1_000, 5, undefined, async (update) => { - if (update.event === "preview/replace.checkpoint") { - blocked.resolve(); - await release.promise; - } - }); - await registerRun(harness.adapter); - await harness.adapter.registerTerminal(RUN_ID, "terminal-1"); - const output = harness.adapter.handleTerminalOutput(RUN_ID, "terminal-1", { - exitStatus: null, - output: "abcdef", - truncated: false, - }); - await blocked.promise; - let completed = false; - const completion = harness.adapter - .completePrompt(RUN_ID, { stopReason: "end_turn" }) - .then(() => { - completed = true; - }); - await Bun.sleep(0); - - expect(completed).toBe(false); - release.resolve(); - await Promise.all([output, completion]); - expect(harness.snapshot().runs[0]?.status).toBe("completed"); - }); - - test("drains a permission authority write before completing the Run", async () => { - const blocked = Promise.withResolvers(); - const release = Promise.withResolvers(); - const harness = createHarness(5 * 60 * 1_000, undefined, undefined, async (update) => { - if (update.event === "permission/requested") { - blocked.resolve(); - await release.promise; - } - }); - await registerRun(harness.adapter); - const permission = harness.adapter.openPermission(RUN_ID, { - options: [{ kind: "allow_once", name: "Allow", optionId: "yes" }], - sessionId: NATIVE_SESSION_ID, - toolCall: { title: "Run command", toolCallId: "tool-last" }, - }); - await blocked.promise; - let completed = false; - const completion = harness.adapter - .completePrompt(RUN_ID, { stopReason: "end_turn" }) - .then(() => { - completed = true; - }); - await Bun.sleep(0); - - expect(completed).toBe(false); - release.resolve(); - await Promise.all([permission, completion]); - expect(harness.snapshot().runs[0]?.status).toBe("completed"); - expect(harness.snapshot().interactions.every((entry) => entry.status !== "open")).toBe(true); - }); - - test.each([ - [ - "prompt completion", - (adapter: AcpV1ContractAdapter) => adapter.completePrompt(RUN_ID, { stopReason: "end_turn" }), - ], - [ - "session update", - (adapter: AcpV1ContractAdapter) => - adapter.handleSessionUpdate( - RUN_ID, - notification({ - content: { text: "late", type: "text" }, - messageId: "late-message", - sessionUpdate: "agent_message_chunk", - }), - ), - ], - [ - "terminal output", - (adapter: AcpV1ContractAdapter) => - adapter.handleTerminalOutput(RUN_ID, "terminal-late", { - exitStatus: null, - output: "late", - truncated: false, - }), - ], - [ - "terminal exit", - (adapter: AcpV1ContractAdapter) => - adapter.handleTerminalExit(RUN_ID, "terminal-late", { - exitCode: 0, - signal: null, - }), - ], - ] as const)("ignores late %s after the Run is terminal", async (_name, lateEvent) => { - const harness = createHarness(); - await registerRun(harness.adapter); - await harness.adapter.registerTerminal(RUN_ID, "terminal-late"); - await harness.adapter.completePrompt(RUN_ID, { stopReason: "end_turn" }); - const before = harness.authority.length; - - await lateEvent(harness.adapter); - expect(harness.authority).toHaveLength(before); - }); - - test("returns session-scoped updates and rejects the wrong native session", async () => { - const harness = createHarness(); - await registerRun(harness.adapter); - const update = { - configOptions: [], - sessionUpdate: "config_option_update", - } satisfies SessionNotification["update"]; - - await expect( - harness.adapter.handleSessionUpdate(RUN_ID, notification(update)), - ).resolves.toEqual(update); - await expect( - harness.adapter.handleSessionUpdate(RUN_ID, { - sessionId: "other-session", - update, - }), - ).rejects.toThrow("active native session"); - }); - - test("maps stable boolean and grouped select options into the Session config", () => { - const options = [ - { - currentValue: true, - id: "auto-approve", - name: "Auto approve", - type: "boolean", - }, - { - currentValue: "fast", - id: "model", - name: "Model", - options: [ - { - group: "recommended", - name: "Recommended", - options: [{ name: "Fast", value: "fast" }], - }, - ], - type: "select", - }, - ] satisfies SessionConfigOption[]; - - expect(toConfigOptions(options)).toEqual([ - { - id: "auto-approve", - label: "Auto approve", - type: "boolean", - value: true, - }, - { - choices: [{ id: "fast", label: "Fast" }], - extensions: { - "agentclientprotocol.v1/select-groups": [ - { id: "recommended", label: "Recommended", optionIds: ["fast"] }, - ], - }, - id: "model", - label: "Model", - type: "select", - value: "fast", - }, - ]); - }); -}); diff --git a/tests/agent-backend-lifecycle.test.ts b/tests/agent-backend-lifecycle.test.ts new file mode 100644 index 0000000..97ecb87 --- /dev/null +++ b/tests/agent-backend-lifecycle.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, jest, test } from "bun:test"; + +import type { AgentDriverContext } from "../src/core/agent-driver-backend"; +import { AgentBackendLifecycle } from "../src/core/agent-backend-lifecycle"; +import { createBackend } from "./driver-runtime-boundary-fixtures"; + +describe("AgentBackendLifecycle", () => { + test("shares a failed stop owner and retries only after it settles", async () => { + const firstStopEntered = Promise.withResolvers(); + const releaseFirstStop = Promise.withResolvers(); + const firstFailure = new Error("first stop failed"); + const backend = createBackend(); + let activeStops = 0; + let maxActiveStops = 0; + let stopCount = 0; + backend.stop = async () => { + stopCount += 1; + activeStops += 1; + maxActiveStops = Math.max(maxActiveStops, activeStops); + + try { + if (stopCount === 1) { + firstStopEntered.resolve(); + await releaseFirstStop.promise; + throw firstFailure; + } + } finally { + activeStops -= 1; + } + }; + const lifecycle = new AgentBackendLifecycle({ + backend, + createContext: () => ({}) as AgentDriverContext, + labels: { + finalStop: "test final stop", + start: "test start", + stop: "test stop", + }, + shutdownSignal: new AbortController().signal, + startTimeoutMs: 1_000, + stopTimeoutMs: 1_000, + }); + + const first = lifecycle.shutdown("first"); + await firstStopEntered.promise; + const concurrent = lifecycle.shutdown("concurrent"); + expect(stopCount).toBe(1); + + releaseFirstStop.resolve(); + const results = await Promise.allSettled([first, concurrent]); + expect(results).toEqual([ + { reason: firstFailure, status: "rejected" }, + { reason: firstFailure, status: "rejected" }, + ]); + + await expect(lifecycle.shutdown("retry")).resolves.toBeUndefined(); + expect(stopCount).toBe(2); + expect(maxActiveStops).toBe(1); + }); + + test("bounds a final stop and serializes its retry behind late cleanup", async () => { + const backend = createBackend(); + const startEntered = Promise.withResolvers(); + const releaseStart = Promise.withResolvers(); + const firstStopEntered = Promise.withResolvers(); + const finalStopEntered = Promise.withResolvers(); + const finalStopAborted = Promise.withResolvers(); + const releaseFinalStop = Promise.withResolvers(); + const deferredComplete = Promise.withResolvers(); + let activeStops = 0; + let maxActiveStops = 0; + let stopCount = 0; + backend.start = async () => { + startEntered.resolve(); + await releaseStart.promise; + }; + backend.stop = async (_context, _reason, signal) => { + stopCount += 1; + activeStops += 1; + maxActiveStops = Math.max(maxActiveStops, activeStops); + try { + if (stopCount === 1) { + firstStopEntered.resolve(); + return; + } + if (stopCount === 2) { + finalStopEntered.resolve(); + signal.addEventListener("abort", () => finalStopAborted.resolve(), { once: true }); + await releaseFinalStop.promise; + } + } finally { + activeStops -= 1; + } + }; + const lifecycle = new AgentBackendLifecycle({ + backend, + createContext: () => ({}) as AgentDriverContext, + labels: { + finalStop: "test final stop", + start: "test start", + stop: "test stop", + }, + onDeferredStopComplete: deferredComplete.resolve, + shutdownSignal: new AbortController().signal, + startTimeoutMs: 10_000, + stopTimeoutMs: 1_000, + }); + jest.useFakeTimers({ now: 0 }); + + try { + const start = lifecycle.start(); + await startEntered.promise; + const shutdown = lifecycle.shutdown("test shutdown"); + await firstStopEntered.promise; + jest.setSystemTime(400); + releaseStart.resolve(); + await start; + await finalStopEntered.promise; + jest.advanceTimersByTime(600); + + await finalStopAborted.promise; + await expect(shutdown).rejects.toThrow("test final stop timed out after 600ms"); + const retry = lifecycle.shutdown("retry"); + await Promise.resolve(); + expect(stopCount).toBe(2); + releaseFinalStop.resolve(); + await expect(retry).resolves.toBeUndefined(); + await deferredComplete.promise; + expect(stopCount).toBe(3); + expect(maxActiveStops).toBe(1); + } finally { + releaseStart.resolve(); + releaseFinalStop.resolve(); + jest.useRealTimers(); + } + }); +}); diff --git a/tests/agent-driver-kernel-async-queue.test.ts b/tests/agent-driver-kernel-async-queue.test.ts index 8c3607c..532a233 100644 --- a/tests/agent-driver-kernel-async-queue.test.ts +++ b/tests/agent-driver-kernel-async-queue.test.ts @@ -7,7 +7,12 @@ import type { DriverEventInput } from "../src/protocol/events"; import { createDriverStartInputFromBootPayload } from "../src/protocol/start"; import type { RuntimeCommand } from "../src/runtime-command"; import { driverBootPayload } from "./driver-boot-payload-fixture"; -import { DRIVER_TEST_IDS, bootPayload, createBackend } from "./driver-runtime-boundary-fixtures"; +import { + DRIVER_TEST_IDS, + bootPayload, + createBackend, + settleBackendInput, +} from "./driver-runtime-boundary-fixtures"; const jsonBytes = (value: unknown) => Buffer.byteLength(JSON.stringify(value), "utf8"); @@ -161,6 +166,7 @@ describe("AgentDriverKernelCore", () => { delivery: "lossless", kind: "diagnostic.reported", payload: { message: "" }, + sourceEventId: "large-lossless-event", }; const event: DriverEventInput = { ...base, @@ -187,7 +193,7 @@ describe("AgentDriverKernelCore", () => { await expect(events.next()).resolves.toEqual({ done: true, value: undefined }); }); - test("can retry a run terminal after an oversized reserve admission fails", async () => { + test("omits an oversized run error before terminal queue admission", async () => { const kernel = new AgentDriverKernelCore({ backendFactory: () => createBackend() }); const events = kernel.events()[Symbol.asyncIterator](); kernel.beginRun(DRIVER_TEST_IDS.runId); @@ -199,25 +205,20 @@ describe("AgentDriverKernelCore", () => { message: "x".repeat(1_024 * 1_024), retryable: false, }), - ).rejects.toThrow("queue reserve exceeds 1048576 UTF-8 JSON bytes"); - const retryError = { - code: "retry", - details: {}, - message: "retry fits", - retryable: false, - }; - const retry = kernel.failRun(retryError); - Reflect.set(retryError, "code", "mutated"); - Reflect.set(retryError, "message", "mutated after admission"); - await expect(retry).resolves.toBeUndefined(); - await kernel.completeRun(); + ).resolves.toBeUndefined(); + await expect(kernel.completeRun()).rejects.toThrow("conflicts"); await kernel.stop("test.complete"); await expect(events.next()).resolves.toMatchObject({ done: false, value: { kind: "run.failed", - payload: { error: { code: "retry" } }, + payload: { + error: { + code: "driver.error_oversized", + details: { originalBytes: expect.any(Number) }, + }, + }, runId: DRIVER_TEST_IDS.runId, }, }); @@ -265,6 +266,144 @@ describe("AgentDriverKernelCore", () => { }, ); + test("commits only an admitted terminal for the active run", async () => { + const kernel = new AgentDriverKernelCore({ backendFactory: () => createBackend() }); + const events = kernel.events()[Symbol.asyncIterator](); + const delta: DriverEventInput = { + delivery: "lossless", + kind: "message.delta", + payload: { + contentDelta: "x", + messageId: "message-1", + role: "agent", + }, + }; + kernel.beginRun(DRIVER_TEST_IDS.runId); + + for (const runId of [null, DRIVER_TEST_IDS.secondRunId]) { + await expect( + kernel.pushEvents({ + events: [ + { + kind: "run.completed", + payload: { stopReason: "end_turn" }, + runId, + }, + ], + }), + ).rejects.toThrow("must target the active run"); + } + await kernel.pushEvents({ events: Array.from({ length: 1_024 }, () => delta) }); + await expect( + kernel.pushEvents({ + events: [ + { + kind: "run.completed", + payload: { stopReason: "end_turn" }, + runId: DRIVER_TEST_IDS.runId, + }, + ], + }), + ).rejects.toThrow("exceeds 1024 items"); + expect(kernel.runSnapshot(DRIVER_TEST_IDS.runId)?.terminal).toBeNull(); + + await kernel.failRun({ + code: "terminal_admission_failed", + details: {}, + message: "Terminal admission failed.", + retryable: false, + }); + await kernel.stop("test.complete"); + + const received: DriverEventInput[] = []; + for await (const event of { [Symbol.asyncIterator]: () => events }) { + received.push(event); + } + + expect(received).toHaveLength(1_025); + expect(received.at(-1)).toMatchObject({ + kind: "run.failed", + runId: DRIVER_TEST_IDS.runId, + }); + }); + + test("linearizes each run at its single final terminal", async () => { + const kernel = new AgentDriverKernelCore({ backendFactory: () => createBackend() }); + const delta: DriverEventInput = { + delivery: "lossless", + kind: "message.delta", + payload: { + contentDelta: "x", + messageId: "message-1", + role: "agent", + }, + }; + const completed: DriverEventInput = { + kind: "run.completed", + payload: { stopReason: "end_turn" }, + runId: DRIVER_TEST_IDS.runId, + }; + const ticket = kernel.beginRun(DRIVER_TEST_IDS.runId); + + await expect(kernel.pushEvents({ events: [completed, delta] })).rejects.toThrow( + "must be the only event", + ); + await expect( + kernel.pushEvents({ + events: [ + completed, + { + kind: "run.failed", + payload: { error: { code: "failed", message: "failed", retryable: false } }, + runId: DRIVER_TEST_IDS.runId, + }, + ], + }), + ).rejects.toThrow("multiple run terminals"); + await expect( + kernel.pushEvents({ events: [{ ...delta, runId: DRIVER_TEST_IDS.secondRunId }] }), + ).rejects.toThrow("must target the active run"); + await expect( + kernel.pushEvents({ events: [{ ...completed, delivery: "best_effort" }] }), + ).rejects.toThrow("must be lossless"); + expect(kernel.runSnapshot(DRIVER_TEST_IDS.runId)?.terminal).toBeNull(); + + await expect(kernel.pushEvents({ events: [delta, completed] })).rejects.toThrow( + "must be the only event", + ); + await expect(kernel.pushEvents({ events: [delta] })).resolves.toMatchObject({ + accepted: [{ type: "message.delta" }], + }); + await expect(kernel.pushEvents({ events: [completed] })).resolves.toMatchObject({ + accepted: [{ type: "run.completed" }], + }); + expect(kernel.runSnapshot(DRIVER_TEST_IDS.runId)?.terminal).toMatchObject({ + phase: "acked", + value: { status: "completed" }, + }); + + await expect(kernel.pushEvents({ events: [delta] })).rejects.toThrow( + "cannot target a terminated run", + ); + await expect(kernel.pushEvents({ events: [{ ...delta, runId: null }] })).resolves.toMatchObject( + { accepted: [{ type: "message.delta" }] }, + ); + + expect(() => kernel.beginRun(DRIVER_TEST_IDS.secondRunId)).toThrow( + `Driver run ${DRIVER_TEST_IDS.runId} is already active.`, + ); + expect(kernel.currentRunId()).toBe(DRIVER_TEST_IDS.runId); + expect(kernel.runSnapshot(DRIVER_TEST_IDS.runId)?.terminal).toMatchObject({ + phase: "acked", + value: { status: "completed" }, + }); + + kernel.releaseRun(ticket, "command_acked"); + kernel.beginRun(DRIVER_TEST_IDS.secondRunId); + expect(kernel.currentRunId()).toBe(DRIVER_TEST_IDS.secondRunId); + expect(kernel.runSnapshot(DRIVER_TEST_IDS.secondRunId)?.terminal).toBeNull(); + }); + test("treats stop before start as a terminal lifecycle", async () => { const kernel = new AgentDriverKernelCore({ backendFactory: () => createBackend() }); const events = kernel.events()[Symbol.asyncIterator](); @@ -293,7 +432,9 @@ describe("AgentDriverKernelCore", () => { expect(startInput).not.toHaveProperty("traceparent"); expect(startInput.execution).not.toHaveProperty("configRevision"); expect(startInput.execution).toHaveProperty("run"); - expect(startInput.execution.session).not.toHaveProperty("context"); + expect(startInput.execution.session.context).toEqual( + driverBootPayload.execution.session.context, + ); expect(startInput.execution.session).toHaveProperty("sharedRootPath"); }); @@ -334,8 +475,9 @@ describe("AgentDriverKernelCore", () => { test("owns a command before the caller can mutate its identity or payload", async () => { const backend = createBackend(); let handledText: string | null = null; - backend.handleInput = async (_context, input) => { + backend.handleInput = async (context, input, runId, signal) => { handledText = input.text; + await settleBackendInput(context, runId, signal); }; const kernel = new AgentDriverKernelCore({ backendFactory: () => backend }); const command = { @@ -378,6 +520,7 @@ describe("AgentDriverKernelCore", () => { commandId: "owned-result-command", kind: "mcp.execute", requestId: "owned-result-request", + runId: DRIVER_TEST_IDS.runId, serverId: "mcp-server", toolCallId: "owned-result-tool", toolName: "tool", diff --git a/tests/agent-driver-kernel-commands.test.ts b/tests/agent-driver-kernel-commands.test.ts index 0819417..10c7a80 100644 --- a/tests/agent-driver-kernel-commands.test.ts +++ b/tests/agent-driver-kernel-commands.test.ts @@ -1,14 +1,24 @@ import { describe, expect, test } from "bun:test"; +import { ACTIVE_INPUT_SETTLE_GRACE_MS } from "../src/core/driver-command-dispatcher"; import type { AgentDriverContext, AgentDriverContextPortOverrides, } from "../src/core/agent-driver-backend"; import { AgentDriverKernelCore } from "../src/core/agent-driver-kernel"; import type { DriverEventInput } from "../src/protocol/events"; -import type { RuntimeCommand } from "../src/runtime-command"; +import { + RUNTIME_COMMAND_MAX_UTF8_BYTES, + measureRuntimeCommandJson, + type RuntimeCommand, +} from "../src/runtime-command"; import { settlePromiseWithTimeout } from "../src/utils/async"; -import { DRIVER_TEST_IDS, bootPayload, createBackend } from "./driver-runtime-boundary-fixtures"; +import { + DRIVER_TEST_IDS, + bootPayload, + createBackend, + settleBackendInput, +} from "./driver-runtime-boundary-fixtures"; describe("AgentDriverKernelCore", () => { test("does not publish diagnostics after an adapter run terminal", async () => { @@ -22,6 +32,10 @@ describe("AgentDriverKernelCore", () => { payload: { startedAt: new Date().toISOString() }, runId, }, + ], + }); + await context.ports.eventSink.pushEvents({ + events: [ { kind: "run.failed", payload: { @@ -55,67 +69,17 @@ describe("AgentDriverKernelCore", () => { expect(events.map((event) => event.kind)).toEqual(["run.started", "run.failed"]); }); - test("automatically retries final cleanup after shutdown times out during startup", async () => { - const backend = createBackend(); - const startEntered = Promise.withResolvers(); - const releaseStart = Promise.withResolvers(); - const finalCleanup = Promise.withResolvers(); - let resourceActive = false; - let stopCount = 0; - let startSignal: AbortSignal | undefined; - backend.start = async (_context, signal) => { - startSignal = signal; - startEntered.resolve(); - await releaseStart.promise; - signal.throwIfAborted(); - resourceActive = true; - }; - backend.stop = async () => { - stopCount += 1; - resourceActive = false; - - if (stopCount === 2) { - finalCleanup.resolve(); - } - }; - const kernel = new AgentDriverKernelCore({ backendFactory: () => backend }); - const nativeSetTimeout = globalThis.setTimeout; - const acceleratedSetTimeout = ( - callback: (...args: unknown[]) => void, - delay?: number, - ...args: unknown[] - ) => nativeSetTimeout(callback, delay === 5_000 ? 10 : delay, ...args); - globalThis.setTimeout = acceleratedSetTimeout as typeof setTimeout; - - try { - const start = kernel.start(bootPayload); - await startEntered.promise; - await expect(kernel.stop("startup stop")).rejects.toThrow("timed out"); - expect(startSignal?.aborted).toBe(true); - expect(startSignal?.reason).toMatchObject({ message: "startup stop" }); - releaseStart.resolve(); - await start; - - const cleaned = await Promise.race([ - finalCleanup.promise.then(() => true), - Bun.sleep(50).then(() => false), - ]); - expect(cleaned).toBe(true); - expect(stopCount).toBe(2); - expect(resourceActive).toBe(false); - } finally { - releaseStart.resolve(); - globalThis.setTimeout = nativeSetTimeout; - } - }); - - test("fails closed for late startup permissions before deferred shutdown closes events", async () => { + test("fails closed for late startup permissions before shutdown closes events", async () => { const backend = createBackend(); const startEntered = Promise.withResolvers(); + const startAborted = Promise.withResolvers(); const releaseStart = Promise.withResolvers(); const latePermissionEntered = Promise.withResolvers(); let latePermission: Promise | null = null; + let startSignal: AbortSignal | undefined; backend.start = async (context, signal) => { + startSignal = signal; + signal.addEventListener("abort", () => startAborted.resolve(), { once: true }); startEntered.resolve(); await releaseStart.promise; latePermission = context.ports.permission.request({ @@ -138,26 +102,25 @@ describe("AgentDriverKernelCore", () => { permissionPolicy: "supervised" as const, }, }; - const nativeSetTimeout = globalThis.setTimeout; - const acceleratedSetTimeout = ( - callback: (...args: unknown[]) => void, - delay?: number, - ...args: unknown[] - ) => nativeSetTimeout(callback, delay === 5_000 ? 10 : delay, ...args); - globalThis.setTimeout = acceleratedSetTimeout as typeof setTimeout; + let start: Promise | null = null; + let stop: Promise | null = null; try { - const start = kernel.start(payload); + start = kernel.start(payload); await startEntered.promise; - await expect(kernel.stop("startup stop")).rejects.toThrow("timed out"); + stop = kernel.stop("startup stop"); + await startAborted.promise; + expect(startSignal?.aborted).toBe(true); + expect(startSignal?.reason).toMatchObject({ message: "startup stop" }); releaseStart.resolve(); await latePermissionEntered.promise; - await start; + await expect(start).resolves.toBeUndefined(); + await expect(stop).resolves.toBeUndefined(); await expect(events.next()).resolves.toEqual({ done: true, value: undefined }); await expect(latePermission).resolves.toBe("reject_once"); } finally { releaseStart.resolve(); - globalThis.setTimeout = nativeSetTimeout; + await Promise.allSettled([start, stop].filter((task) => task !== null)); } }); @@ -180,33 +143,101 @@ describe("AgentDriverKernelCore", () => { }, }, }); - const text = "x".repeat(17 * 1_024 * 1_024); + const commandAtSize = (index: number) => { + const base = { + commandId: `large-command-${String(index)}`, + input: { text: "" }, + kind: "input.start" as const, + requestId: `large-request-${String(index)}`, + runId: DRIVER_TEST_IDS.runId, + }; + return { + ...base, + input: { text: "x".repeat(800 * 1_024 - measureRuntimeCommandJson(base)) }, + }; + }; await kernel.start(bootPayload); await pollEntered.promise; - const first = kernel.dispatch({ - commandId: "large-command-1", - input: { text }, - kind: "input.start", - requestId: "large-request-1", - runId: DRIVER_TEST_IDS.runId, - }); - void first.catch(() => {}); - await expect( - kernel.dispatch({ - commandId: "large-command-2", - input: { text }, - kind: "input.start", - requestId: "large-request-2", - runId: DRIVER_TEST_IDS.runId, - }), - ).rejects.toThrow("UTF-8 JSON bytes"); + const queued = Array.from({ length: 40 }, (_, index) => kernel.dispatch(commandAtSize(index))); + for (const pending of queued) void pending.catch(() => {}); + + expect(measureRuntimeCommandJson(commandAtSize(0))).toBe(800 * 1_024); + expect(800 * 1_024).toBeLessThan(RUNTIME_COMMAND_MAX_UTF8_BYTES); + await expect(kernel.dispatch(commandAtSize(40))).rejects.toThrow("UTF-8 JSON bytes"); (context as AgentDriverContext | null)?.lifecycle.fail(failure); - await expect(first).rejects.toBe(failure); + for (const pending of queued) await expect(pending).rejects.toBe(failure); await expect(kernel.stop("join failure")).rejects.toBe(failure); }); + test("rejects an oversized MCP command before external effects", async () => { + const calls: string[] = []; + const base = { + argumentsJson: "", + commandId: "oversized-mcp", + kind: "mcp.execute" as const, + requestId: "request-1", + runId: DRIVER_TEST_IDS.runId, + serverId: "server-1", + toolCallId: "tool-call-1", + toolName: "tool-1", + }; + const command = { + ...base, + argumentsJson: "x".repeat( + RUNTIME_COMMAND_MAX_UTF8_BYTES + 1 - measureRuntimeCommandJson(base), + ), + }; + const kernel = new AgentDriverKernelCore({ + backendFactory: () => createBackend(), + externalToolEffectLedger: { + async claimExternalToolEffect() { + calls.push("claim"); + throw new Error("unexpected claim"); + }, + async observeExternalToolEffect() { + calls.push("observe"); + throw new Error("unexpected observe"); + }, + async settleExternalToolEffect() { + calls.push("settle"); + throw new Error("unexpected settle"); + }, + }, + hostPorts: { + mcp: { + async prepare() { + calls.push("prepare"); + throw new Error("unexpected prepare"); + }, + }, + }, + }); + + expect(measureRuntimeCommandJson(command)).toBe(RUNTIME_COMMAND_MAX_UTF8_BYTES + 1); + await kernel.start(bootPayload); + await expect(kernel.dispatch(command)).rejects.toThrow("UTF-8 bytes"); + expect(calls).toEqual([]); + await expect(kernel.stop("test.stop")).resolves.toBeUndefined(); + }); + + test("publishes one unscoped completion when an idle driver stops", async () => { + const kernel = new AgentDriverKernelCore({ backendFactory: () => createBackend() }); + const events = kernel.events()[Symbol.asyncIterator](); + + await kernel.start(bootPayload); + await expect(kernel.stop("idle stop")).resolves.toBeUndefined(); + const terminal = await events.next(); + + expect(terminal).toMatchObject({ + done: false, + value: { kind: "run.completed" }, + }); + expect(terminal.value).not.toHaveProperty("runId"); + await expect(events.next()).resolves.toEqual({ done: true, value: undefined }); + }); + test.each([ ["poll", "resolve"], ["poll", "reject"], @@ -239,8 +270,10 @@ describe("AgentDriverKernelCore", () => { entered.resolve(); await late.promise; }, + currentRunId: () => null, pushEvents: async ({ events }) => ({ accepted: events.map((event, index) => ({ + eventId: event.sourceEventId!, seq: index + 1, type: event.kind, })), @@ -258,8 +291,8 @@ describe("AgentDriverKernelCore", () => { ? kernel .dispatch({ commandId: "blocked-accepted-command", - kind: "turn.cancel", - reason: "test.cancel", + kind: "session.stop", + reason: "test.stop", }) .catch(() => {}) : null; @@ -304,6 +337,125 @@ describe("AgentDriverKernelCore", () => { await expect(events.next()).resolves.toEqual({ done: true, value: undefined }); }); + test("finalizes events only after a late backend stop and the run task settle", async () => { + type ManualTimer = { active: boolean; run: () => void }; + + const backend = createBackend(); + const inputEntered = Promise.withResolvers(); + const releaseInput = Promise.withResolvers(); + const stopEntered = Promise.withResolvers(); + const stopAborted = Promise.withResolvers(); + const releaseStop = Promise.withResolvers(); + const failure = new Error("backend lifecycle failed"); + let context!: AgentDriverContext; + backend.start = async (startedContext) => { + context = startedContext; + }; + backend.handleInput = async () => { + inputEntered.resolve(); + await releaseInput.promise; + throw failure; + }; + backend.stop = async (_context, _reason, signal) => { + stopEntered.resolve(); + signal.addEventListener("abort", () => stopAborted.resolve(), { once: true }); + await releaseStop.promise; + }; + const kernel = new AgentDriverKernelCore({ backendFactory: () => backend }); + const events = kernel.events()[Symbol.asyncIterator](); + + await kernel.start(bootPayload); + const input = kernel.dispatch({ + commandId: "late-stop-active-input", + input: { text: "wait" }, + kind: "input.start", + requestId: "late-stop-active-request", + runId: DRIVER_TEST_IDS.runId, + }); + void input.catch(() => {}); + await inputEntered.promise; + + const nativeClearTimeout = globalThis.clearTimeout; + const nativeNow = Date.now; + const nativeSetTimeout = globalThis.setTimeout; + let shutdownTimer: ManualTimer | null = null; + Date.now = () => 0; + globalThis.setTimeout = (( + callback: (...args: unknown[]) => void, + delay = 0, + ...args: unknown[] + ) => { + if (delay !== 5_000 || shutdownTimer !== null) { + return nativeSetTimeout(callback, delay, ...args); + } + + const timer: ManualTimer = { + active: true, + run: () => { + if (timer.active) { + callback(...args); + } + }, + }; + shutdownTimer = timer; + return timer as unknown as ReturnType; + }) as typeof setTimeout; + globalThis.clearTimeout = ((handle: ReturnType) => { + if (handle === (shutdownTimer as unknown as ReturnType)) { + if (shutdownTimer !== null) { + shutdownTimer.active = false; + } + } else { + nativeClearTimeout(handle); + } + }) as typeof clearTimeout; + + try { + context.lifecycle.fail(failure); + await stopEntered.promise; + const timer = shutdownTimer as ManualTimer | null; + expect(timer).not.toBeNull(); + timer?.run(); + await stopAborted.promise; + + const nextEvent = events.next(); + const observed = nextEvent.then((value) => ({ kind: "event" as const, value })); + releaseStop.resolve(); + await new Promise((resolve) => setImmediate(resolve)); + await expect( + Promise.race([observed, Promise.resolve({ kind: "pending" as const })]), + ).resolves.toEqual({ kind: "pending" }); + + releaseInput.resolve(); + await expect(input).rejects.toBe(failure); + await expect(observed).resolves.toMatchObject({ + kind: "event", + value: { + done: false, + value: { + kind: "diagnostic.reported", + payload: { + code: "driver.command_failed", + message: failure.message, + }, + }, + }, + }); + await expect(events.next()).resolves.toMatchObject({ + done: false, + value: { kind: "run.failed", runId: DRIVER_TEST_IDS.runId }, + }); + await expect(events.next()).resolves.toEqual({ done: true, value: undefined }); + await expect(kernel.stop("join backend failure")).rejects.toBe(failure); + } finally { + releaseInput.resolve(); + releaseStop.resolve(); + Date.now = nativeNow; + globalThis.clearTimeout = nativeClearTimeout; + globalThis.setTimeout = nativeSetTimeout; + } + }); + test("publishes an active backend failure only after input and cleanup settle", async () => { const backend = createBackend(); const cleanupEntered = Promise.withResolvers(); @@ -318,6 +470,7 @@ describe("AgentDriverKernelCore", () => { backend.handleInput = async () => { inputEntered.resolve(); await releaseInput.promise; + throw failure; }; backend.stop = async () => { cleanupEntered.resolve(); @@ -342,13 +495,17 @@ describe("AgentDriverKernelCore", () => { const stop = kernel.stop("wait for failure cleanup"); void stop.catch(() => {}); await cleanupEntered.promise; - const terminal = events.next(); - expect(await Promise.race([terminal.then(() => true), Bun.sleep(20).then(() => false)])).toBe( + const diagnostic = events.next(); + expect(await Promise.race([diagnostic.then(() => true), Bun.sleep(20).then(() => false)])).toBe( false, ); releaseCleanup.resolve(); await expect(stop).rejects.toBe(failure); - await expect(terminal).resolves.toMatchObject({ + await expect(diagnostic).resolves.toMatchObject({ + done: false, + value: { kind: "diagnostic.reported" }, + }); + await expect(events.next()).resolves.toMatchObject({ done: false, value: { kind: "run.failed", runId: DRIVER_TEST_IDS.runId }, }); @@ -366,6 +523,7 @@ describe("AgentDriverKernelCore", () => { backend.handleInput = async () => { inputEntered.resolve(); await releaseInput.promise; + throw failure; }; backend.stop = async () => { releaseInput.resolve(); @@ -387,6 +545,10 @@ describe("AgentDriverKernelCore", () => { await expect(input).rejects.toBe(failure); await expect(kernel.stop("wait for failed cleanup")).rejects.toBe(failure); + await expect(events.next()).resolves.toMatchObject({ + done: false, + value: { kind: "diagnostic.reported" }, + }); const terminal = events.next(); expect(await Promise.race([terminal.then(() => true), Bun.sleep(20).then(() => false)])).toBe( false, @@ -416,6 +578,7 @@ describe("AgentDriverKernelCore", () => { }); permissionEntered.resolve(); await permission; + throw failure; }; backend.stop = async () => { cleanupEntered.resolve(); @@ -487,14 +650,25 @@ describe("AgentDriverKernelCore", () => { done: false, value: { kind: "diagnostic.reported", + payload: { code: "permission.cancelled" }, runId: DRIVER_TEST_IDS.runId, }, }); await cleanupEntered.promise; + await expect(events.next()).resolves.toMatchObject({ + done: false, + value: { + kind: "diagnostic.reported", + payload: { code: "driver.command_failed" }, + }, + }); const terminal = events.next(); - expect(await Promise.race([terminal.then(() => true), Bun.sleep(20).then(() => false)])).toBe( - false, - ); + expect( + await Promise.race([ + terminal.then((value) => ({ kind: "event" as const, value })), + Bun.sleep(20).then(() => ({ kind: "pending" as const })), + ]), + ).toEqual({ kind: "pending" }); releaseCleanup.resolve(); await expect(terminal).resolves.toMatchObject({ done: false, @@ -514,9 +688,10 @@ describe("AgentDriverKernelCore", () => { const inputEntered = Promise.withResolvers(); const releaseInput = Promise.withResolvers(); let stopCount = 0; - backend.handleInput = async () => { + backend.handleInput = async (context, _input, runId, signal) => { inputEntered.resolve(); await releaseInput.promise; + await settleBackendInput(context, runId, signal); }; backend.stop = async () => { stopCount += 1; @@ -538,19 +713,6 @@ describe("AgentDriverKernelCore", () => { .catch(() => {}), ]; await inputEntered.promise; - pending.push( - kernel - .dispatch({ - commandId: "queued-input", - input: { text: "wait again" }, - kind: "input.start", - requestId: "queued-request", - runId: DRIVER_TEST_IDS.runId, - }) - .catch(() => {}), - ); - await Bun.sleep(0); - for (let index = 0; index < 1_024; index += 1) { pending.push( kernel @@ -558,6 +720,7 @@ describe("AgentDriverKernelCore", () => { commandId: `queued-cancel-${index}`, kind: "turn.cancel", reason: "queued", + runId: DRIVER_TEST_IDS.runId, }) .catch(() => {}), ); @@ -568,7 +731,7 @@ describe("AgentDriverKernelCore", () => { expect(stopCount).toBe(1); await expect(events.next()).resolves.toMatchObject({ done: false, - value: { kind: "run.completed" }, + value: { kind: "run.cancelled", runId: DRIVER_TEST_IDS.runId }, }); await expect(events.next()).resolves.toEqual({ done: true, value: undefined }); }); @@ -578,9 +741,10 @@ describe("AgentDriverKernelCore", () => { const inputEntered = Promise.withResolvers(); const releaseInput = Promise.withResolvers(); let stopCount = 0; - backend.handleInput = async () => { + backend.handleInput = async (context, _input, runId, signal) => { inputEntered.resolve(); await releaseInput.promise; + await settleBackendInput(context, runId, signal); }; backend.cancelActiveTurn = async (_context, reason) => { backend.cancelledReasons.push(reason); @@ -632,17 +796,38 @@ describe("AgentDriverKernelCore", () => { }) .catch(() => {}); await inputEntered.promise; + const nativeSetTimeout = globalThis.setTimeout; + let activeInputTimeouts = 0; + const acceleratedSetTimeout = ( + callback: (...args: unknown[]) => void, + timeout?: number, + ...args: unknown[] + ) => { + if (timeout === ACTIVE_INPUT_SETTLE_GRACE_MS) { + activeInputTimeouts += 1; + } + return nativeSetTimeout( + callback, + timeout === ACTIVE_INPUT_SETTLE_GRACE_MS ? 10 : timeout, + ...args, + ); + }; + globalThis.setTimeout = acceleratedSetTimeout as typeof setTimeout; - const first = await Promise.allSettled([kernel.stop("first stop")]); - const second = await Promise.allSettled([kernel.stop("second stop")]); - const settledBeforeRelease = inputSettled; - releaseInput.resolve(); - await input; + try { + const first = await Promise.allSettled([kernel.stop("first stop")]); + const second = await Promise.allSettled([kernel.stop("second stop")]); - expect(first[0]?.status).toBe("rejected"); - expect(second[0]?.status).toBe("rejected"); - expect(settledBeforeRelease).toBe(false); - }, 12_000); + expect(first[0]?.status).toBe("rejected"); + expect(second[0]?.status).toBe("rejected"); + expect(inputSettled).toBe(false); + expect(activeInputTimeouts).toBe(1); + } finally { + globalThis.setTimeout = nativeSetTimeout; + releaseInput.resolve(); + await input; + } + }); test("keeps cleanup event delivery available across a transient stop failure", async () => { const backend = createBackend(); @@ -728,6 +913,7 @@ describe("AgentDriverKernelCore", () => { commandId: "after-stop-failure", kind: "turn.cancel", reason: "test", + runId: DRIVER_TEST_IDS.runId, }), ).rejects.toThrow("not accepting commands: failed"); }, @@ -756,12 +942,14 @@ describe("AgentDriverKernelCore", () => { } }; const kernel = new AgentDriverKernelCore({ backendFactory: () => backend }); + const nativeNow = Date.now; const nativeSetTimeout = globalThis.setTimeout; const acceleratedSetTimeout = ( callback: (...args: unknown[]) => void, delay?: number, ...args: unknown[] ) => nativeSetTimeout(callback, delay === 5_000 ? 10 : delay, ...args); + Date.now = () => 0; globalThis.setTimeout = acceleratedSetTimeout as typeof setTimeout; try { @@ -781,6 +969,7 @@ describe("AgentDriverKernelCore", () => { expect(stopSignals[1]).not.toBe(stopSignals[0]); expect(stopSignals[1]?.aborted).toBe(false); } finally { + Date.now = nativeNow; globalThis.setTimeout = nativeSetTimeout; } }); @@ -789,34 +978,44 @@ describe("AgentDriverKernelCore", () => { const backend = createBackend(); let mcpOutput: string | null = null; let materializedSkillName: string | null = null; - backend.start = async (context: AgentDriverContext) => { - const [skill] = await context.ports.skill.materialize(context.payload.execution); + backend.start = async (context: AgentDriverContext, signal: AbortSignal) => { + const [skill] = await context.ports.skill.materialize(context.payload.execution, signal); materializedSkillName = skill?.skillName ?? null; }; - backend.handleInput = async (context: AgentDriverContext) => { - const result = await context.ports.mcp.execute( - { - argumentsJson: '{"ok":true}', - commandId: "mcp-port-1", - kind: "mcp.execute", - requestId: "request-1", - serverId: "server-1", - toolCallId: "tool-1", - toolName: "complete", - }, - new AbortController().signal, - ); + backend.handleInput = async (context: AgentDriverContext, _input, runId, signal) => { + const command = { + argumentsJson: '{"ok":true}', + commandId: "mcp-port-1", + kind: "mcp.execute" as const, + requestId: "request-1", + runId: DRIVER_TEST_IDS.runId, + serverId: "server-1", + toolCallId: "tool-1", + toolName: "complete", + }; + const mcpSignal = new AbortController().signal; + await using prepared = await context.ports.mcp.prepare(command, mcpSignal); + const result = await prepared.execute({ + attempt: 1, + effectId: "effect-1", + idempotencyKey: "effect-1", + kind: "claimed", + }); mcpOutput = result.outputText; + await settleBackendInput(context, runId, signal); }; const kernel = new AgentDriverKernelCore({ backendFactory: () => backend, hostPorts: { mcp: { - execute: async (command) => ({ - outputText: `port:${command.toolName}`, - requestId: command.requestId, - serverId: command.serverId, - toolName: command.toolName, + prepare: async (command) => ({ + execute: async () => ({ + outputText: `port:${command.toolName}`, + requestId: command.requestId, + serverId: command.serverId, + toolName: command.toolName, + }), + async [Symbol.asyncDispose]() {}, }), }, skill: { diff --git a/tests/agent-driver-kernel-lifecycle.test.ts b/tests/agent-driver-kernel-lifecycle.test.ts index b4b5336..9470e42 100644 --- a/tests/agent-driver-kernel-lifecycle.test.ts +++ b/tests/agent-driver-kernel-lifecycle.test.ts @@ -4,15 +4,68 @@ import type { AgentDriverContext } from "../src/core/agent-driver-backend"; import { AgentDriverKernelCore } from "../src/core/agent-driver-kernel"; import type { DriverEventInput } from "../src/protocol/events"; import type { RuntimeCommand } from "../src/runtime-command"; -import { DRIVER_TEST_IDS, bootPayload, createBackend } from "./driver-runtime-boundary-fixtures"; +import { + DRIVER_TEST_IDS, + bootPayload, + createBackend, + settleBackendInput, +} from "./driver-runtime-boundary-fixtures"; describe("AgentDriverKernelCore", () => { + test("commits a custom event sink terminal through the kernel lifecycle owner", async () => { + const backend = createBackend(); + const delivered: DriverEventInput[] = []; + backend.handleInput = async (context, _input, runId, signal) => { + expect(context.ports.eventSink.currentRunId()).toBe(runId); + await settleBackendInput(context, runId, signal); + }; + const kernel = new AgentDriverKernelCore({ + backendFactory: () => backend, + hostPorts: { + eventSink: { + commandUpdate: async () => {}, + currentRunId: () => DRIVER_TEST_IDS.secondRunId, + pushEvents: async ({ events }) => { + delivered.push(...structuredClone(events)); + return { + accepted: events.map((event, index) => ({ + eventId: event.sourceEventId!, + seq: index + 1, + type: event.kind, + })), + }; + }, + }, + }, + }); + + await kernel.start(bootPayload); + await expect( + kernel.dispatch({ + commandId: "custom-sink-input", + input: { text: "complete" }, + kind: "input.start", + requestId: "custom-sink-request", + runId: DRIVER_TEST_IDS.runId, + }), + ).resolves.toEqual({ requestId: "custom-sink-request" }); + + expect(delivered).toContainEqual( + expect.objectContaining({ kind: "run.completed", runId: DRIVER_TEST_IDS.runId }), + ); + expect(kernel.currentRunId()).toBeNull(); + await expect(kernel.stop("custom sink complete")).resolves.toBeUndefined(); + }); + test("refuses to synthesize an in-memory MCP effect ledger", async () => { const kernel = new AgentDriverKernelCore({ backendFactory: () => createBackend() }); await expect( kernel.claimExternalToolEffect( - { commandId: "mcp-command-without-durable-ledger" }, + { + claimToken: "00000000-0000-4000-8000-000000000001", + commandId: "mcp-command-without-durable-ledger", + }, new AbortController().signal, ), ).rejects.toThrow(/durable external tool effect ledger/); @@ -27,8 +80,17 @@ describe("AgentDriverKernelCore", () => { string, { outputText: string; requestId: string; serverId: string; toolName: string } >(); - backend.handleInput = async () => { + const activeRunEntered = Promise.withResolvers(); + const releaseActiveRun = Promise.withResolvers(); + backend.handleInput = async (context, _input, runId, signal) => { + if (kind === "mcp") { + activeRunEntered.resolve(); + await releaseActiveRun.promise; + await settleBackendInput(context, runId, signal); + return; + } calls += 1; + await settleBackendInput(context, runId, signal); }; const kernel = new AgentDriverKernelCore({ backendFactory: () => backend, @@ -38,27 +100,38 @@ describe("AgentDriverKernelCore", () => { const effectId = `test-effect-${commandId}`; return result === undefined - ? { attempt: 1, effectId, idempotencyKey: effectId, kind: "execute" as const } - : { effectId, kind: "completed" as const, result }; + ? { attempt: 1, effectId, idempotencyKey: effectId, kind: "claimed" as const } + : { effectId, kind: "succeeded" as const, result }; }, - completeExternalToolEffect: async ({ commandId, result }) => { - effectResults.set(commandId, structuredClone(result)); + observeExternalToolEffect: async ({ commandId }) => { + const result = effectResults.get(commandId); + const effectId = `test-effect-${commandId}`; + return result === undefined + ? { effectId, kind: "intent" as const } + : { effectId, kind: "succeeded" as const, result }; + }, + settleExternalToolEffect: async ({ commandId, effectId, settlement }) => { + if (settlement.kind === "unknown") { + return { effectId, kind: "unknown" as const }; + } + effectResults.set(commandId, structuredClone(settlement.result)); + return { effectId, kind: "succeeded" as const, result: settlement.result }; }, - markExternalToolEffectUnknown: async () => {}, }, hostPorts: { mcp: { - execute: async (command) => { - calls += 1; - const result = { - debug: { nested: "original" }, - outputText: `ran ${command.toolName}`, - requestId: command.requestId, - serverId: command.serverId, - toolName: command.toolName, - }; - return result; - }, + prepare: async (command) => ({ + async execute() { + calls += 1; + return { + outputText: `ran ${command.toolName}`, + requestId: command.requestId, + serverId: command.serverId, + toolName: command.toolName, + }; + }, + async [Symbol.asyncDispose]() {}, + }), }, }, }); @@ -76,28 +149,39 @@ describe("AgentDriverKernelCore", () => { commandId: "replayed-command", kind: "mcp.execute", requestId: "request-replay", + runId: DRIVER_TEST_IDS.runId, serverId: "mcp-linear", toolCallId: "tool-replay", toolName: "createIssue", }; await kernel.start(bootPayload); + const activeRun = + kind === "mcp" + ? kernel.dispatch({ + commandId: "active-run-command", + input: { text: "keep the run active" }, + kind: "input.start", + requestId: "active-run-request", + runId: DRIVER_TEST_IDS.runId, + }) + : null; + if (activeRun !== null) { + await activeRunEntered.promise; + } const first = await kernel.dispatch(command); if (typeof first === "object" && first !== null) { Reflect.set(first, "requestId", "caller-mutated"); - const debug = Reflect.get(first, "debug") as { nested?: string } | undefined; - if (debug !== undefined) { - debug.nested = "caller-mutated"; - } } const replay = await kernel.dispatch(structuredClone(command)); + releaseActiveRun.resolve(); + await activeRun; await kernel.stop("test.stop"); expect(replay).toEqual( kind === "input" ? { requestId: "request-replay" } : { - debug: { nested: "original" }, outputText: "ran createIssue", requestId: "request-replay", serverId: "mcp-linear", @@ -117,8 +201,9 @@ describe("AgentDriverKernelCore", () => { role: "agent", }, }; - backend.handleInput = async (context: AgentDriverContext) => { + backend.handleInput = async (context: AgentDriverContext, _input, runId, signal) => { await context.ports.eventSink.pushEvents({ events: [event] }); + await settleBackendInput(context, runId, signal); }; const kernel = new AgentDriverKernelCore({ backendFactory: () => backend, @@ -136,7 +221,7 @@ describe("AgentDriverKernelCore", () => { runId: DRIVER_TEST_IDS.runId, }); - await expect(events.next()).resolves.toEqual({ + await expect(events.next()).resolves.toMatchObject({ done: false, value: event, }); @@ -148,12 +233,32 @@ describe("AgentDriverKernelCore", () => { test("turn cancel dispatches through the active backend", async () => { const backend = createBackend(); + const inputEntered = Promise.withResolvers(); + const releaseInput = Promise.withResolvers(); + backend.handleInput = async (context, _input, runId, signal) => { + inputEntered.resolve(); + await releaseInput.promise; + await settleBackendInput(context, runId, signal); + }; + backend.cancelActiveTurn = async (_context, reason) => { + backend.cancelledReasons.push(reason); + releaseInput.resolve(); + }; const kernel = new AgentDriverKernelCore({ backendFactory: () => backend, }); await kernel.start(bootPayload); + const input = kernel.dispatch({ + commandId: "cancel-active-input", + input: { text: "wait" }, + kind: "input.start", + requestId: "cancel-active-request", + runId: DRIVER_TEST_IDS.runId, + }); + await inputEntered.promise; await kernel.cancel("test.cancel"); + await input; expect(backend.cancelledReasons).toEqual(["test.cancel"]); await kernel.stop("test.stop"); @@ -165,9 +270,10 @@ describe("AgentDriverKernelCore", () => { const inputEntered = Promise.withResolvers(); const releaseInput = Promise.withResolvers(); const cancelEntered = Promise.withResolvers(); - backend.handleInput = async () => { + backend.handleInput = async (context, _input, runId, signal) => { inputEntered.resolve(); await releaseInput.promise; + await settleBackendInput(context, runId, signal); }; backend.cancelActiveTurn = async (_context, reason) => { backend.cancelledReasons.push(reason); @@ -196,7 +302,7 @@ describe("AgentDriverKernelCore", () => { await kernel.stop("test.stop"); }); - test("treats an input error after the local abort as cancellation", async () => { + test("keeps an ordinary input error failed after the local abort", async () => { const backend = createBackend(); const inputEntered = Promise.withResolvers(); const cancelInput = Promise.withResolvers(); @@ -219,10 +325,9 @@ describe("AgentDriverKernelCore", () => { }); await inputEntered.promise; - await expect(Promise.all([input, kernel.cancel("test.cancel")])).resolves.toEqual([ - undefined, - undefined, - ]); + await expect(Promise.all([input, kernel.cancel("test.cancel")])).rejects.toThrow( + "native cancellation", + ); await expect(kernel.stop("test.stop")).resolves.toBeUndefined(); }); @@ -239,12 +344,13 @@ describe("AgentDriverKernelCore", () => { let inputCount = 0; let permission: Promise | null = null; let resolutionRunId: unknown; - backend.handleInput = async (context) => { + backend.handleInput = async (context, _input, runId, signal) => { inputCount += 1; if (inputCount > 1) { nextInputEntered.resolve(); await releaseNextInput.promise; + await settleBackendInput(context, runId, signal); return; } @@ -257,6 +363,7 @@ describe("AgentDriverKernelCore", () => { }); inputEntered.resolve(); await releaseInput.promise; + await settleBackendInput(context, runId, signal); }; backend.cancelActiveTurn = async () => { releaseInput.resolve(); @@ -290,17 +397,22 @@ describe("AgentDriverKernelCore", () => { runId: DRIVER_TEST_IDS.runId, }); await inputEntered.promise; - await expect(Promise.all([firstInput, kernel.cancel("test.cancel")])).resolves.toEqual([ + const cancellation = kernel.cancel("test.cancel"); + await resolutionPublishing.promise; + releaseResolution.resolve(); + await expect(Promise.all([firstInput, cancellation])).resolves.toEqual([ undefined, undefined, ]); - await kernel.dispatch({ - commandId: "resolve-stale-permission", - decision, - kind: "permission.resolve", - requestId: "permission-stale", - }); - await resolutionPublishing.promise; + await expect( + kernel.dispatch({ + commandId: "resolve-stale-permission", + decision, + kind: "permission.resolve", + requestId: "permission-stale", + runId: DRIVER_TEST_IDS.runId, + }), + ).rejects.toThrow("does not target the active run"); const nextInput = kernel.dispatch({ commandId: "next-input", input: { text: "continue" }, @@ -309,8 +421,7 @@ describe("AgentDriverKernelCore", () => { runId: DRIVER_TEST_IDS.secondRunId, }); await nextInputEntered.promise; - releaseResolution.resolve(); - await permission; + await expect(permission).resolves.toBe("reject_once"); expect(resolutionRunId).toBe(DRIVER_TEST_IDS.runId); releaseNextInput.resolve(); await expect(nextInput).resolves.toEqual({ requestId: "next-request" }); @@ -362,8 +473,10 @@ describe("AgentDriverKernelCore", () => { requestId: "permission-delivery-request", runId: DRIVER_TEST_IDS.runId, }); + void input.catch(() => {}); await inputEntered.promise; const cancel = kernel.cancel("test.cancel"); + void cancel.catch(() => {}); await deliveryEntered.promise; releaseDelivery.resolve(); @@ -378,7 +491,7 @@ describe("AgentDriverKernelCore", () => { runId: DRIVER_TEST_IDS.secondRunId, }), ).rejects.toThrow("not accepting commands: failed"); - await kernel.stop("test.stop"); + await expect(kernel.stop("test.stop")).rejects.toThrow("could not be delivered"); }, ); @@ -427,6 +540,7 @@ describe("AgentDriverKernelCore", () => { commandId: "after-start-failure", kind: "turn.cancel", reason: "test", + runId: DRIVER_TEST_IDS.runId, }), ).rejects.toThrow("not accepting commands: failed"); expect(stopCount).toBe(expectedStopCount); @@ -483,7 +597,7 @@ describe("AgentDriverKernelCore", () => { }, ); - test("serializes concurrent stop calls with an in-flight start", async () => { + test("joins concurrent stop calls with an in-flight start", async () => { const backend = createBackend(); const startEntered = Promise.withResolvers(); const releaseStart = Promise.withResolvers(); diff --git a/tests/architecture-boundaries.test.ts b/tests/architecture-boundaries.test.ts index 69d09a5..460c3c5 100644 --- a/tests/architecture-boundaries.test.ts +++ b/tests/architecture-boundaries.test.ts @@ -1,108 +1,55 @@ import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; -import { relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; -import * as ts from "typescript"; - const repositoryRoot = fileURLToPath(new URL("../", import.meta.url)); -const sourceRoot = resolve(repositoryRoot, "src"); - -interface SourceGraph { - readonly dependencies: ReadonlyMap>; - readonly files: readonly string[]; -} - -function loadCompilerOptions(): ts.CompilerOptions { - const configPath = resolve(repositoryRoot, "tsconfig.json"); - const config = ts.readConfigFile(configPath, (path) => readFileSync(path, "utf8")); - - if (config.error !== undefined) { - throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, "\n")); - } - - return ts.parseJsonConfigFileContent(config.config, ts.sys, repositoryRoot).options; -} - -function moduleSpecifiers(sourceFile: ts.SourceFile): string[] { - const specifiers: string[] = []; - - function visit(node: ts.Node): void { - if ( - (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && - node.moduleSpecifier !== undefined && - ts.isStringLiteralLike(node.moduleSpecifier) - ) { - specifiers.push(node.moduleSpecifier.text); - } else if ( - ts.isCallExpression(node) && - node.expression.kind === ts.SyntaxKind.ImportKeyword && - node.arguments.length === 1 && - ts.isStringLiteralLike(node.arguments[0]!) - ) { - specifiers.push(node.arguments[0].text); - } - - ts.forEachChild(node, visit); - } - - visit(sourceFile); - return specifiers; -} - -function isSourceFile(path: string): boolean { - return path === sourceRoot || path.startsWith(`${sourceRoot}${sep}`); -} +type SourceGraph = ReadonlyMap>; -function createSourceGraph(): SourceGraph { - const options = loadCompilerOptions(); - const config = ts.parseJsonConfigFileContent( - ts.readConfigFile(resolve(repositoryRoot, "tsconfig.json"), (path) => - readFileSync(path, "utf8"), - ).config, - ts.sys, - repositoryRoot, +async function createSourceGraph(): Promise { + const entrypoints = await Array.fromAsync( + new Bun.Glob("src/**/*.ts").scan({ absolute: true, cwd: repositoryRoot, onlyFiles: true }), ); - const program = ts.createProgram(config.fileNames, options); - const files = program - .getSourceFiles() - .map((sourceFile) => sourceFile.fileName) - .filter((path) => isSourceFile(path) && !path.endsWith(".d.ts")) - .toSorted(); - const fileSet = new Set(files); - const dependencies = new Map>(); - - for (const file of files) { - const sourceFile = program.getSourceFile(file); - - if (sourceFile === undefined) { - throw new Error(`TypeScript program omitted ${file}.`); - } - - const resolvedDependencies = new Set(); - - for (const specifier of moduleSpecifiers(sourceFile)) { - const resolvedModule = ts.resolveModuleName(specifier, file, options, ts.sys).resolvedModule; - const dependency = resolvedModule?.resolvedFileName; - - if (dependency !== undefined && fileSet.has(dependency)) { - resolvedDependencies.add(dependency); - } - } - - dependencies.set(file, resolvedDependencies); + const metafile = ( + await Bun.build({ + allowUnresolved: [], + entrypoints, + metafile: true, + packages: "external", + root: repositoryRoot, + treeShaking: false, + }) + ).metafile!; + const inputs = new Set(Object.keys(metafile.inputs)); + const sourceFiles = new Set( + [...inputs].filter((path) => path.startsWith("src/") && path.endsWith(".ts")), + ); + const unsupportedInput = [...inputs].find( + (path) => + !sourceFiles.has(path) && + path !== "package.json" && + !(path.startsWith("src/") && path.endsWith(".json")), + ); + if (unsupportedInput !== undefined) { + throw new Error(`Source import resolved to an unsupported file: ${unsupportedInput}.`); } - return { dependencies, files }; -} - -function sourcePath(path: string): string { - return relative(repositoryRoot, path).split(sep).join("/"); + return new Map( + [...sourceFiles].map((file) => { + const imports = metafile.inputs[file]!.imports; + if (imports.some((dependency) => dependency.kind !== "import-statement")) { + throw new Error(`Runtime module loaders are unsupported: ${file}.`); + } + return [ + file, + new Set( + imports.map((dependency) => dependency.path).filter((path) => sourceFiles.has(path)), + ), + ]; + }), + ); } function isWithin(path: string, directory: string): boolean { - const root = resolve(sourceRoot, directory); - return path === root || path.startsWith(`${root}${sep}`); + return path.startsWith(`src/${directory}/`); } function isProviderRuntime(path: string): boolean { @@ -111,21 +58,36 @@ function isProviderRuntime(path: string): boolean { ); } +function reachableDependencies( + graph: SourceGraph, + file: string, + reachable = new Set(), +): ReadonlySet { + for (const dependency of graph.get(file) ?? []) { + if (reachable.has(dependency)) { + continue; + } + reachable.add(dependency); + reachableDependencies(graph, dependency, reachable); + } + + return reachable; +} + function boundaryViolations(graph: SourceGraph): string[] { const violations: string[] = []; - for (const file of graph.files) { - for (const dependency of graph.dependencies.get(file) ?? []) { + for (const file of graph.keys()) { + for (const dependency of reachableDependencies(graph, file)) { const forbidden = (isWithin(file, "contract") && - ["core", "runtimes", "infrastructure", "stores", "surfaces"].some((directory) => + ["core", "infrastructure", "runtimes", "stores", "surfaces"].some((directory) => isWithin(dependency, directory), )) || (isWithin(file, "protocol") && - (["core", "runtimes", "infrastructure", "runtime-events"].some((directory) => + ["core", "infrastructure", "runtimes", "runtime-events"].some((directory) => isWithin(dependency, directory), - ) || - isWithin(dependency, "runtime-events"))) || + )) || (isWithin(file, "core") && (isProviderRuntime(dependency) || ["infrastructure", "stores", "surfaces"].some((directory) => @@ -137,7 +99,7 @@ function boundaryViolations(graph: SourceGraph): string[] { )); if (forbidden) { - violations.push(`${sourcePath(file)} -> ${sourcePath(dependency)}`); + violations.push(`${file} -> ${dependency}`); } } } @@ -146,101 +108,88 @@ function boundaryViolations(graph: SourceGraph): string[] { } function compositionViolations(graph: SourceGraph): string[] { - return graph.files + return [...graph.keys()] .filter((file) => !isWithin(file, "bin")) .filter((file) => { - const dependencies = [...(graph.dependencies.get(file) ?? [])]; + const dependencies = [...reachableDependencies(graph, file)]; return ( dependencies.some((dependency) => isWithin(dependency, "core")) && dependencies.some(isProviderRuntime) && dependencies.some((dependency) => isWithin(dependency, "infrastructure")) ); }) - .map(sourcePath) .toSorted(); } -function fileCycles(graph: SourceGraph): string[] { - const visited = new Set(); - const visiting = new Set(); - const stack: string[] = []; - const cycles = new Set(); - - function visit(file: string): void { - if (visited.has(file)) { - return; - } - - visiting.add(file); - stack.push(file); - - for (const dependency of graph.dependencies.get(file) ?? []) { - if (visiting.has(dependency)) { - const start = stack.indexOf(dependency); - cycles.add([...stack.slice(start), dependency].map(sourcePath).join(" -> ")); - } else { - visit(dependency); - } - } - - stack.pop(); - visiting.delete(file); - visited.add(file); - } - - for (const file of graph.files) { - visit(file); - } - - return [...cycles].toSorted(); -} - function sourceDirectory(path: string): string { - const parts = relative(sourceRoot, path).split(sep); + const parts = path.slice("src/".length).split("/"); return parts.length === 1 ? "(entrypoints)" : parts[0]!; } function bidirectionalDirectories(graph: SourceGraph): string[] { const edges = new Set(); - for (const file of graph.files) { + for (const file of graph.keys()) { const source = sourceDirectory(file); - - for (const dependency of graph.dependencies.get(file) ?? []) { + for (const dependency of graph.get(file) ?? []) { const target = sourceDirectory(dependency); - if (source !== target) { edges.add(`${source}\0${target}`); } } } - const pairs = new Set(); - - for (const edge of edges) { - const [source, target] = edge.split("\0") as [string, string]; - - if (edges.has(`${target}\0${source}`)) { - pairs.add([source, target].toSorted().join(" <-> ")); - } - } - - return [...pairs].toSorted(); + return [...edges] + .filter((edge) => { + const [source, target] = edge.split("\0") as [string, string]; + return edges.has(`${target}\0${source}`); + }) + .map((edge) => edge.split("\0").toSorted().join(" <-> ")) + .filter((pair, index, pairs) => pairs.indexOf(pair) === index) + .toSorted(); } -const graph = createSourceGraph(); +const graph = await createSourceGraph(); describe("source architecture", () => { - test("keeps contract, protocol, core, and provider imports pointing inward", () => { + test("keeps transitive contract, protocol, core, and provider dependencies inward", () => { expect(boundaryViolations(graph)).toEqual([]); + + const core = "src/core/audit.ts"; + const wrapper = "src/audit/wrapper.ts"; + const provider = "src/runtimes/openai/audit.ts"; + + expect( + boundaryViolations( + new Map([ + [core, new Set([wrapper])], + [wrapper, new Set([provider])], + [provider, new Set()], + ]), + ), + ).toEqual(["src/core/audit.ts -> src/runtimes/openai/audit.ts"]); }); - test("keeps bin as the only full runtime composition root", () => { + test("keeps bin as the only transitive full runtime composition root", () => { expect(compositionViolations(graph)).toEqual([]); - }); - test("has no file-level import cycles", () => { - expect(fileCycles(graph)).toEqual([]); + const root = "src/composition-audit.ts"; + const wrapper = "src/audit/wrapper.ts"; + const core = "src/core/audit.ts"; + const provider = "src/runtimes/openai/audit.ts"; + const infrastructure = "src/infrastructure/audit.ts"; + + expect( + compositionViolations( + new Map([ + [root, new Set([wrapper])], + [wrapper, new Set([core, provider, infrastructure])], + [core, new Set()], + [provider, new Set()], + [infrastructure, new Set()], + ]), + ), + ).toEqual(["src/audit/wrapper.ts", "src/composition-audit.ts"]); }); test("has no bidirectional top-level source directory dependencies", () => { diff --git a/tests/async.test.ts b/tests/async.test.ts index e8b0598..af31494 100644 --- a/tests/async.test.ts +++ b/tests/async.test.ts @@ -3,6 +3,7 @@ import { describe, expect, test } from "bun:test"; import { promiseWithTimeout, raceWithAbort, + readBoundedStreamBytes, settlePromiseWithTimeout, sleepPromise, } from "../src/utils/async"; @@ -98,6 +99,29 @@ describe("async lifecycle utilities", () => { ); }); + test("does not wait for an unresponsive stream cancellation", async () => { + const controller = new AbortController(); + const reason = new Error("stream cancelled"); + let cancelled = false; + const body = new ReadableStream({ + cancel() { + cancelled = true; + return new Promise(() => {}); + }, + pull() {}, + }); + const read = readBoundedStreamBytes(body, 1, new Error("too large"), controller.signal); + + controller.abort(reason); + + const result = await settlePromiseWithTimeout(read, { + label: "bounded stream cancellation", + timeoutMs: 100, + }); + expect(result).toEqual({ error: reason, status: "failed" }); + expect(cancelled).toBe(true); + }); + test.each([ ["NaN", Number.NaN], ["positive infinity", Number.POSITIVE_INFINITY], diff --git a/tests/child-process-env.test.ts b/tests/child-process-env.test.ts index 81a593a..bd99a6e 100644 --- a/tests/child-process-env.test.ts +++ b/tests/child-process-env.test.ts @@ -1,13 +1,15 @@ import { describe, expect, test } from "bun:test"; +import { delimiter } from "node:path"; import { parseDriverBootPayload } from "../src/protocol/boot"; import type { DriverExecutionEnvironment } from "../src/protocol/boot"; +import { createDriverStartInputFromBootPayload } from "../src/protocol/start"; import { DRIVER_BOOT_PAYLOAD_ENV_NAME, DRIVER_BOOT_PAYLOAD_FILE_ENV_NAME, buildRuntimeChildProcessEnv, } from "../src/runtimes/child-process-env"; -import { driverBootPayload } from "./driver-boot-payload-fixture"; +import { DRIVER_TEST_IDS, driverBootPayload } from "./driver-boot-payload-fixture"; function withEnvironmentPaths(paths: unknown) { return { @@ -51,13 +53,39 @@ describe("buildRuntimeChildProcessEnv", () => { }, ); - expect(env["PATH"]).toBe("/artifact/bin:/runtime/bin"); - expect(env["NODE_PATH"]).toBe("/artifact/node:/runtime/node"); - expect(env["PYTHONPATH"]).toBe("/artifact/python:/runtime/python"); + expect(env["PATH"]).toBe(["/artifact/bin", "/runtime/bin"].join(delimiter)); + expect(env["NODE_PATH"]).toBe(["/artifact/node", "/runtime/node"].join(delimiter)); + expect(env["PYTHONPATH"]).toBe(["/artifact/python", "/runtime/python"].join(delimiter)); + }); + + test("inherits and canonicalizes case-insensitive Windows path variables", () => { + const env = buildRuntimeChildProcessEnv( + { executable: ["C:\\artifact"], node: [], python: [] }, + { Path: "C:\\Windows" }, + "win32", + ); + + expect(env["PATH"]).toBe("C:\\artifact;C:\\Windows"); + expect(env).not.toHaveProperty("Path"); }); }); describe("Driver execution environment paths", () => { + test("validates the v2 boot metadata before deriving the internal sandbox identity", () => { + const parsed = parseDriverBootPayload(driverBootPayload); + + expect(parsed.driverControlPort).toBe(20_000); + expect(parsed.driverGeneration).toBe(0); + expect(parsed.heartbeatIntervalMs).toBe(1_000); + expect(createDriverStartInputFromBootPayload(parsed).sandboxId).toBe( + driverBootPayload.execution.session.context.sandboxId, + ); + + expect(() => + parseDriverBootPayload({ ...driverBootPayload, sandboxId: DRIVER_TEST_IDS.secondRunId }), + ).toThrow("sandbox IDs must match"); + }); + test("parses absolute path arrays as an additive protocol version 1 field", () => { const paths = { executable: ["/artifact/bin"], @@ -74,9 +102,32 @@ describe("Driver execution environment paths", () => { expect(parseDriverBootPayload(driverBootPayload).execution.environment.paths).toBeUndefined(); }); - test.each(["relative/path", "/artifact/\0bin"])("rejects invalid path %j", (path) => { - expect(() => - parseDriverBootPayload(withEnvironmentPaths({ executable: [path], node: [], python: [] })), - ).toThrow("execution.environment.paths.executable[0]"); + test.each(["relative/path", "/artifact/\0bin", `/trusted${delimiter}/tmp/untrusted`])( + "rejects invalid path %j", + (path) => { + expect(() => + parseDriverBootPayload(withEnvironmentPaths({ executable: [path], node: [], python: [] })), + ).toThrow("execution.environment.paths.executable[0]"); + }, + ); + + test("preserves dangerous environment variable names as data", () => { + const payload = structuredClone(driverBootPayload); + payload.execution.environment.variables = JSON.parse('{"__proto__":"value"}'); + + const variables = parseDriverBootPayload(payload).execution.environment.variables; + expect(Object.hasOwn(variables, "__proto__")).toBe(true); + expect(variables["__proto__"]).toBe("value"); + }); + + test.each([ + ["", "value"], + ["A=B", "value"], + ["NAME", "bad\0value"], + ])("rejects invalid environment entry %j", (name, value) => { + const payload = structuredClone(driverBootPayload); + payload.execution.environment.variables = { [name]: value }; + + expect(() => parseDriverBootPayload(payload)).toThrow("must be a valid environment entry"); }); }); diff --git a/tests/claude-agent-sdk-driver-backend.test.ts b/tests/claude-agent-sdk-driver-backend.test.ts index 75887e6..7b51fc0 100644 --- a/tests/claude-agent-sdk-driver-backend.test.ts +++ b/tests/claude-agent-sdk-driver-backend.test.ts @@ -7,18 +7,32 @@ import type { WarmQuery, } from "@anthropic-ai/claude-agent-sdk"; -import { DriverTurnCancelledError } from "../src/core/driver-runtime-state"; -import { createBufferedSinkLogger } from "../src/observability"; +import { + DriverRuntimeStateMachine, + DriverTurnCancelledError, +} from "../src/core/driver-runtime-state"; +import { createDisabledLogger } from "../src/observability"; import type { DriverEventInput } from "../src/protocol/events"; +import type { RunId } from "../src/protocol/id"; import type { DriverStartInput } from "../src/protocol/start"; import { createAgentDriverContext } from "../src/core/agent-driver-backend"; import { ClaudeAgentSdkDriverBackend } from "../src/runtimes/claude/agent-sdk-driver-backend"; import { registerClaudeTaskRetry } from "../src/runtimes/claude/agent-sdk-tasks"; -import { bootPayload, DRIVER_TEST_IDS } from "./driver-runtime-boundary-fixtures"; +import { settlePromiseWithTimeout } from "../src/utils/async"; +import { + bootPayload, + createDispatcher, + DRIVER_TEST_IDS, + FakeDriverRuntimeIo, +} from "./driver-runtime-boundary-fixtures"; const PREWARM_ENV = "AGENT_DRIVER_CLAUDE_PREWARM"; const previousPrewarm = process.env[PREWARM_ENV]; +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + afterEach(() => { if (previousPrewarm === undefined) { delete process.env[PREWARM_ENV]; @@ -65,6 +79,13 @@ function errorResultMessage(): SDKMessage { } as unknown as SDKMessage; } +function cancelledResultMessage(): SDKMessage { + return { + ...errorResultMessage(), + terminal_reason: "aborted_tools", + } as SDKMessage; +} + function fakeQuery( messages: AsyncIterable | readonly SDKMessage[], close = () => {}, @@ -112,12 +133,8 @@ function createHarness( payloadOverride?: DriverStartInput, ) { const events: DriverEventInput[] = []; + let currentRunId: RunId | null = null; let seq = 0; - const logger = createBufferedSinkLogger({ - level: "error", - service: "claude-agent-sdk-driver-backend-test", - sink: async () => {}, - }); const payload = payloadOverride ?? ({ @@ -127,29 +144,113 @@ function createHarness( } as DriverStartInput); const context = createAgentDriverContext({ eventSink: { + currentRunId: () => currentRunId, pushEvents: async ({ events: batch }) => { await beforePush?.(batch); events.push(...batch); + for (const event of batch) { + if (event.kind === "run.started" && event.runId !== undefined) { + currentRunId = event.runId; + } else if ( + (event.kind === "run.cancelled" || + event.kind === "run.completed" || + event.kind === "run.failed") && + event.runId === currentRunId + ) { + currentRunId = null; + } + } return { - accepted: batch.map((event) => ({ seq: (seq += 1), type: event.kind })), + accepted: batch.map((event) => ({ + eventId: event.sourceEventId!, + seq: (seq += 1), + type: event.kind, + })), }; }, }, - logger, + logger: createDisabledLogger(), payload, permission: { request: async () => "allow_once" }, ports: { skill: { materialize: async () => [] } }, }); + const backend = new ClaudeAgentSdkDriverBackend(payload, dependencies); + const handleInput = backend.handleInput.bind(backend); + backend.handleInput = async (inputContext, input, runId, signal) => { + currentRunId ??= runId; + + try { + await handleInput(inputContext, input, runId, signal); + } finally { + if (currentRunId === runId) { + currentRunId = null; + } + } + }; return { - backend: new ClaudeAgentSdkDriverBackend(payload, dependencies), + backend, context, events, - logger, }; } describe("Claude Agent SDK driver backend", () => { + test("rejects invalid native session IDs before retaining or publishing them", async () => { + const oversizedSessionId = "s".repeat(257); + const resumePayload = { + ...bootPayload, + execution: { + ...bootPayload.execution, + session: { + ...bootPayload.execution.session, + nativeResumeRef: { + kind: "claude_session_id", + runtimeId: "claude-agent-sdk", + value: oversizedSessionId, + }, + }, + }, + runtime: "claude-agent-sdk", + runtimeTransport: "claude-agent-sdk", + } as DriverStartInput; + expect(() => new ClaudeAgentSdkDriverBackend(resumePayload)).toThrow( + "Claude native session ID must contain 1-256 UTF-8 bytes (received 257).", + ); + + const harness = createHarness({ + createQueryOptions: async () => ({}), + query: () => fakeQuery([resultMessage(oversizedSessionId)]), + startup: async () => { + throw new Error("prewarm is disabled"); + }, + }); + await expect( + harness.backend.handleInput(harness.context, { text: "hello" }, DRIVER_TEST_IDS.runId), + ).rejects.toThrow("Claude native session ID must contain 1-256 UTF-8 bytes (received 257)."); + expect(harness.events.some((event) => event.kind === "runtime.resume.updated")).toBe(false); + expect(harness.events.some((event) => event.kind === "run.failed")).toBe(true); + expect(JSON.stringify(harness.events)).not.toContain(oversizedSessionId); + + const emptyHarness = createHarness({ + createQueryOptions: async () => ({}), + query: () => fakeQuery([resultMessage("")]), + startup: async () => { + throw new Error("prewarm is disabled"); + }, + }); + await expect( + emptyHarness.backend.handleInput( + emptyHarness.context, + { text: "hello" }, + DRIVER_TEST_IDS.runId, + ), + ).rejects.toThrow("Claude native session ID must contain 1-256 UTF-8 bytes (received 0)."); + expect(emptyHarness.events.some((event) => event.kind === "runtime.resume.updated")).toBe( + false, + ); + }); + test("consumes a ready prewarm for the first turn", async () => { process.env[PREWARM_ENV] = "1"; const startupCalled = Promise.withResolvers(); @@ -182,7 +283,6 @@ describe("Claude Agent SDK driver backend", () => { await nextEventLoopTurn(); await harness.backend.handleInput(harness.context, { text: "first" }, DRIVER_TEST_IDS.runId); await harness.backend.stop(harness.context, "test.complete", new AbortController().signal); - await harness.logger.destroy(); expect(prompts).toEqual(["first"]); expect(coldQueries).toBe(0); @@ -234,13 +334,57 @@ describe("Claude Agent SDK driver backend", () => { DRIVER_TEST_IDS.secondRunId, ); await harness.backend.stop(harness.context, "test.complete", new AbortController().signal); - await harness.logger.destroy(); expect(coldQueries).toBe(2); expect(warmQueries).toBe(0); expect(optionSessionIds).toEqual([null, null, "native-session-1"]); }); + test("retains late prewarm cleanup ownership until stop retries it", async () => { + process.env[PREWARM_ENV] = "1"; + const startup = Promise.withResolvers(); + const startupCalled = Promise.withResolvers(); + const processExit = Promise.withResolvers(); + const warmClosed = Promise.withResolvers(); + let optionCalls = 0; + let cleanupRetries = 0; + const harness = createHarness({ + createQueryOptions: async (input) => { + optionCalls += 1; + if (optionCalls === 1) { + input.processTasks?.add(processExit.promise); + registerClaudeTaskRetry(processExit.promise, async () => { + cleanupRetries += 1; + }); + } + return { abortController: input.abortController }; + }, + query: () => fakeQuery([resultMessage()]), + startup: async () => { + startupCalled.resolve(); + return startup.promise; + }, + }); + + await harness.backend.start(harness.context, new AbortController().signal); + await startupCalled.promise; + await harness.backend.handleInput(harness.context, { text: "first" }, DRIVER_TEST_IDS.runId); + + startup.resolve({ + close: () => warmClosed.resolve(), + query: () => fakeQuery([resultMessage("stale-session")]), + async [Symbol.asyncDispose]() {}, + }); + await warmClosed.promise; + processExit.reject(new Error("late prewarm cleanup failed")); + await nextEventLoopTurn(); + + await expect( + harness.backend.stop(harness.context, "test.stop", new AbortController().signal), + ).resolves.toBeUndefined(); + expect(cleanupRetries).toBe(1); + }); + test("stop joins a prewarm that ignores cancellation until startup settles", async () => { process.env[PREWARM_ENV] = "1"; const startup = Promise.withResolvers(); @@ -285,7 +429,6 @@ describe("Claude Agent SDK driver backend", () => { expect(stopped).toBe(false); processExit.resolve(); await stopping; - await harness.logger.destroy(); }); test("stop joins a cooperatively aborted prewarm", async () => { @@ -314,7 +457,6 @@ describe("Claude Agent SDK driver backend", () => { harness.backend.stop(harness.context, "test.stop", new AbortController().signal), ).resolves.toBeUndefined(); expect(warmSignal?.aborted).toBe(true); - await harness.logger.destroy(); }); test("stop fails at its deadline and can retry when prewarm later settles", async () => { @@ -354,7 +496,6 @@ describe("Claude Agent SDK driver backend", () => { }); await expect(retry).resolves.toBeUndefined(); expect(closes).toBe(1); - await harness.logger.destroy(); }); test("stop consumes a late prewarm rejection", async () => { @@ -386,7 +527,6 @@ describe("Claude Agent SDK driver backend", () => { startup.reject(new Error("late startup failure")); await expect(stopping).resolves.toBeUndefined(); - await harness.logger.destroy(); }); test("stop propagates a rejected prewarm process cleanup", async () => { @@ -430,7 +570,6 @@ describe("Claude Agent SDK driver backend", () => { harness.backend.stop(harness.context, "test.stop.retry", new AbortController().signal), ).resolves.toBeUndefined(); expect(cleanupRetries).toBe(1); - await harness.logger.destroy(); }); test("retains a spontaneous prewarm process cleanup rejection", async () => { @@ -459,7 +598,46 @@ describe("Claude Agent SDK driver backend", () => { await expect( harness.backend.stop(harness.context, "test.stop", new AbortController().signal), ).rejects.toBe(cleanupError); - await harness.logger.destroy(); + }); + + test("does not lose a permanent prewarm cleanup failure while retrying its sibling", async () => { + process.env[PREWARM_ENV] = "1"; + const permanentExit = Promise.withResolvers(); + const retryableExit = Promise.withResolvers(); + const startupCalled = Promise.withResolvers(); + const permanentError = new Error("permanent prewarm cleanup failure"); + let cleanupRetries = 0; + const harness = createHarness({ + createQueryOptions: async (input) => { + input.processTasks?.add(permanentExit.promise); + input.processTasks?.add(retryableExit.promise); + registerClaudeTaskRetry(retryableExit.promise, async () => { + cleanupRetries += 1; + }); + return { abortController: input.abortController }; + }, + query: () => fakeQuery([resultMessage()]), + startup: async () => { + startupCalled.resolve(); + throw new Error("prewarm startup failed"); + }, + }); + + await harness.backend.start(harness.context, new AbortController().signal); + await startupCalled.promise; + await nextEventLoopTurn(); + permanentExit.reject(permanentError); + retryableExit.reject(new Error("retryable prewarm cleanup failure")); + await nextEventLoopTurn(); + + await expect( + harness.backend.stop(harness.context, "test.stop", new AbortController().signal), + ).rejects.toBe(permanentError); + expect(cleanupRetries).toBe(1); + await expect( + harness.backend.stop(harness.context, "test.stop.retry", new AbortController().signal), + ).rejects.toBe(permanentError); + expect(cleanupRetries).toBe(1); }); test("concurrent stops share the same prewarm join", async () => { @@ -505,7 +683,6 @@ describe("Claude Agent SDK driver backend", () => { await Promise.all([first, second]); expect(closes).toBe(1); - await harness.logger.destroy(); }); test("stop drains prewarm cleanup after active turn cleanup fails", async () => { @@ -589,7 +766,6 @@ describe("Claude Agent SDK driver backend", () => { { reason: activeCleanupError, status: "rejected" }, { reason: activeCleanupError, status: "rejected" }, ]); - await harness.logger.destroy(); expect( harness.events.some((event) => @@ -625,12 +801,15 @@ describe("Claude Agent SDK driver backend", () => { ); await optionsRequested.promise; + let stopping: Promise | null = null; if (action === "cancel") { await harness.backend.cancelActiveTurn(harness.context, "test.cancel"); } else { - await harness.backend.stop(harness.context, "test.stop", new AbortController().signal); + stopping = harness.backend.stop(harness.context, "test.stop", new AbortController().signal); + } + if (stopping !== null) { + await stopping; } - options.resolve({}); await expect(handling).rejects.toBeInstanceOf(DriverTurnCancelledError); expect(queryCalls).toBe(0); @@ -643,7 +822,6 @@ describe("Claude Agent SDK driver backend", () => { { kind: "run.cancelled", runId: DRIVER_TEST_IDS.runId }, ]); await harness.backend.stop(harness.context, "test.complete", new AbortController().signal); - await harness.logger.destroy(); }, ); @@ -663,7 +841,6 @@ describe("Claude Agent SDK driver backend", () => { harness.backend.handleInput(harness.context, { text: "hello" }, DRIVER_TEST_IDS.runId), ).rejects.toThrow("query options failed"); await harness.backend.stop(harness.context, "test.complete", new AbortController().signal); - await harness.logger.destroy(); expect( harness.events.filter((event) => @@ -720,7 +897,6 @@ describe("Claude Agent SDK driver backend", () => { processExit.resolve(); await expect(handling).rejects.toThrow("query creation failed"); await harness.backend.stop(harness.context, "test.complete", new AbortController().signal); - await harness.logger.destroy(); expect(processTasks?.size).toBe(0); expect(harness.events.some((event) => event.kind === "run.failed")).toBe(true); @@ -753,7 +929,6 @@ describe("Claude Agent SDK driver backend", () => { harness.backend.handleInput(harness.context, { text: "hello" }, DRIVER_TEST_IDS.runId), ).rejects.toThrow("event sink unavailable"); await harness.backend.stop(harness.context, "test.complete", new AbortController().signal); - await harness.logger.destroy(); expect(queryCalls).toBe(0); expect( @@ -763,6 +938,106 @@ describe("Claude Agent SDK driver backend", () => { ).toBe(false); }); + test("bounds a stuck dequeued-result return after cancellation is claimed", async () => { + delete process.env[PREWARM_ENV]; + const releaseReturn = Promise.withResolvers(); + const returnStarted = Promise.withResolvers(); + let closeCalls = 0; + let innerReturnCalls = 0; + let outerReturnCalls = 0; + let turnSignal: AbortSignal | undefined; + const message = resultMessage(); + let innerMessagePending = true; + const inner = { + async next() { + if (innerMessagePending) { + innerMessagePending = false; + return { done: false as const, value: message }; + } + return { done: true as const, value: undefined }; + }, + async return() { + innerReturnCalls += 1; + await releaseReturn.promise; + return { done: true as const, value: undefined }; + }, + [Symbol.asyncIterator]() { + return this; + }, + }; + let outerMessagePending = true; + const query = { + close() { + closeCalls += 1; + }, + async interrupt() {}, + async next() { + if (outerMessagePending) { + outerMessagePending = false; + return { done: false as const, value: message }; + } + return { done: true as const, value: undefined }; + }, + async return() { + outerReturnCalls += 1; + returnStarted.resolve(); + await releaseReturn.promise; + return { done: true as const, value: undefined }; + }, + [Symbol.asyncIterator]() { + return inner; + }, + } as unknown as Query; + const harness = createHarness({ + createQueryOptions: async ({ abortController }) => { + turnSignal = abortController.signal; + return {}; + }, + query: () => query, + startup: async () => { + throw new Error("prewarm is disabled"); + }, + }); + const runCancellation = new AbortController(); + + try { + const handling = harness.backend.handleInput( + harness.context, + { text: "finish" }, + DRIVER_TEST_IDS.runId, + runCancellation.signal, + ); + await returnStarted.promise; + runCancellation.abort(new DriverTurnCancelledError("test.cancel")); + + expect( + await settlePromiseWithTimeout( + harness.backend.cancelActiveTurn(harness.context, "test.cancel"), + { label: "dequeued Claude cancellation", timeoutMs: 50 }, + ), + ).toMatchObject({ status: "completed" }); + expect(turnSignal?.aborted).toBe(true); + expect( + await settlePromiseWithTimeout(handling, { + label: "stuck Claude query return", + timeoutMs: 3_000, + }), + ).toMatchObject({ error: expect.any(DriverTurnCancelledError), status: "failed" }); + + expect(closeCalls).toBe(1); + expect(outerReturnCalls).toBe(1); + expect(innerReturnCalls).toBe(0); + expect( + harness.events.filter((event) => + ["run.cancelled", "run.completed", "run.failed"].includes(event.kind), + ), + ).toMatchObject([{ kind: "run.cancelled", runId: DRIVER_TEST_IDS.runId }]); + } finally { + releaseReturn.resolve(); + await harness.backend.stop(harness.context, "test.complete", new AbortController().signal); + } + }, 6_000); + test("awaits query and process cleanup before publishing a completed terminal", async () => { delete process.env[PREWARM_ENV]; const cleanupStarted = Promise.withResolvers(); @@ -825,7 +1100,6 @@ describe("Claude Agent SDK driver backend", () => { releaseLateProcess.resolve(); await handling; await harness.backend.stop(harness.context, "test.complete", new AbortController().signal); - await harness.logger.destroy(); expect(harness.events.some((event) => event.kind === "run.completed")).toBe(true); }); @@ -889,7 +1163,6 @@ describe("Claude Agent SDK driver backend", () => { await expect( harness.backend.stop(harness.context, "test.stop.retry", new AbortController().signal), ).resolves.toBeUndefined(); - await harness.logger.destroy(); expect(cleanupRetries).toBe(1); expect( @@ -934,7 +1207,6 @@ describe("Claude Agent SDK driver backend", () => { await expect( harness.backend.stop(harness.context, "test.complete", new AbortController().signal), ).resolves.toBeUndefined(); - await harness.logger.destroy(); expect( harness.events.some((event) => @@ -948,9 +1220,7 @@ describe("Claude Agent SDK driver backend", () => { delete process.env[PREWARM_ENV]; const closed = Promise.withResolvers(); const cleanupStarted = Promise.withResolvers(); - const interrupted = Promise.withResolvers(); const releaseCleanup = Promise.withResolvers(); - const releaseInterrupt = Promise.withResolvers(); const releasePermissionDelivery = Promise.withResolvers(); const queryCreated = Promise.withResolvers(); const started = Promise.withResolvers(); @@ -983,10 +1253,7 @@ describe("Claude Agent SDK driver backend", () => { closes += 1; closed.resolve(); }, - async () => { - interrupted.resolve(); - await releaseInterrupt.promise; - }, + async () => undefined, async () => { cleanupStarted.resolve(); await releaseCleanup.promise; @@ -1018,21 +1285,17 @@ describe("Claude Agent SDK driver backend", () => { await queryCreated.promise; const cancellation = harness.backend.cancelActiveTurn(harness.context, "test.cancel"); - await interrupted.promise; - expect(turnSignal?.aborted).toBe(false); - expect(closes).toBe(0); - releaseInterrupt.resolve(); + await cancellation; await cleanupStarted.promise; expect(turnSignal?.aborted).toBe(true); + expect(closes).toBe(1); expect(harness.events.some((event) => event.kind === "run.cancelled")).toBe(false); releaseCleanup.resolve(); await nextEventLoopTurn(); expect(harness.events.some((event) => event.kind === "run.cancelled")).toBe(false); releasePermissionDelivery.resolve(); - await cancellation; await expect(handling).rejects.toBeInstanceOf(DriverTurnCancelledError); await harness.backend.stop(harness.context, "test.complete", new AbortController().signal); - await harness.logger.destroy(); expect(closes).toBe(1); expect( @@ -1043,15 +1306,84 @@ describe("Claude Agent SDK driver backend", () => { expect(terminalOrder).toEqual(["permission.resolved", "run.cancelled"]); }); - test("stop joins an in-flight cancellation and propagates its cleanup failure", async () => { + test("stop waits for the cancelled terminal acknowledgement", async () => { + delete process.env[PREWARM_ENV]; + const closed = Promise.withResolvers(); + const releaseTerminal = Promise.withResolvers(); + const terminalEntered = Promise.withResolvers(); + const queryCreated = Promise.withResolvers(); + const harness = createHarness( + { + createQueryOptions: async () => ({}), + query: () => { + queryCreated.resolve(); + return fakeQuery( + (async function* () { + await closed.promise; + yield* [] as SDKMessage[]; + })(), + () => closed.resolve(), + ); + }, + startup: async () => { + throw new Error("prewarm is disabled"); + }, + }, + async (events) => { + if (events.some(({ kind }) => kind === "run.cancelled")) { + terminalEntered.resolve(); + await releaseTerminal.promise; + } + }, + ); + const handling = harness.backend.handleInput( + harness.context, + { text: "wait" }, + DRIVER_TEST_IDS.runId, + ); + void handling.catch(() => {}); + await queryCreated.promise; + let stopSettled = false; + const stopping = harness.backend.stop( + harness.context, + "test.stop", + new AbortController().signal, + ); + void stopping.then( + () => { + stopSettled = true; + }, + () => { + stopSettled = true; + }, + ); + + try { + await terminalEntered.promise; + await nextEventLoopTurn(); + expect(stopSettled).toBe(false); + releaseTerminal.resolve(); + await expect(Promise.all([stopping, handling])).rejects.toBeInstanceOf( + DriverTurnCancelledError, + ); + expect( + harness.events.filter(({ kind }) => + ["run.cancelled", "run.completed", "run.failed"].includes(kind), + ), + ).toMatchObject([{ kind: "run.cancelled", runId: DRIVER_TEST_IDS.runId }]); + } finally { + releaseTerminal.resolve(); + await Promise.allSettled([stopping, handling]); + } + }); + + test("stop owns in-flight cancellation cleanup and propagates its failure", async () => { delete process.env[PREWARM_ENV]; const closed = Promise.withResolvers(); const cleanupError = new Error("provider process cleanup failed"); const cleanupStarted = Promise.withResolvers(); - const interrupted = Promise.withResolvers(); const processExit = Promise.withResolvers(); const queryCreated = Promise.withResolvers(); - const releaseInterrupt = Promise.withResolvers(); let cancellationSettled = false; let stopSettled = false; const harness = createHarness({ @@ -1067,10 +1399,7 @@ describe("Claude Agent SDK driver backend", () => { yield* [] as SDKMessage[]; })(), () => closed.resolve(), - async () => { - interrupted.resolve(); - await releaseInterrupt.promise; - }, + async () => undefined, async () => cleanupStarted.resolve(), ); }, @@ -1094,7 +1423,7 @@ describe("Claude Agent SDK driver backend", () => { cancellationSettled = true; }, ); - await interrupted.promise; + await cleanupStarted.promise; const stopping = harness.backend.stop( harness.context, @@ -1110,23 +1439,16 @@ describe("Claude Agent SDK driver backend", () => { }, ); await nextEventLoopTurn(); - expect(cancellationSettled).toBe(false); - expect(stopSettled).toBe(false); - - releaseInterrupt.resolve(); - await cleanupStarted.promise; - await nextEventLoopTurn(); - expect(cancellationSettled).toBe(false); + expect(cancellationSettled).toBe(true); expect(stopSettled).toBe(false); const settled = Promise.allSettled([cancellation, stopping, handling]); processExit.reject(cleanupError); expect(await settled).toEqual([ - { reason: cleanupError, status: "rejected" }, + { status: "fulfilled", value: undefined }, { reason: cleanupError, status: "rejected" }, { reason: cleanupError, status: "rejected" }, ]); - await harness.logger.destroy(); expect( harness.events.some((event) => @@ -1166,7 +1488,6 @@ describe("Claude Agent SDK driver backend", () => { releaseFinish.resolve(); await expect(handling).resolves.toBeUndefined(); await harness.backend.stop(harness.context, "test.complete", new AbortController().signal); - await harness.logger.destroy(); expect(closes).toBe(1); expect( @@ -1176,6 +1497,236 @@ describe("Claude Agent SDK driver backend", () => { ).toMatchObject([{ kind: "run.completed", runId: DRIVER_TEST_IDS.runId }]); }); + test.each(["query cleanup", "terminal selection"] as const)( + "honors a socket cancellation claimed during %s", + async (window) => { + delete process.env[PREWARM_ENV]; + const windowEntered = Promise.withResolvers(); + const releaseWindow = Promise.withResolvers(); + const cancellationClaimed = Promise.withResolvers(); + const order: string[] = []; + const backend = new ClaudeAgentSdkDriverBackend( + { + ...bootPayload, + runtime: "claude-agent-sdk", + runtimeTransport: "claude-agent-sdk", + } as DriverStartInput, + { + createQueryOptions: async () => ({}), + query: () => + fakeQuery( + [resultMessage()], + () => {}, + async () => undefined, + async () => { + if (window === "query cleanup") { + windowEntered.resolve(); + await releaseWindow.promise; + } + order.push("cleanup"); + }, + ), + }, + ); + const socket = new FakeDriverRuntimeIo([ + { + commandId: "result-cleanup-input", + input: { text: "finish" }, + kind: "input.start", + requestId: "result-cleanup-request", + runId: DRIVER_TEST_IDS.runId, + }, + { + commandId: "result-cleanup-cancel", + kind: "turn.cancel", + reason: "test.cancel", + runId: DRIVER_TEST_IDS.runId, + }, + ]); + const nextCommand = socket.nextCommand.bind(socket); + socket.nextCommand = async (signal) => { + const command = await nextCommand(signal); + if (command?.kind === "turn.cancel") { + await windowEntered.promise; + } + return command; + }; + const registerRunTerminalBarrier = socket.registerRunTerminalBarrier.bind(socket); + socket.registerRunTerminalBarrier = (barrier) => + registerRunTerminalBarrier((events) => { + const pending = barrier(events); + if ( + window !== "terminal selection" || + !events.some(({ kind }) => kind === "run.completed") + ) { + return pending; + } + return Promise.resolve(pending).then(async () => { + windowEntered.resolve(); + await releaseWindow.promise; + }); + }); + const claimRunCancellation = socket.claimRunCancellation.bind(socket); + socket.claimRunCancellation = (ticket, reason) => { + const claim = claimRunCancellation(ticket, reason); + cancellationClaimed.resolve(); + return claim; + }; + const pushEvents = socket.pushEvents.bind(socket); + socket.pushEvents = async (input) => { + const result = await pushEvents(input); + const terminal = input.events.find(({ kind }) => + ["run.cancelled", "run.completed", "run.failed"].includes(kind), + ); + if (terminal !== undefined) { + order.push(terminal.kind); + } + return result; + }; + const runtimeState = new DriverRuntimeStateMachine("ready"); + const { dispatcher, logger, shutdownCalls } = createDispatcher({ + backend, + isShuttingDown: () => socket.isDrained(), + runtimeState, + }); + + const running = dispatcher.run(socket, logger); + await windowEntered.promise; + await cancellationClaimed.promise; + const claimedSnapshot = socket.runSnapshot(DRIVER_TEST_IDS.runId); + releaseWindow.resolve(); + await running; + + expect(claimedSnapshot).toMatchObject({ + cancellation: { reason: "test.cancel" }, + terminal: null, + }); + expect(order).toEqual(["cleanup", "run.cancelled"]); + expect(socket.updates).toEqual([ + { commandId: "result-cleanup-input", status: "accepted" }, + { commandId: "result-cleanup-cancel", status: "accepted" }, + { commandId: "result-cleanup-input", status: "cancelled" }, + { commandId: "result-cleanup-cancel", status: "completed" }, + ]); + expect(socket.failedRuns).toEqual([]); + expect(shutdownCalls).toEqual([]); + expect(runtimeState.status()).toBe("ready"); + const events = socket.pushedEvents.flatMap(({ events }) => events); + expect( + events + .filter(({ kind }) => kind === "message.completed" || kind === "message.cancelled") + .map(({ kind }) => kind), + ).toEqual(["message.completed"]); + expect(events.filter(({ kind }) => kind === "agent.tasks.replaced")).toHaveLength(1); + }, + ); + + test("settles a provider-aborted result as cancellation without failing the runtime", async () => { + delete process.env[PREWARM_ENV]; + const backend = new ClaudeAgentSdkDriverBackend( + { + ...bootPayload, + runtime: "claude-agent-sdk", + runtimeTransport: "claude-agent-sdk", + } as DriverStartInput, + { + createQueryOptions: async () => ({}), + query: () => fakeQuery([cancelledResultMessage()]), + }, + ); + const socket = new FakeDriverRuntimeIo([ + { + commandId: "provider-cancelled-input", + input: { text: "cancel" }, + kind: "input.start", + requestId: "provider-cancelled-request", + runId: DRIVER_TEST_IDS.runId, + }, + ]); + let cancellationClaims = 0; + const claimRunCancellation = socket.claimRunCancellation.bind(socket); + socket.claimRunCancellation = (ticket, reason) => { + cancellationClaims += 1; + return claimRunCancellation(ticket, reason); + }; + const runtimeState = new DriverRuntimeStateMachine("ready"); + const { dispatcher, logger } = createDispatcher({ + backend, + isShuttingDown: () => socket.isDrained() && socket.currentRunId() === null, + runtimeState, + }); + + await dispatcher.run(socket, logger); + + expect( + socket.pushedEvents + .flatMap(({ events }) => events) + .filter(({ kind }) => ["run.cancelled", "run.completed", "run.failed"].includes(kind)) + .map(({ kind }) => kind), + ).toEqual(["run.cancelled"]); + expect(socket.updates).toEqual([ + { commandId: "provider-cancelled-input", status: "accepted" }, + { commandId: "provider-cancelled-input", status: "cancelled" }, + ]); + expect(socket.failedRuns).toEqual([]); + expect(cancellationClaims).toBe(0); + expect(runtimeState.status()).toBe("ready"); + }); + + test("keeps a provider failure authoritative when cancellation is claimed during cleanup", async () => { + delete process.env[PREWARM_ENV]; + const cleanupEntered = Promise.withResolvers(); + const releaseCleanup = Promise.withResolvers(); + const payload = { + ...bootPayload, + runtime: "claude-agent-sdk", + runtimeTransport: "claude-agent-sdk", + } as DriverStartInput; + const backend = new ClaudeAgentSdkDriverBackend(payload, { + createQueryOptions: async () => ({}), + query: () => + fakeQuery( + [errorResultMessage()], + () => {}, + async () => undefined, + async () => { + cleanupEntered.resolve(); + await releaseCleanup.promise; + }, + ), + }); + const socket = new FakeDriverRuntimeIo([]); + const ticket = socket.beginRun(DRIVER_TEST_IDS.runId); + const context = createAgentDriverContext({ + eventSink: socket, + logger: createDisabledLogger(), + payload, + permission: { request: async () => "reject_once" }, + }); + + const handling = backend.handleInput( + context, + { text: "fail" }, + DRIVER_TEST_IDS.runId, + ticket.signal, + ); + await cleanupEntered.promise; + expect(socket.claimRunCancellation(ticket, "test.cancel")).toBe("claimed"); + releaseCleanup.resolve(); + + await expect(handling).rejects.toThrow("failed"); + expect(socket.runSnapshot(DRIVER_TEST_IDS.runId)?.terminal).toMatchObject({ + phase: "acked", + value: { status: "failed" }, + }); + expect( + socket.pushedEvents + .flatMap(({ events }) => events) + .filter(({ kind }) => ["run.cancelled", "run.completed", "run.failed"].includes(kind)) + .map(({ kind }) => kind), + ).toEqual(["run.failed"]); + }); + test.each([ { message: resultMessage(), terminal: "run.completed" }, { message: errorResultMessage(), terminal: "run.failed" }, @@ -1183,7 +1734,6 @@ describe("Claude Agent SDK driver backend", () => { "does not replace a selected $terminal provider terminal after its delivery fails", async ({ message, terminal }) => { delete process.env[PREWARM_ENV]; - let rejectTerminal = true; const terminalAttempts: string[][] = []; const harness = createHarness( { @@ -1202,8 +1752,7 @@ describe("Claude Agent SDK driver backend", () => { if (terminals.length > 0) { terminalAttempts.push(terminals); } - if (rejectTerminal && terminals.includes(terminal)) { - rejectTerminal = false; + if (terminals.includes(terminal)) { throw new Error("terminal delivery unavailable"); } }, @@ -1213,9 +1762,8 @@ describe("Claude Agent SDK driver backend", () => { harness.backend.handleInput(harness.context, { text: "finish" }, DRIVER_TEST_IDS.runId), ).rejects.toThrow("terminal delivery unavailable"); await harness.backend.stop(harness.context, "test.complete", new AbortController().signal); - await harness.logger.destroy(); - expect(terminalAttempts).toEqual([[terminal]]); + expect(terminalAttempts).toEqual([[terminal], [terminal]]); }, ); @@ -1250,7 +1798,6 @@ describe("Claude Agent SDK driver backend", () => { await stopping; await optionsReturned.promise; await Promise.resolve(); - await harness.logger.destroy(); expect(startupCalls).toBe(0); }); @@ -1271,7 +1818,6 @@ describe("Claude Agent SDK driver backend", () => { harness.backend.handleInput(harness.context, { text: "second" }, DRIVER_TEST_IDS.secondRunId), ).rejects.toThrow("different native session"); await harness.backend.stop(harness.context, "test.complete", new AbortController().signal); - await harness.logger.destroy(); expect( harness.events.filter((event) => @@ -1283,8 +1829,69 @@ describe("Claude Agent SDK driver backend", () => { ]); }); - test("requires one result frame and ignores provider frames after it", async () => { + test("adopts a conversation reset as the next native resume session", async () => { + delete process.env[PREWARM_ENV]; + let queryIndex = 0; + const optionSessionIds: Array = []; + const harness = createHarness({ + createQueryOptions: async (input) => { + optionSessionIds.push(input.nativeSessionId); + return {}; + }, + query: () => { + queryIndex += 1; + return queryIndex === 1 + ? fakeQuery([resultMessage("native-session-1")]) + : fakeQuery([ + { + event: { message: { id: "old-message" }, type: "message_start" }, + parent_tool_use_id: null, + session_id: "native-session-1", + type: "stream_event", + uuid: "old-stream", + } as unknown as SDKMessage, + { + new_conversation_id: "native-session-2", + session_id: "native-session-1", + type: "conversation_reset", + uuid: "reset-1", + } as unknown as SDKMessage, + resultMessage("native-session-2"), + ]); + }, + startup: async () => { + throw new Error("prewarm is disabled"); + }, + }); + + await harness.backend.handleInput(harness.context, { text: "first" }, DRIVER_TEST_IDS.runId); + await harness.backend.handleInput( + harness.context, + { text: "second" }, + DRIVER_TEST_IDS.secondRunId, + ); + await harness.backend.stop(harness.context, "test.complete", new AbortController().signal); + + expect(optionSessionIds).toEqual([null, "native-session-1"]); + expect( + harness.events.flatMap((event) => { + if ( + event.kind !== "runtime.resume.updated" || + !isRecord(event.payload) || + typeof event.payload["resumePointer"] !== "string" + ) { + return []; + } + return [event.payload["resumePointer"]]; + }), + ).toEqual(["native-session-1", "native-session-2"]); + expect(harness.events.some(({ kind }) => kind === "message.cancelled")).toBe(true); + expect(harness.events.filter(({ kind }) => kind === "run.completed")).toHaveLength(2); + }); + + test("requires one result frame and drains informational frames before its terminal", async () => { delete process.env[PREWARM_ENV]; + let closedAfterTail = false; let queryIndex = 0; let lateFrameRead = false; const harness = createHarness({ @@ -1301,13 +1908,27 @@ describe("Claude Agent SDK driver backend", () => { yield resultMessage(); lateFrameRead = true; yield { - message: { content: [{ text: "late", type: "text" }] }, - parent_tool_use_id: null, + output_file: "/tmp/report.pdf", + resource_links: [ + { + mimeType: "application/pdf", + name: "report.pdf", + uri: "file:///workspace/report.pdf", + }, + ], session_id: "native-session-1", - type: "assistant", - uuid: "late-assistant", + status: "completed", + subtype: "task_notification", + summary: "done", + task_id: "task-resource", + tool_use_id: "tool-resource", + type: "system", + uuid: "post-result-resource", } as unknown as SDKMessage; })(), + () => { + closedAfterTail = lateFrameRead; + }, ); }, startup: async () => { @@ -1326,21 +1947,139 @@ describe("Claude Agent SDK driver backend", () => { DRIVER_TEST_IDS.secondRunId, ); await harness.backend.stop(harness.context, "test.complete", new AbortController().signal); - await harness.logger.destroy(); - expect(lateFrameRead).toBe(false); + const resourceEventIndex = harness.events.findIndex( + (event) => + event.kind === "tool.call.updated" && + isRecord(event.payload) && + isRecord(event.payload["structuredOutput"]), + ); + const terminalEventIndex = harness.events.findIndex( + (event) => event.kind === "run.completed" && event.runId === DRIVER_TEST_IDS.secondRunId, + ); + expect(lateFrameRead).toBe(true); + expect(closedAfterTail).toBe(true); + expect(resourceEventIndex).toBeGreaterThanOrEqual(0); + expect(harness.events[resourceEventIndex]).toMatchObject({ + kind: "tool.call.updated", + payload: { + status: "completed", + structuredOutput: { + resourceLinks: [ + { + mimeType: "application/pdf", + name: "report.pdf", + uri: "file:///workspace/report.pdf", + }, + ], + }, + toolCallId: "tool-resource", + }, + }); + expect(terminalEventIndex).toBeGreaterThan(resourceEventIndex); + }); + + test("fails closed on turn content after a result frame", async () => { + delete process.env[PREWARM_ENV]; + const harness = createHarness({ + createQueryOptions: async () => ({}), + query: () => + fakeQuery([ + resultMessage(), + { + message: { content: [{ text: "late", type: "text" }] }, + parent_tool_use_id: null, + session_id: "native-session-1", + type: "assistant", + uuid: "late-assistant", + } as unknown as SDKMessage, + ]), + startup: async () => { + throw new Error("prewarm is disabled"); + }, + }); + + await expect( + harness.backend.handleInput(harness.context, { text: "terminal" }, DRIVER_TEST_IDS.runId), + ).rejects.toThrow("turn content after its result frame"); + await harness.backend.stop(harness.context, "test.complete", new AbortController().signal); + + expect(harness.events.some((event) => event.kind === "run.completed")).toBe(false); + expect(harness.events.filter((event) => event.kind === "run.failed")).toHaveLength(1); expect( harness.events.some( (event) => event.kind === "message.delta" && - typeof event.payload === "object" && - event.payload !== null && - "contentDelta" in event.payload && - event.payload.contentDelta === "late", + isRecord(event.payload) && + event.payload["contentDelta"] === "late", ), ).toBe(false); }); + test("keeps result bookkeeping causal across a post-result conversation reset", async () => { + delete process.env[PREWARM_ENV]; + const harness = createHarness({ + createQueryOptions: async () => ({}), + query: () => + fakeQuery([ + { + message: { + content: [ + { + id: "tool-before-reset", + input: {}, + name: "Bash", + type: "tool_use", + }, + ], + }, + parent_tool_use_id: null, + session_id: "native-session-1", + type: "assistant", + uuid: "assistant-before-reset", + }, + resultMessage("native-session-1"), + { + new_conversation_id: "native-session-2", + session_id: "native-session-1", + type: "conversation_reset", + uuid: "post-result-reset", + }, + ] as unknown as SDKMessage[]), + startup: async () => { + throw new Error("prewarm is disabled"); + }, + }); + + await harness.backend.handleInput(harness.context, { text: "terminal" }, DRIVER_TEST_IDS.runId); + await harness.backend.stop(harness.context, "test.complete", new AbortController().signal); + + const resetIndex = harness.events.findIndex( + (event) => + event.kind === "runtime.resume.updated" && + isRecord(event.payload) && + event.payload["resumePointer"] === "native-session-2", + ); + const terminalIndex = harness.events.findIndex((event) => event.kind === "run.completed"); + expect(resetIndex).toBeGreaterThanOrEqual(0); + expect(terminalIndex).toBeGreaterThan(resetIndex); + expect(harness.events.filter((event) => event.kind === "run.completed")).toHaveLength(1); + expect(harness.events.some((event) => event.kind === "run.failed")).toBe(false); + expect( + harness.events.flatMap((event) => { + if ( + event.kind !== "tool.call.updated" || + !isRecord(event.payload) || + event.payload["toolCallId"] !== "tool-before-reset" || + !["cancelled", "completed", "failed"].includes(String(event.payload["status"])) + ) { + return []; + } + return [event.payload["status"]]; + }), + ).toEqual(["completed"]); + }); + function recoveryPayload( recoveryMessages: DriverStartInput["execution"]["session"]["recoveryMessages"], nativeResumeRef: DriverStartInput["execution"]["session"]["nativeResumeRef"] = null, @@ -1396,7 +2135,6 @@ describe("Claude Agent SDK driver backend", () => { DRIVER_TEST_IDS.secondRunId, ); await harness.backend.stop(harness.context, "test.complete", new AbortController().signal); - await harness.logger.destroy(); expect(prompts).toHaveLength(2); expect(prompts[0]).toContain(""); @@ -1436,7 +2174,6 @@ describe("Claude Agent SDK driver backend", () => { DRIVER_TEST_IDS.runId, ); await harness.backend.stop(harness.context, "test.complete", new AbortController().signal); - await harness.logger.destroy(); expect(prompts).toEqual(["add a page"]); }); diff --git a/tests/claude-agent-sdk-durability.test.ts b/tests/claude-agent-sdk-durability.test.ts new file mode 100644 index 0000000..d95cdf5 --- /dev/null +++ b/tests/claude-agent-sdk-durability.test.ts @@ -0,0 +1,714 @@ +import { describe, expect, test } from "bun:test"; + +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; + +import { toDriverEventEnvelopes } from "../src/infrastructure/runtime/driver-event-envelope"; +import { createDisabledLogger } from "../src/observability"; +import type { DriverBootPayload } from "../src/protocol/boot"; +import type { DriverEventInput } from "../src/protocol/events"; +import { createDriverId } from "../src/protocol/id"; +import type { EventId, RunId, SessionId } from "../src/protocol/id"; +import { createDriverStartInputFromBootPayload } from "../src/protocol/start"; +import { toRuntimeEventInput } from "../src/runtime-events"; +import { createAgentDriverContext } from "../src/core/agent-driver-backend"; +import { ClaudeDurableEventTooLargeError } from "../src/runtimes/claude/agent-sdk-event-writer"; +import { ClaudeAgentSdkMessageTranslator } from "../src/runtimes/claude/agent-sdk-message-translator"; +import { toRuntimePublicId } from "../src/runtimes/runtime-public-id"; +import { DriverEventPublisher } from "../src/runtimes/driver-event-publisher"; +import { CMA_MAX_EVENT_BYTES, encodeCmaSseRecord } from "../src/stores/cma-store"; +import { createCmaMemoryStore } from "../src/stores/memory"; +import { + DRIVER_TEST_IDS, + driverBootPayload, + driverStartInput as bootPayload, +} from "./driver-boot-payload-fixture"; +import { isRecord, messageText } from "./claude-agent-sdk-test-helpers"; + +function payload(event: DriverEventInput): Record { + return isRecord(event.payload) ? event.payload : {}; +} + +function successResult(input: { + readonly result?: string; + readonly structuredOutput?: unknown; +}): SDKMessage { + return { + is_error: false, + modelUsage: {}, + permission_denials: [], + result: input.result ?? "", + ...(input.structuredOutput === undefined ? {} : { structured_output: input.structuredOutput }), + subtype: "success", + total_cost_usd: 0, + type: "result", + usage: {}, + uuid: createDriverId(), + } as unknown as SDKMessage; +} + +function createCmaHarness(ids: { readonly runId?: RunId; readonly sessionId?: SessionId } = {}) { + const runId = ids.runId ?? (createDriverId() as RunId); + const sessionId = ids.sessionId ?? (createDriverId() as SessionId); + const events: DriverEventInput[] = []; + const sseFrameBytes: number[] = []; + const context = createAgentDriverContext({ + eventSink: { + currentRunId: () => runId, + pushEvents: async () => ({ accepted: [] }), + }, + logger: createDisabledLogger(), + payload: bootPayload, + permission: { request: async () => "allow_once" }, + }); + const store = createCmaMemoryStore({ sessions: [{ id: sessionId }] }); + const append = async (batch: readonly DriverEventInput[]) => { + for (const event of batch) { + events.push(event); + const [envelope] = toRuntimeEventInput( + { + createId: () => createDriverId() as EventId, + driverInstanceId: DRIVER_TEST_IDS.driverInstanceId, + occurredAt: "2026-08-13T00:00:00.000Z", + runId, + sessionId, + }, + event, + ); + const records = await store.appendDriverEvent(sessionId, envelope!); + sseFrameBytes.push(...records.map((record) => encodeCmaSseRecord(record).byteLength)); + } + }; + const translator = new ClaudeAgentSdkMessageTranslator({ + publicToolCallId: (nativeToolCallId) => toRuntimePublicId(nativeToolCallId, "claude-tool"), + push: async (_context, _reason, batch) => append(batch), + pushTerminal: async (_context, _reason, closures, terminal) => append([...closures, terminal]), + recordNativeSessionId: async () => {}, + replaceNativeSessionId: async () => {}, + sessionId, + }); + + return { context, events, runId, sessionId, sseFrameBytes, translator }; +} + +describe("Claude Agent SDK durable event boundaries", () => { + test("materializes a large result fallback as bounded lossless message chunks", async () => { + const harness = createCmaHarness(); + const text = `开始😀${"x".repeat(1_200_000)}结束`; + + await harness.translator.handleSdkMessage( + harness.context, + successResult({ result: text }), + harness.runId, + ); + + const terminal = harness.events.find((event) => event.kind === "run.completed"); + const finalMessageId = payload(terminal!)["finalMessageId"]; + const snapshots = harness.events.filter( + (event) => event.kind === "message.added" || event.kind === "message.delta", + ); + + expect(typeof finalMessageId).toBe("string"); + expect(snapshots.length).toBeGreaterThan(1); + expect(snapshots.every((event) => event.delivery !== "best_effort")).toBe(true); + expect(messageText(harness.events, finalMessageId as string)).toBe(text); + expect(payload(terminal!)).not.toHaveProperty("finalMessageText"); + expect(harness.sseFrameBytes.every((bytes) => bytes < CMA_MAX_EVENT_BYTES)).toBe(true); + }); + + test("rejects oversized structured output before choosing completed closures", async () => { + const harness = createCmaHarness(); + + await harness.translator.handleSdkMessage( + harness.context, + { + event: { message: { id: "native-open" }, type: "message_start" }, + type: "stream_event", + uuid: "wire-open", + } as unknown as SDKMessage, + harness.runId, + ); + await harness.translator.handleSdkMessage( + harness.context, + successResult({ structuredOutput: { data: "x".repeat(600_000) } }), + harness.runId, + ); + + const failed = harness.events.find((event) => event.kind === "run.failed"); + expect(payload(failed!)["error"]).toMatchObject({ + code: "claude.structured_output_too_large", + }); + expect(harness.events.map(({ kind }) => kind)).toContain("message.failed"); + expect(harness.events.map(({ kind }) => kind)).not.toContain("run.completed"); + expect(harness.events.map(({ kind }) => kind)).not.toContain("message.completed"); + expect(harness.sseFrameBytes.every((bytes) => bytes < CMA_MAX_EVENT_BYTES)).toBe(true); + }); + + test("keeps accepted structured output inside the real CMA and SSE boundary", async () => { + const harness = createCmaHarness(); + + await harness.translator.handleSdkMessage( + harness.context, + successResult({ structuredOutput: { data: "x".repeat(400_000) } }), + harness.runId, + ); + + expect(harness.events.map(({ kind }) => kind)).toContain("run.completed"); + expect(harness.sseFrameBytes.every((bytes) => bytes < CMA_MAX_EVENT_BYTES)).toBe(true); + }); + + test("bounds oversized provider errors before terminal delivery", async () => { + const harness = createCmaHarness(); + const providerError = "x".repeat(1_100_000); + + await harness.translator.handleSdkMessage( + harness.context, + { + errors: [providerError], + is_error: true, + modelUsage: {}, + permission_denials: [], + subtype: "error_during_execution", + terminal_reason: "model_error", + total_cost_usd: 0, + type: "result", + usage: {}, + uuid: createDriverId(), + } as unknown as SDKMessage, + harness.runId, + ); + + const failed = harness.events.find((event) => event.kind === "run.failed"); + expect(payload(failed!)["error"]).toMatchObject({ + code: "claude.error_during_execution", + details: { originalMessageUtf8Bytes: 1_100_000 }, + message: "Claude Agent SDK failure exceeded durable event capacity.", + }); + expect(JSON.stringify(failed)).not.toContain(providerError); + expect(harness.sseFrameBytes.every((bytes) => bytes < CMA_MAX_EVENT_BYTES)).toBe(true); + }); + + test("bounds oversized cancellation reasons before terminal delivery", async () => { + const harness = createCmaHarness(); + const reason = "x".repeat(1_100_000); + + await harness.translator.cancelTurn(harness.context, harness.runId, reason); + + const cancelled = harness.events.find((event) => event.kind === "run.cancelled"); + expect(payload(cancelled!)).toMatchObject({ + originalReasonUtf8Bytes: 1_100_000, + reason: "Claude cancellation reason exceeded durable event capacity.", + stopReason: "cancelled", + }); + expect(JSON.stringify(cancelled)).not.toContain(reason); + expect(harness.sseFrameBytes.every((bytes) => bytes < CMA_MAX_EVENT_BYTES)).toBe(true); + }); + + test("stores one copy of a large tool result and fails closed on oversized structured output", async () => { + const accepted = createCmaHarness(); + const content = "x".repeat(525_000); + + await accepted.translator.handleSdkMessage( + accepted.context, + { + message: { + content: [{ id: "tool-large", input: {}, name: "Read", type: "tool_use" }], + id: "assistant-tool-large", + }, + type: "assistant", + uuid: "wire-tool-large", + } as unknown as SDKMessage, + accepted.runId, + ); + await accepted.translator.handleSdkMessage( + accepted.context, + { + message: { + content: [{ content, tool_use_id: "tool-large", type: "tool_result" }], + }, + type: "user", + uuid: "wire-tool-large-result", + } as unknown as SDKMessage, + accepted.runId, + ); + + const acceptedResult = accepted.events.find( + (event) => event.kind === "tool.call.updated" && payload(event)["content"] === content, + ); + expect(acceptedResult).toBeDefined(); + expect(payload(acceptedResult!)).not.toHaveProperty("rawOutput"); + expect(accepted.sseFrameBytes.every((bytes) => bytes < CMA_MAX_EVENT_BYTES)).toBe(true); + + const rejected = createCmaHarness(); + await rejected.translator.handleSdkMessage( + rejected.context, + { + message: { + content: [{ id: "tool-oversized", input: {}, name: "Read", type: "tool_use" }], + id: "assistant-tool-oversized", + }, + type: "assistant", + uuid: "wire-tool-oversized", + } as unknown as SDKMessage, + rejected.runId, + ); + + let failure: ClaudeDurableEventTooLargeError | null = null; + try { + await rejected.translator.handleSdkMessage( + rejected.context, + { + message: { + content: [{ content: "ok", tool_use_id: "tool-oversized", type: "tool_result" }], + }, + tool_use_result: { data: "x".repeat(1_048_000) }, + type: "user", + uuid: "wire-tool-oversized-result", + } as unknown as SDKMessage, + rejected.runId, + ); + } catch (error) { + if (error instanceof ClaudeDurableEventTooLargeError) { + failure = error; + } else { + throw error; + } + } + + expect(failure?.code).toBe("claude.tool_result_too_large"); + await rejected.translator.failTurn( + rejected.context, + rejected.runId, + failure!.code, + failure!.message, + ); + expect( + rejected.events.some( + (event) => + event.kind === "tool.call.updated" && + payload(event)["toolCallId"] === "tool-oversized" && + payload(event)["status"] === "completed", + ), + ).toBe(false); + expect( + rejected.events.some( + (event) => + event.kind === "tool.call.updated" && + payload(event)["toolCallId"] === "tool-oversized" && + payload(event)["status"] === "failed", + ), + ).toBe(true); + expect( + payload(rejected.events.find((event) => event.kind === "run.failed")!)["error"], + ).toMatchObject({ code: "claude.tool_result_too_large" }); + expect(rejected.sseFrameBytes.every((bytes) => bytes < CMA_MAX_EVENT_BYTES)).toBe(true); + }); + + test("rejects an oversized tool input before it can poison terminal delivery", async () => { + const runId = DRIVER_TEST_IDS.runId; + const claudeBootPayload = { + ...driverBootPayload, + runtime: "claude-agent-sdk", + runtimeTransport: "claude-agent-sdk", + } satisfies DriverBootPayload; + const events: DriverEventInput[] = []; + const sseFrameBytes: number[] = []; + const store = createCmaMemoryStore({ sessions: [{ id: DRIVER_TEST_IDS.sessionId }] }); + let activeRunId: RunId | null = runId; + let sequence = 0; + const context = createAgentDriverContext({ + eventSink: { + currentRunId: () => activeRunId, + pushEvents: async ({ events: batch }) => { + const envelopes = batch.flatMap((event) => + toDriverEventEnvelopes(claudeBootPayload, event, activeRunId), + ); + for (const envelope of envelopes) { + const records = await store.appendDriverEvent( + DRIVER_TEST_IDS.sessionId, + envelope.event, + ); + events.push(envelope.event); + sseFrameBytes.push(...records.map((record) => encodeCmaSseRecord(record).byteLength)); + } + if (batch.some((event) => event.kind === "run.failed")) { + activeRunId = null; + } + return { + accepted: batch.map((event) => ({ + eventId: event.sourceEventId!, + seq: (sequence += 1), + type: event.kind, + })), + }; + }, + }, + logger: createDisabledLogger(), + payload: createDriverStartInputFromBootPayload(claudeBootPayload), + permission: { request: async () => "allow_once" }, + }); + const publisher = new DriverEventPublisher("claude-agent-sdk", () => "native-session-1"); + const translator = new ClaudeAgentSdkMessageTranslator({ + publicToolCallId: (nativeToolCallId) => nativeToolCallId, + push: (pushContext, reason, batch) => publisher.push(pushContext, reason, batch), + pushTerminal: (pushContext, reason, closures, terminal) => + publisher.pushTerminal(pushContext, reason, closures, terminal), + recordNativeSessionId: async () => {}, + replaceNativeSessionId: async () => {}, + sessionId: context.payload.execution.run.sessionId, + }); + + let failure: ClaudeDurableEventTooLargeError | null = null; + try { + await translator.handleSdkMessage( + context, + { + message: { + content: [ + { + id: "tool-large-input", + input: { data: "x".repeat(1_100_000) }, + name: "Write", + type: "tool_use", + }, + ], + id: "assistant-large-input", + }, + type: "assistant", + uuid: "wire-large-input", + } as unknown as SDKMessage, + runId, + ); + } catch (error) { + if (error instanceof ClaudeDurableEventTooLargeError) failure = error; + else throw error; + } + + expect(failure?.code).toBe("claude.tool_input_too_large"); + await translator.failTurn(context, runId, failure!.code, failure!.message); + expect(events.some((event) => event.kind === "run.failed")).toBe(true); + expect(events.some((event) => payload(event)["rawInput"] !== undefined)).toBe(false); + expect(sseFrameBytes.every((bytes) => bytes < CMA_MAX_EVENT_BYTES)).toBe(true); + }); + + test("closes a retained background task start before terminal failure", async () => { + const runId = DRIVER_TEST_IDS.runId; + const claudeBootPayload = { + ...driverBootPayload, + runtime: "claude-agent-sdk", + runtimeTransport: "claude-agent-sdk", + } satisfies DriverBootPayload; + const events: DriverEventInput[] = []; + let activeRunId: RunId | null = runId; + let acceptDelivery = false; + let sequence = 0; + const context = createAgentDriverContext({ + eventSink: { + currentRunId: () => activeRunId, + pushEvents: async ({ events: batch }) => { + if (!acceptDelivery) return { accepted: [] }; + const envelopes = batch.flatMap((event) => + toDriverEventEnvelopes(claudeBootPayload, event, activeRunId), + ); + events.push(...envelopes.map(({ event }) => event)); + if (batch.some((event) => event.kind === "run.failed")) activeRunId = null; + return { + accepted: envelopes.map((envelope) => ({ + eventId: envelope.eventId, + seq: (sequence += 1), + type: envelope.event.kind, + })), + }; + }, + }, + logger: createDisabledLogger(), + payload: createDriverStartInputFromBootPayload(claudeBootPayload), + permission: { request: async () => "allow_once" }, + }); + const publisher = new DriverEventPublisher("claude-agent-sdk", () => "native-session-1"); + const translator = new ClaudeAgentSdkMessageTranslator({ + publicToolCallId: (nativeToolCallId) => nativeToolCallId, + push: (pushContext, reason, batch) => publisher.push(pushContext, reason, batch), + pushTerminal: (pushContext, reason, closures, terminal) => + publisher.pushTerminal(pushContext, reason, closures, terminal), + recordNativeSessionId: async () => {}, + replaceNativeSessionId: async () => {}, + sessionId: context.payload.execution.run.sessionId, + }); + + await expect( + translator.handleSdkMessage( + context, + { + session_id: "native-session-1", + subtype: "background_tasks_changed", + tasks: [ + { + description: "Inspect the repository", + task_id: "task-1", + task_type: "local_agent", + }, + ], + type: "system", + uuid: "00000000-0000-0000-0000-000000000001", + } as unknown as SDKMessage, + runId, + ), + ).rejects.toThrow(); + + acceptDelivery = true; + await translator.failTurn(context, runId, "claude.task_delivery_failed", "delivery failed"); + + expect( + events + .filter((event) => event.kind === "agent.tasks.replaced") + .map((event) => payload(event)), + ).toMatchObject([{ tasks: [{ taskId: "task-1" }] }, { tasks: [] }]); + expect(events.at(-1)?.kind).toBe("run.failed"); + }); + + test("maps oversized native tool IDs and rejects oversized tool names before durable state", async () => { + const accepted = createCmaHarness(); + const replayed = createCmaHarness({ runId: accepted.runId, sessionId: accepted.sessionId }); + const nativeToolCallId = `tool-${"x".repeat(1_100_000)}`; + const frames = [ + { + message: { + content: [{ id: nativeToolCallId, input: {}, name: "Read", type: "tool_use" }], + id: "assistant-long-tool", + }, + type: "assistant", + uuid: "wire-long-tool", + }, + { + decision_reason: "Blocked by policy", + message: "Denied by policy", + subtype: "permission_denied", + tool_name: "Read", + tool_use_id: nativeToolCallId, + type: "system", + uuid: "wire-long-tool-advisory", + }, + { + is_error: false, + modelUsage: {}, + permission_denials: [{ tool_input: {}, tool_name: "Read", tool_use_id: nativeToolCallId }], + result: "done", + subtype: "success", + total_cost_usd: 0, + type: "result", + usage: {}, + uuid: "wire-long-tool-result", + }, + ] as unknown as SDKMessage[]; + + for (const harness of [accepted, replayed]) { + for (const frame of frames) { + await harness.translator.handleSdkMessage(harness.context, frame, harness.runId); + } + } + + const toolEvents = accepted.events.filter( + (event) => event.kind === "item.started" || event.kind === "tool.call.updated", + ); + const publicIds = toolEvents.flatMap((event) => { + const value = payload(event)[event.kind === "item.started" ? "itemId" : "toolCallId"]; + return typeof value === "string" ? [value] : []; + }); + expect(new Set(publicIds).size).toBe(1); + expect(publicIds[0]).not.toBe(nativeToolCallId); + expect( + accepted.events.some( + (event) => + event.kind === "tool.call.updated" && + payload(event)["decisionReason"] === "Blocked by policy", + ), + ).toBe(true); + expect(JSON.stringify(accepted.events)).not.toContain(nativeToolCallId); + expect(accepted.sseFrameBytes.every((bytes) => bytes < CMA_MAX_EVENT_BYTES)).toBe(true); + expect(replayed.events).toEqual(accepted.events); + + const rejected = createCmaHarness(); + const oversizedName = "n".repeat(1_100_000); + let failure: ClaudeDurableEventTooLargeError | null = null; + try { + await rejected.translator.handleSdkMessage( + rejected.context, + { + message: { + content: [{ id: "tool-long-name", input: {}, name: oversizedName, type: "tool_use" }], + id: "assistant-long-name", + }, + type: "assistant", + uuid: "wire-long-name", + } as unknown as SDKMessage, + rejected.runId, + ); + } catch (error) { + if (error instanceof ClaudeDurableEventTooLargeError) failure = error; + else throw error; + } + expect(failure?.code).toBe("claude.tool_start_too_large"); + await rejected.translator.failTurn( + rejected.context, + rejected.runId, + failure!.code, + failure!.message, + ); + expect(rejected.events.some((event) => event.kind === "item.started")).toBe(false); + expect( + payload(rejected.events.find((event) => event.kind === "run.failed")!)["error"], + ).toMatchObject({ code: "claude.tool_start_too_large" }); + expect(JSON.stringify(rejected.events)).not.toContain(oversizedName); + }); + + test("rejects oversized file paths before publishing a partial durable batch", async () => { + const harness = createCmaHarness(); + let failure: ClaudeDurableEventTooLargeError | null = null; + try { + await harness.translator.handleSdkMessage( + harness.context, + { + failed: [], + files: [{ filename: "f".repeat(1_100_000) }], + subtype: "files_persisted", + type: "system", + } as unknown as SDKMessage, + harness.runId, + ); + } catch (error) { + if (error instanceof ClaudeDurableEventTooLargeError) failure = error; + else throw error; + } + expect(failure?.code).toBe("claude.files_persisted_too_large"); + await harness.translator.failTurn( + harness.context, + harness.runId, + failure!.code, + failure!.message, + ); + expect(harness.events.some((event) => event.kind === "file.change.updated")).toBe(false); + expect( + payload(harness.events.find((event) => event.kind === "run.failed")!)["error"], + ).toMatchObject({ code: "claude.files_persisted_too_large" }); + expect(harness.sseFrameBytes.every((bytes) => bytes < CMA_MAX_EVENT_BYTES)).toBe(true); + }); + + test("bounds mirror errors and commits thought and retraction state only after delivery", async () => { + const mirror = createCmaHarness(); + await mirror.translator.handleSdkMessage( + mirror.context, + { + error: "x".repeat(525_000), + key: { subpath: "events.jsonl" }, + subtype: "mirror_error", + type: "system", + uuid: "mirror-large", + } as unknown as SDKMessage, + mirror.runId, + ); + const diagnostic = mirror.events.find((event) => event.kind === "diagnostic.reported"); + expect(diagnostic?.delivery).toBe("best_effort"); + expect(payload(diagnostic!)).toEqual({ + message: "Claude transcript mirror write failed.", + raw: { errorBytes: 525_000, kind: "claude.mirror_error" }, + severity: "error", + }); + expect(mirror.sseFrameBytes.every((bytes) => bytes < CMA_MAX_EVENT_BYTES)).toBe(true); + + const reasons: string[] = []; + const replayedEvents: DriverEventInput[] = []; + let rejectThought = true; + let rejectToolRetraction = true; + const runId = createDriverId() as RunId; + const context = createAgentDriverContext({ + eventSink: { currentRunId: () => runId, pushEvents: async () => ({ accepted: [] }) }, + logger: createDisabledLogger(), + payload: bootPayload, + permission: { request: async () => "allow_once" }, + }); + const translator = new ClaudeAgentSdkMessageTranslator({ + publicToolCallId: (nativeToolCallId) => nativeToolCallId, + push: async (_context, reason, events) => { + reasons.push(reason); + if (reason === "driver.claude.thought.completed" && rejectThought) { + rejectThought = false; + throw new Error("thought delivery failed"); + } + if (reason === "driver.claude.tool.retracted" && rejectToolRetraction) { + rejectToolRetraction = false; + throw new Error("tool retraction failed"); + } + replayedEvents.push(...events); + }, + pushTerminal: async () => {}, + recordNativeSessionId: async () => {}, + replaceNativeSessionId: async () => {}, + sessionId: context.payload.execution.run.sessionId, + }); + + await translator.handleSdkMessage( + context, + { + event: { + content_block: { thinking: "", type: "thinking" }, + index: 0, + type: "content_block_start", + }, + type: "stream_event", + uuid: "thought-wire", + } as unknown as SDKMessage, + runId, + ); + const messageStop = { + event: { type: "message_stop" }, + type: "stream_event", + uuid: "thought-wire", + } as unknown as SDKMessage; + await expect(translator.handleSdkMessage(context, messageStop, runId)).rejects.toThrow( + "thought delivery failed", + ); + await expect(translator.handleSdkMessage(context, messageStop, runId)).resolves.toBeNull(); + expect(reasons.filter((reason) => reason === "driver.claude.thought.completed")).toHaveLength( + 2, + ); + + await translator.handleSdkMessage( + context, + { + message: { + content: [ + { text: "stale", type: "text" }, + { id: "tool-stale", input: {}, name: "Read", type: "tool_use" }, + ], + id: "assistant-stale", + }, + type: "assistant", + uuid: "wire-stale", + } as unknown as SDKMessage, + runId, + ); + const fallback = { + retracted_message_uuids: ["wire-stale"], + subtype: "model_refusal_fallback", + type: "system", + uuid: "fallback", + } as unknown as SDKMessage; + await expect(translator.handleSdkMessage(context, fallback, runId)).rejects.toThrow( + "tool retraction failed", + ); + await expect(translator.handleSdkMessage(context, fallback, runId)).resolves.toBeNull(); + expect( + replayedEvents.filter( + (event) => event.kind === "message.cancelled" && payload(event)["reason"] === "superseded", + ), + ).toHaveLength(1); + expect( + replayedEvents.filter( + (event) => + event.kind === "tool.call.updated" && + payload(event)["toolCallId"] === "tool-stale" && + payload(event)["status"] === "cancelled", + ), + ).toHaveLength(1); + }); +}); diff --git a/tests/claude-agent-sdk-process.test.ts b/tests/claude-agent-sdk-process.test.ts index eddc53a..b3b95f4 100644 --- a/tests/claude-agent-sdk-process.test.ts +++ b/tests/claude-agent-sdk-process.test.ts @@ -98,6 +98,27 @@ async function expectExited(pid: number): Promise { } describe.skipIf(process.platform !== "linux")("Claude Agent SDK process supervision", () => { + test("does not claim supervision when spawn throws synchronously", async () => { + const controller = new AbortController(); + const processTasks = new Set>(); + + expect(() => + spawnClaudeCodeProcess( + { + command: "invalid\0command", + args: [], + env: {}, + signal: controller.signal, + }, + () => {}, + controller.signal, + processTasks, + ), + ).toThrow("null bytes"); + expect(processTasks.size).toBe(0); + await expect(drainClaudeTasks(processTasks)).resolves.toBeUndefined(); + }); + test("does not retry cleanup before its original attempt fails", async () => { const cleanupError = new Error("initial cleanup failed"); const initialCleanup = Promise.withResolvers(); diff --git a/tests/claude-agent-sdk-provider-fixtures-terminal.test.ts b/tests/claude-agent-sdk-provider-fixtures-terminal.test.ts index ae1bb2e..fccc4e1 100644 --- a/tests/claude-agent-sdk-provider-fixtures-terminal.test.ts +++ b/tests/claude-agent-sdk-provider-fixtures-terminal.test.ts @@ -1,21 +1,17 @@ import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; -import { createBufferedSinkLogger } from "../src/observability"; -import type { DriverEventInput } from "../src/protocol/events"; -import { isDriverId } from "../src/protocol/id"; -import type { RunId } from "../src/protocol/id"; -import type { AgentDriverContext } from "../src/core/agent-driver-backend"; -import { createAgentDriverContext } from "../src/core/agent-driver-backend"; -import { ClaudeAgentSdkMessageTranslator } from "../src/runtimes/claude/agent-sdk-message-translator"; -import { driverStartInput as bootPayload } from "./driver-boot-payload-fixture"; - -interface EventBatch { - readonly events: DriverEventInput[]; - readonly reason: string; -} +import type { MessageId, RunId } from "../src/protocol/id"; +import { + createClaudeAgentSdkHarness as createHarness, + isRecord, + messageText, +} from "./claude-agent-sdk-test-helpers"; +import { + normalizeClaudeProviderEvents as normalizeClaudeEvents, + readProviderFixture, +} from "./provider-fixture-test-helpers"; interface ClaudeProviderFixtureCase { readonly expectedEvents: readonly unknown[]; @@ -29,168 +25,67 @@ const claudeFixtureNames = [ "result-failure-diagnostic", "stream-text-thinking-tool-result", "system-files-and-session", - "unknown-message-ignored", + "unknown-message-diagnostic", ] as const; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readJsonFixture(path: string): unknown { - return JSON.parse(readFileSync(new URL(path, import.meta.url), "utf8")); -} - function readClaudeProviderFixtureCase(path: string): ClaudeProviderFixtureCase { - const fixture = readJsonFixture(path); - - if (!isRecord(fixture)) { - throw new Error("Claude provider fixture must be an object."); - } - - const messages = fixture["messages"]; - const expectedEvents = fixture["expectedEvents"]; - const expectedNativeSessionIds = fixture["expectedNativeSessionIds"] ?? []; - const runId = fixture["runId"]; + const fixture = readProviderFixture(path, { + arrays: ["expectedEvents", "messages"], + strings: ["runId"], + }); + const expectedNativeSessionIds = fixture.expectedNativeSessionIds ?? []; if ( - !Array.isArray(messages) || - !Array.isArray(expectedEvents) || !Array.isArray(expectedNativeSessionIds) || - typeof runId !== "string" + !expectedNativeSessionIds.every((entry) => typeof entry === "string") ) { - throw new Error("Claude provider fixture shape is malformed."); - } - - if (!expectedNativeSessionIds.every((entry) => typeof entry === "string")) { - throw new Error("Claude provider fixture expectedNativeSessionIds must be strings."); - } - - return { - expectedEvents, - expectedNativeSessionIds, - messages, - runId: runId as RunId, - }; -} - -function collectDriverIds(value: unknown, ids: Set): void { - if (typeof value === "string") { - if (isDriverId(value)) { - ids.add(value); - } - return; - } - - if (Array.isArray(value)) { - for (const entry of value) { - collectDriverIds(entry, ids); - } - return; - } - - if (!isRecord(value)) { - return; - } - - for (const entry of Object.values(value)) { - collectDriverIds(entry, ids); - } -} - -function isIsoTimestamp(value: string): boolean { - return value.endsWith("Z") && !Number.isNaN(Date.parse(value)); -} - -function normalizeClaudeValue( - value: unknown, - driverIds: ReadonlySet, - fieldName?: string, -): unknown { - if (typeof value === "string") { - for (const driverId of driverIds) { - if (value === driverId) { - return ""; - } - - if (value.startsWith(`${driverId}:`)) { - return value.replace(driverId, ""); - } - } - - if (fieldName !== undefined && fieldName.endsWith("At") && isIsoTimestamp(value)) { - return ""; - } - - return value; - } - - if (Array.isArray(value)) { - return value.map((entry) => normalizeClaudeValue(entry, driverIds)); - } - - if (!isRecord(value)) { - return value; + throw new TypeError(`Provider fixture ${path} has malformed native session IDs.`); } - const entries = Object.entries(value).flatMap(([key, entry]): [string, unknown][] => - entry === undefined ? [] : [[key, normalizeClaudeValue(entry, driverIds, key)]], - ); - - return Object.fromEntries(entries); + return { ...fixture, expectedNativeSessionIds }; } -function normalizeClaudeEvents(events: readonly DriverEventInput[]): unknown[] { - const driverIds = new Set(); - - for (const event of events) { - collectDriverIds(event, driverIds); - } - - return events.map((event) => normalizeClaudeValue(event, driverIds)); -} - -function createHarness() { - const batches: EventBatch[] = []; - const nativeSessionIds: string[] = []; - const logger = createBufferedSinkLogger({ - level: "debug", - service: "claude-agent-sdk-provider-fixtures-test", - sink: async () => {}, - }); - const context: AgentDriverContext = createAgentDriverContext({ - eventSink: { - pushEvents: async () => ({ accepted: [] }), - }, - logger, - payload: bootPayload, - permission: { - request: async () => "allow_once", - }, - }); - const translator = new ClaudeAgentSdkMessageTranslator({ - push: async (_context, reason, events) => { - batches.push({ events, reason }); - }, - recordNativeSessionId: async (_context, sessionId) => { - nativeSessionIds.push(sessionId); - }, +describe("Claude Agent SDK provider fixtures", () => { + test("preserves Driver ID equivalence classes while normalizing fixtures", () => { + const firstMessageId = "01ARZ3NDEKTSV4RRFFQ69G5FAV" as MessageId; + const secondMessageId = "01ARZ3NDEKTSV4RRFFQ69G5FAW" as MessageId; + + expect( + normalizeClaudeEvents([ + { + kind: "message.started", + payload: { messageId: firstMessageId, role: "agent" }, + }, + { + kind: "thought.started", + payload: { channel: "summary", thoughtId: `${firstMessageId}:thought` }, + }, + { + kind: "message.started", + payload: { messageId: secondMessageId, role: "agent" }, + }, + ]), + ).toEqual([ + { + kind: "message.started", + payload: { messageId: "", role: "agent" }, + }, + { + kind: "thought.started", + payload: { channel: "summary", thoughtId: ":thought" }, + }, + { + kind: "message.started", + payload: { messageId: "", role: "agent" }, + }, + ]); }); - return { - context, - events: () => batches.flatMap((batch) => batch.events), - logger, - nativeSessionIds, - translator, - }; -} - -describe("Claude Agent SDK provider fixtures", () => { test("keeps chunked stream deltas and the complete assistant snapshot on one message", async () => { // Real SDK wire shape: every envelope (each stream chunk and the complete // assistant replay) carries a distinct uuid; only the API message id inside // message_start / assistant.message is stable across the whole message. - const { context, events, logger, translator } = createHarness(); + const { events, handleMessages } = createHarness(); const messages = [ { event: { @@ -256,10 +151,7 @@ describe("Claude Agent SDK provider fixtures", () => { }, ] as unknown as SDKMessage[]; - for (const message of messages) { - await translator.handleSdkMessage(context, message, "run-1" as RunId); - } - await logger.destroy(); + await handleMessages(messages); const startedEvents = events().filter((event) => event.kind === "message.started"); const completedEvents = events().filter((event) => event.kind === "message.completed"); @@ -283,11 +175,12 @@ describe("Claude Agent SDK provider fixtures", () => { expect(textMessages.map((entry) => entry.contentDelta)).toEqual(["Got it —", " I'm here."]); expect(new Set(textMessages.map((entry) => entry.messageId)).size).toBe(1); expect(payload?.["finalMessageId"]).toBe(textMessages[0]?.messageId); - expect(payload?.["finalMessageText"]).toBe("Got it — I'm here."); + expect(payload).not.toHaveProperty("finalMessageText"); + expect(messageText(events(), payload?.["finalMessageId"])).toBe("Got it — I'm here."); }); test("scopes interleaved subagent stream chunks away from the main message", async () => { - const { context, events, logger, translator } = createHarness(); + const { events, handleMessages } = createHarness(); const messages = [ { event: { @@ -336,10 +229,7 @@ describe("Claude Agent SDK provider fixtures", () => { }, ] as unknown as SDKMessage[]; - for (const message of messages) { - await translator.handleSdkMessage(context, message, "run-1" as RunId); - } - await logger.destroy(); + await handleMessages(messages); const textByMessageId = new Map(); @@ -360,7 +250,7 @@ describe("Claude Agent SDK provider fixtures", () => { }); test("does not promote a replayed stream-only message stop to canonical final", async () => { - const { context, events, logger, translator } = createHarness(); + const { events, handleMessages } = createHarness(); const messages = [ { event: { @@ -393,10 +283,7 @@ describe("Claude Agent SDK provider fixtures", () => { }, ] as unknown as SDKMessage[]; - for (const message of messages) { - await translator.handleSdkMessage(context, message, "run-1" as RunId); - } - await logger.destroy(); + await handleMessages(messages); const liveText = events().flatMap((event) => { if (event.kind !== "message.delta" || !isRecord(event.payload)) { @@ -417,7 +304,7 @@ describe("Claude Agent SDK provider fixtures", () => { }); test("materializes a result-only native resume completion", async () => { - const { context, events, logger, translator } = createHarness(); + const { events, handleMessages } = createHarness(); const messages = [ { event: { type: "message_stop" }, @@ -434,10 +321,7 @@ describe("Claude Agent SDK provider fixtures", () => { }, ] as unknown as SDKMessage[]; - for (const message of messages) { - await translator.handleSdkMessage(context, message, "run-1" as RunId); - } - await logger.destroy(); + await handleMessages(messages); const translated = events(); const started = translated.filter((event) => event.kind === "message.started"); @@ -455,11 +339,358 @@ describe("Claude Agent SDK provider fixtures", () => { { text: "recovered final answer", type: "text" }, ]); expect(completedPayload?.["finalMessageId"]).toBe(snapshotPayload?.["messageId"]); - expect(completedPayload?.["finalMessageText"]).toBe("recovered final answer"); + expect(completedPayload).not.toHaveProperty("finalMessageText"); + expect(messageText(translated, completedPayload?.["finalMessageId"])).toBe( + "recovered final answer", + ); + }); + + test("keeps structured output as the canonical final payload", async () => { + const { context, events, translator } = createHarness(); + + await translator.handleSdkMessage( + context, + { + is_error: false, + modelUsage: {}, + permission_denials: [], + result: "structured output placeholder", + structured_output: { answer: 42, citations: ["source-1"] }, + subtype: "success", + total_cost_usd: 0, + type: "result", + usage: {}, + uuid: "result-structured", + } as unknown as SDKMessage, + "run-1" as RunId, + ); + + expect(events().map(({ kind }) => kind)).not.toContain("message.added"); + expect(events()).toContainEqual({ + kind: "run.completed", + payload: { + stopReason: "end_turn", + structuredOutput: { answer: 42, citations: ["source-1"] }, + }, + runId: "run-1" as RunId, + }); + }); + + test("fails closed for a non-JSON structured output", async () => { + const { context, events, translator } = createHarness(); + + await translator.handleSdkMessage( + context, + { + is_error: false, + modelUsage: {}, + permission_denials: [], + result: "structured output placeholder", + structured_output: Symbol("invalid"), + subtype: "success", + total_cost_usd: 0, + type: "result", + usage: {}, + uuid: "result-invalid-structured", + } as unknown as SDKMessage, + "run-1" as RunId, + ); + + expect(events()).toContainEqual( + expect.objectContaining({ + kind: "run.failed", + payload: expect.objectContaining({ + error: expect.objectContaining({ code: "claude.invalid_structured_output" }), + }), + }), + ); + expect(events().map(({ kind }) => kind)).not.toContain("run.completed"); + }); + + test("retracts a superseded refusal before publishing its replacement", async () => { + const { events, handleMessages } = createHarness(); + const messages = [ + { + message: { + content: [{ text: "stale refusal", type: "text" }], + id: "native-refusal", + }, + parent_tool_use_id: null, + session_id: "session-1", + type: "assistant", + uuid: "wire-refusal", + }, + { + message: { + content: [{ text: "canonical replacement", type: "text" }], + id: "native-replacement", + }, + parent_tool_use_id: null, + session_id: "session-1", + supersedes: ["wire-refusal"], + type: "assistant", + uuid: "wire-replacement", + }, + { + is_error: false, + modelUsage: {}, + permission_denials: [], + result: "canonical replacement", + subtype: "success", + total_cost_usd: 0, + type: "result", + usage: {}, + uuid: "result-replacement", + }, + ] as unknown as SDKMessage[]; + + await handleMessages(messages); + + const snapshots = events().flatMap((event) => { + if (event.kind !== "message.added" || !isRecord(event.payload)) { + return []; + } + const content = event.payload["content"]; + const messageId = event.payload["messageId"]; + return Array.isArray(content) && typeof messageId === "string" + ? [{ messageId, text: (content[0] as { text?: unknown } | undefined)?.text }] + : []; + }); + const stale = snapshots.find(({ text }) => text === "stale refusal"); + const replacement = snapshots.find(({ text }) => text === "canonical replacement"); + const cancellationIndex = events().findIndex( + (event) => + event.kind === "message.cancelled" && + isRecord(event.payload) && + event.payload["messageId"] === stale?.messageId, + ); + const replacementIndex = events().findIndex( + (event) => + event.kind === "message.added" && + isRecord(event.payload) && + event.payload["messageId"] === replacement?.messageId, + ); + const completed = events().find((event) => event.kind === "run.completed"); + + expect(stale).toBeDefined(); + expect(replacement).toBeDefined(); + expect(cancellationIndex).toBeGreaterThan(-1); + expect(cancellationIndex).toBeLessThan(replacementIndex); + expect(completed?.payload).toMatchObject({ + finalMessageId: replacement?.messageId, + }); + expect(completed?.payload).not.toHaveProperty("finalMessageText"); + expect(messageText(events(), replacement?.messageId)).toBe("canonical replacement"); + }); + + test("applies refusal fallback retractions idempotently to messages and tool results", async () => { + const { events, handleMessages } = createHarness(); + const fallback = { + content: "Retrying with fallback model.", + direction: "retry", + fallback_model: "claude-fallback", + original_model: "claude-primary", + request_id: "request-1", + retracted_message_uuids: ["wire-refusal", "wire-tool-result", "unknown-wire"], + session_id: "session-1", + subtype: "model_refusal_fallback", + trigger: "refusal", + type: "system", + uuid: "fallback-notice", + }; + const messages = [ + { + message: { + content: [ + { text: "stale refusal", type: "text" }, + { id: "tool-old", input: { command: "pwd" }, name: "Bash", type: "tool_use" }, + ], + id: "native-refusal", + }, + parent_tool_use_id: null, + session_id: "session-1", + type: "assistant", + uuid: "wire-refusal", + }, + { + message: { + content: [{ content: "stale tool result", tool_use_id: "tool-old", type: "tool_result" }], + }, + session_id: "session-1", + type: "user", + uuid: "wire-tool-result", + }, + fallback, + { ...fallback, uuid: "fallback-notice-replayed" }, + { + is_error: false, + modelUsage: {}, + permission_denials: [ + { + decisionReason: "denied before fallback", + tool_input: { command: "pwd" }, + tool_name: "Bash", + tool_use_id: "tool-old", + }, + ], + result: "canonical replacement", + subtype: "success", + total_cost_usd: 0, + type: "result", + usage: {}, + uuid: "late-authoritative-denial", + }, + { + message: { + content: [{ text: "canonical replacement", type: "text" }], + id: "native-replacement", + }, + parent_tool_use_id: null, + session_id: "session-1", + type: "assistant", + uuid: "wire-replacement", + }, + { + is_error: false, + modelUsage: {}, + permission_denials: [], + result: "canonical replacement", + subtype: "success", + total_cost_usd: 0, + type: "result", + usage: {}, + uuid: "result-replacement", + }, + ] as unknown as SDKMessage[]; + + await handleMessages(messages); + + const retractedMessages = events().filter( + (event) => + event.kind === "message.cancelled" && + isRecord(event.payload) && + event.payload["reason"] === "superseded", + ); + const cancelledTools = events().filter( + (event) => + event.kind === "tool.call.updated" && + isRecord(event.payload) && + event.payload["status"] === "cancelled" && + event.payload["toolCallId"] === "tool-old", + ); + const completed = events().find((event) => event.kind === "run.completed"); + const retractionIndex = events().findIndex( + (event) => + event.kind === "tool.call.updated" && + isRecord(event.payload) && + event.payload["status"] === "cancelled" && + event.payload["toolCallId"] === "tool-old", + ); + const toolUpdatesAfterRetraction = events() + .slice(retractionIndex + 1) + .filter( + (event) => + event.kind === "tool.call.updated" && + isRecord(event.payload) && + event.payload["toolCallId"] === "tool-old", + ); + + expect(retractedMessages).toHaveLength(1); + expect(cancelledTools).toHaveLength(1); + expect(toolUpdatesAfterRetraction).toEqual([]); + expect(completed?.payload).not.toHaveProperty("finalMessageText"); + expect( + messageText( + events(), + isRecord(completed?.payload) ? completed.payload["finalMessageId"] : null, + ), + ).toBe("canonical replacement"); + }); + + test("keeps assistant envelopes distinct when they share an API message id", async () => { + const { events, handleMessages } = createHarness(); + const messages = [ + { + message: { content: [{ text: "first", type: "text" }], id: "shared-native" }, + parent_tool_use_id: null, + session_id: "session-1", + type: "assistant", + uuid: "wire-first", + }, + { + message: { content: [{ text: "second", type: "text" }], id: "shared-native" }, + parent_tool_use_id: null, + session_id: "session-1", + type: "assistant", + uuid: "wire-second", + }, + { + is_error: false, + modelUsage: {}, + permission_denials: [], + result: "second", + subtype: "success", + total_cost_usd: 0, + type: "result", + usage: {}, + uuid: "result-second", + }, + ] as unknown as SDKMessage[]; + + await handleMessages(messages); + + const snapshots = events().flatMap((event) => { + if (event.kind !== "message.added" || !isRecord(event.payload)) { + return []; + } + const content = event.payload["content"]; + const messageId = event.payload["messageId"]; + return Array.isArray(content) && typeof messageId === "string" + ? [{ messageId, text: (content[0] as { text?: unknown } | undefined)?.text }] + : []; + }); + const completed = events().find((event) => event.kind === "run.completed"); + + expect(snapshots.map(({ text }) => text)).toEqual(["first", "second"]); + expect(new Set(snapshots.map(({ messageId }) => messageId)).size).toBe(2); + expect(completed?.payload).toMatchObject({ + finalMessageId: snapshots[1]?.messageId, + }); + expect(completed?.payload).not.toHaveProperty("finalMessageText"); + expect(messageText(events(), snapshots[1]?.messageId)).toBe("second"); + }); + + test("turns provider-aborted result frames into runtime cancellation", async () => { + const { context, events, translator } = createHarness(); + + await translator.handleSdkMessage( + context, + { + errors: ["aborted"], + is_error: true, + modelUsage: {}, + permission_denials: [], + subtype: "error_during_execution", + terminal_reason: "aborted_tools", + total_cost_usd: 0, + type: "result", + usage: {}, + uuid: "result-aborted", + } as unknown as SDKMessage, + "run-1" as RunId, + ); + + expect(events()).toContainEqual( + expect.objectContaining({ + kind: "run.cancelled", + payload: expect.objectContaining({ reason: "aborted_tools" }), + }), + ); + expect(events().map(({ kind }) => kind)).not.toContain("run.failed"); }); test("does not let a late older assistant completion replace the final message", async () => { - const { context, events, logger, translator } = createHarness(); + const { events, handleMessages } = createHarness(); const messages = [ { event: { @@ -493,10 +724,7 @@ describe("Claude Agent SDK provider fixtures", () => { }, ] as unknown as SDKMessage[]; - for (const message of messages) { - await translator.handleSdkMessage(context, message, "run-1" as RunId); - } - await logger.destroy(); + await handleMessages(messages); const runCompleted = events().find((event) => event.kind === "run.completed"); const payload = @@ -504,11 +732,12 @@ describe("Claude Agent SDK provider fixtures", () => { expect(runCompleted).toBeDefined(); expect(payload).not.toBeNull(); - expect(payload?.["finalMessageText"]).toBe("最终回答 B"); + expect(payload).not.toHaveProperty("finalMessageText"); + expect(messageText(events(), payload?.["finalMessageId"])).toBe("最终回答 B"); }); test("fails closed when a newer assistant is still incomplete at result success", async () => { - const { context, events, logger, translator } = createHarness(); + const { events, handleMessages } = createHarness(); const messages = [ { message: { @@ -535,10 +764,7 @@ describe("Claude Agent SDK provider fixtures", () => { }, ] as unknown as SDKMessage[]; - for (const message of messages) { - await translator.handleSdkMessage(context, message, "run-1" as RunId); - } - await logger.destroy(); + await handleMessages(messages); const runCompleted = events().find((event) => event.kind === "run.completed"); const payload = @@ -551,7 +777,7 @@ describe("Claude Agent SDK provider fixtures", () => { }); test("fails closed when an older full frame arrives after a newer incomplete message", async () => { - const { context, events, logger, translator } = createHarness(); + const { events, handleMessages } = createHarness(); const messages = [ { event: { @@ -578,10 +804,7 @@ describe("Claude Agent SDK provider fixtures", () => { }, ] as unknown as SDKMessage[]; - for (const message of messages) { - await translator.handleSdkMessage(context, message, "run-1" as RunId); - } - await logger.destroy(); + await handleMessages(messages); const runCompleted = events().find((event) => event.kind === "run.completed"); const payload = @@ -598,7 +821,7 @@ describe("Claude Agent SDK provider fixtures", () => { // scope comes from the burst anchor: parallel tool blocks of one message // are told apart by content-block index, and a message_stop boundary // separates one message's thought stream from the next. - const { context, events, logger, translator } = createHarness(); + const { events, handleMessages } = createHarness(); const stream = (uuid: string, event: Record) => ({ event, type: "stream_event", uuid }) as unknown as SDKMessage; const messages = [ @@ -656,20 +879,17 @@ describe("Claude Agent SDK provider fixtures", () => { }), ]; - for (const message of messages) { - await translator.handleSdkMessage(context, message, "run-1" as RunId); - } - await logger.destroy(); + await handleMessages(messages); const toolArguments = events().flatMap((event) => { if (event.kind !== "tool.call.updated" || !isRecord(event.payload)) { return []; } - const rawInput = event.payload["rawInput"]; + const rawInputDelta = event.payload["rawInputDelta"]; const toolCallId = event.payload["toolCallId"]; - return typeof rawInput === "string" && typeof toolCallId === "string" - ? [{ rawInput, toolCallId }] + return typeof rawInputDelta === "string" && typeof toolCallId === "string" + ? [{ rawInputDelta, toolCallId }] : []; }); const thoughtDeltas = events().flatMap((event) => { @@ -688,9 +908,9 @@ describe("Claude Agent SDK provider fixtures", () => { ); expect(toolArguments).toEqual([ - { rawInput: '{"a":', toolCallId: "tool-a" }, - { rawInput: '{"b":1}', toolCallId: "tool-b" }, - { rawInput: "1}", toolCallId: "tool-a" }, + { rawInputDelta: '{"a":', toolCallId: "tool-a" }, + { rawInputDelta: '{"b":1}', toolCallId: "tool-b" }, + { rawInputDelta: "1}", toolCallId: "tool-a" }, ]); expect(thoughts["A1"]).toBe(thoughts["A2"]); expect(thoughts["A1"]).not.toBe(thoughts["B1"]); @@ -700,13 +920,9 @@ describe("Claude Agent SDK provider fixtures", () => { const fixture = readClaudeProviderFixtureCase( `./fixtures/providers/claude-agent-sdk/cases/${name}.json`, ); - const { context, events, logger, nativeSessionIds, translator } = createHarness(); - - for (const message of fixture.messages) { - await translator.handleSdkMessage(context, message as SDKMessage, fixture.runId); - } + const { events, handleMessages, nativeSessionIds } = createHarness(); - await logger.destroy(); + await handleMessages(fixture.messages as SDKMessage[], fixture.runId); expect(nativeSessionIds).toEqual(fixture.expectedNativeSessionIds); expect(normalizeClaudeEvents(events())).toEqual(fixture.expectedEvents); diff --git a/tests/claude-agent-sdk-provider-fixtures-transcript.test.ts b/tests/claude-agent-sdk-provider-fixtures-transcript.test.ts index 735a852..1a33655 100644 --- a/tests/claude-agent-sdk-provider-fixtures-transcript.test.ts +++ b/tests/claude-agent-sdk-provider-fixtures-transcript.test.ts @@ -2,62 +2,445 @@ import { describe, expect, test } from "bun:test"; import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; -import { createBufferedSinkLogger } from "../src/observability"; -import type { DriverEventInput } from "../src/protocol/events"; import type { RunId } from "../src/protocol/id"; -import type { AgentDriverContext } from "../src/core/agent-driver-backend"; -import { createAgentDriverContext } from "../src/core/agent-driver-backend"; -import { ClaudeAgentSdkMessageTranslator } from "../src/runtimes/claude/agent-sdk-message-translator"; -import { driverStartInput as bootPayload } from "./driver-boot-payload-fixture"; - -interface EventBatch { - readonly events: DriverEventInput[]; - readonly reason: string; -} - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function createHarness() { - const batches: EventBatch[] = []; - const nativeSessionIds: string[] = []; - const logger = createBufferedSinkLogger({ - level: "debug", - service: "claude-agent-sdk-provider-fixtures-test", - sink: async () => {}, +import { + createClaudeAgentSdkHarness as createHarness, + isRecord, + messageText, +} from "./claude-agent-sdk-test-helpers"; + +describe("Claude Agent SDK provider fixtures", () => { + test("projects visible task activity without leaking task internals", async () => { + const { events, handleMessages } = createHarness(); + const messages = [ + { + description: "Inspect the repository", + is_backgrounded: true, + prompt: "private task prompt", + session_id: "native-session-1", + subtype: "task_started", + task_id: "task-1", + task_type: "local_agent", + tool_use_id: "tool-1", + type: "system", + uuid: "task-started-1", + }, + { + description: "Inspecting tests", + last_tool_name: "Read", + session_id: "native-session-1", + subtype: "task_progress", + summary: "private progress summary", + task_id: "task-1", + tool_use_id: "tool-1", + type: "system", + usage: { duration_ms: 10, tool_uses: 1, total_tokens: 50 }, + uuid: "task-progress-1", + }, + { + patch: { description: "Finalizing", error: "private task error", status: "running" }, + session_id: "native-session-1", + subtype: "task_updated", + task_id: "task-1", + type: "system", + uuid: "task-updated-1", + }, + { + elapsed_time_seconds: 5, + heartbeat: true, + parent_tool_use_id: null, + session_id: "native-session-1", + tool_name: "Agent", + tool_use_id: "tool-1", + type: "tool_progress", + uuid: "tool-heartbeat-1", + }, + { + output_file: "/tmp/private-task-output", + resource_links: [ + { + mimeType: "application/pdf", + name: "report.pdf", + uri: "file:///workspace/report.pdf", + }, + ], + session_id: "native-session-1", + status: "completed", + subtype: "task_notification", + summary: "private terminal summary", + task_id: "task-1", + tool_use_id: "tool-1", + type: "system", + usage: { duration_ms: 25, tool_uses: 2, total_tokens: 100 }, + uuid: "task-notification-1", + }, + { + session_id: "native-session-1", + subtype: "background_tasks_changed", + tasks: [ + { + description: "Inspect the repository", + task_id: "task-1", + task_type: "local_agent", + }, + { + ambient: true, + description: "private ambient task", + task_id: "ambient-task", + task_type: "local_agent", + }, + ], + type: "system", + uuid: "background-tasks-1", + }, + { + session_id: "native-session-1", + subtype: "background_tasks_changed", + tasks: [], + type: "system", + uuid: "background-tasks-2", + }, + ] as unknown as SDKMessage[]; + + await handleMessages(messages); + + const taskEvents = events().filter((event) => event.kind === "agent.tasks.replaced"); + expect(taskEvents).toHaveLength(2); + expect(taskEvents.at(0)).toMatchObject({ + delivery: "lossless", + payload: { + tasks: [ + { + taskId: "task-1", + taskType: "local_agent", + title: "Inspect the repository", + }, + ], + }, + visibility: "participant", + }); + expect(taskEvents.at(1)).toEqual({ + delivery: "lossless", + kind: "agent.tasks.replaced", + payload: { tasks: [] }, + visibility: "participant", + }); + expect(JSON.stringify(taskEvents)).not.toContain("private"); + expect(events().filter((event) => event.kind === "diagnostic.reported")).toHaveLength(4); + expect(events()).toContainEqual( + expect.objectContaining({ + kind: "tool.call.updated", + payload: expect.objectContaining({ + status: "completed", + structuredOutput: { + resourceLinks: [ + { + mimeType: "application/pdf", + name: "report.pdf", + uri: "file:///workspace/report.pdf", + }, + ], + }, + toolCallId: "tool-1", + }), + }), + ); }); - const context: AgentDriverContext = createAgentDriverContext({ - eventSink: { - pushEvents: async () => ({ accepted: [] }), - }, - logger, - payload: bootPayload, - permission: { - request: async () => "allow_once", - }, + + test("closes visible tasks before the run terminal and resets them between turns", async () => { + const { context, events, translator } = createHarness(); + + await translator.handleSdkMessage( + context, + { + session_id: "native-session-1", + subtype: "background_tasks_changed", + tasks: [ + { + description: "Inspect the repository", + task_id: "task-1", + task_type: "local_agent", + }, + ], + type: "system", + uuid: "background-tasks-1", + } as unknown as SDKMessage, + "run-1" as RunId, + ); + await translator.handleSdkMessage( + context, + { + result: "", + subtype: "success", + total_cost_usd: 0, + type: "result", + usage: {}, + uuid: "result-1", + } as unknown as SDKMessage, + "run-1" as RunId, + ); + + const terminalIndex = events().findIndex((event) => event.kind === "run.completed"); + expect(terminalIndex).toBeGreaterThan(0); + expect(events().slice(0, terminalIndex)).toContainEqual({ + delivery: "lossless", + kind: "agent.tasks.replaced", + payload: { tasks: [] }, + visibility: "participant", + }); + + const beforeReset = events().length; + translator.resetTurnMessageState(); + await translator.handleSdkMessage( + context, + { + session_id: "native-session-1", + subtype: "background_tasks_changed", + tasks: [ + { + description: "Inspect the repository again", + task_id: "task-1", + task_type: "local_agent", + }, + ], + type: "system", + uuid: "background-tasks-2", + } as unknown as SDKMessage, + "run-2" as RunId, + ); + expect(events().slice(beforeReset)).toContainEqual({ + delivery: "lossless", + kind: "agent.tasks.replaced", + payload: { + tasks: [ + { + taskId: "task-1", + taskType: "local_agent", + title: "Inspect the repository again", + }, + ], + }, + visibility: "participant", + }); }); - const translator = new ClaudeAgentSdkMessageTranslator({ - push: async (_context, reason, events) => { - batches.push({ events, reason }); - }, - recordNativeSessionId: async (_context, sessionId) => { - nativeSessionIds.push(sessionId); - }, + + test("preserves the prior task snapshot when the visible-task bound is exceeded", async () => { + const { context, events, translator } = createHarness(); + const message = (tasks: Array<{ description: string; task_id: string; task_type: string }>) => + ({ + session_id: "native-session-1", + subtype: "background_tasks_changed", + tasks, + type: "system", + uuid: `background-tasks-${String(tasks.length)}`, + }) as unknown as SDKMessage; + + await translator.handleSdkMessage( + context, + message([{ description: "Inspect", task_id: "task-1", task_type: "local_agent" }]), + "run-1" as RunId, + ); + await translator.handleSdkMessage( + context, + message( + Array.from({ length: 257 }, (_, index) => ({ + description: "Inspect", + task_id: `task-${String(index)}`, + task_type: "local_agent", + })), + ), + "run-1" as RunId, + ); + + expect(events().filter((event) => event.kind === "agent.tasks.replaced")).toMatchObject([ + { payload: { tasks: [{ taskId: "task-1" }] } }, + ]); + expect(events().filter((event) => event.kind === "diagnostic.reported")).toMatchObject([ + { payload: { code: "claude.visible_background_tasks_too_many" } }, + ]); }); - return { - context, - events: () => batches.flatMap((batch) => batch.events), - logger, - nativeSessionIds, - translator, - }; -} + test("projects informational, local command, mirror failure, and conversation reset frames", async () => { + const { events, handleMessages, nativeSessionResets } = createHarness(); + const messages = [ + { + content: "A stop hook blocked continuation.", + level: "warning", + prevent_continuation: true, + session_id: "native-session-1", + subtype: "informational", + tool_use_id: "tool-1", + type: "system", + uuid: "informational-1", + }, + { + error: "Transcript mirror write failed.", + key: { + projectKey: "project-1", + sessionId: "native-session-1", + subpath: "events.jsonl", + }, + session_id: "native-session-1", + subtype: "mirror_error", + type: "system", + uuid: "mirror-1", + }, + { + new_conversation_id: "native-session-2", + session_id: "native-session-1", + type: "conversation_reset", + uuid: "reset-1", + }, + { + content: "Local command output.", + session_id: "native-session-2", + subtype: "local_command_output", + type: "system", + uuid: "local-command-1", + }, + ] as unknown as SDKMessage[]; + + await handleMessages(messages); + + expect(events()).toContainEqual( + expect.objectContaining({ + kind: "message.added", + payload: expect.objectContaining({ + content: [{ text: "A stop hook blocked continuation.", type: "text" }], + level: "warning", + preventContinuation: true, + subtype: "informational", + toolCallId: "tool-1", + }), + }), + ); + expect(events()).toContainEqual( + expect.objectContaining({ + kind: "message.added", + payload: expect.objectContaining({ + content: [{ text: "Local command output.", type: "text" }], + subtype: "local_command_output", + }), + }), + ); + expect(events()).toContainEqual( + expect.objectContaining({ + delivery: "best_effort", + kind: "diagnostic.reported", + payload: expect.objectContaining({ + message: "Claude transcript mirror write failed.", + raw: { + errorBytes: 31, + kind: "claude.mirror_error", + }, + severity: "error", + }), + }), + ); + expect(events()).toContainEqual( + expect.objectContaining({ + kind: "session.info.updated", + payload: expect.objectContaining({ title: null }), + }), + ); + expect(nativeSessionResets).toEqual([["native-session-1", "native-session-2"]]); + }); + + test("cancels a truncated assistant frame", async () => { + const { context, events, translator } = createHarness(); + + await translator.handleSdkMessage( + context, + { + aborted: true, + message: { content: [{ text: "partial", type: "text" }] }, + type: "assistant", + uuid: "assistant-aborted", + } as unknown as SDKMessage, + "run-1" as RunId, + ); + + const transcript = events(); + expect(transcript).toContainEqual( + expect.objectContaining({ + kind: "message.added", + payload: expect.objectContaining({ content: [{ text: "partial", type: "text" }] }), + }), + ); + expect(transcript.findIndex(({ kind }) => kind === "message.added")).toBeLessThan( + transcript.findIndex(({ kind }) => kind === "message.cancelled"), + ); + expect(transcript.map(({ kind }) => kind)).not.toContain("message.completed"); + }); + + test("fails an assistant frame carrying an SDK error", async () => { + const { context, events, translator } = createHarness(); + + await translator.handleSdkMessage( + context, + { + error: "rate_limit", + message: { content: [] }, + type: "assistant", + uuid: "assistant-error", + } as unknown as SDKMessage, + "run-1" as RunId, + ); + + expect(events()).toContainEqual( + expect.objectContaining({ + kind: "message.failed", + payload: expect.objectContaining({ + error: expect.objectContaining({ code: "claude.rate_limit", retryable: true }), + }), + }), + ); + expect(events().findIndex(({ kind }) => kind === "message.started")).toBeLessThan( + events().findIndex(({ kind }) => kind === "message.failed"), + ); + expect(events().map(({ kind }) => kind)).not.toContain("message.completed"); + }); + + test("fails a terminating API error carried by a success result", async () => { + const { context, events, translator } = createHarness(); + + await translator.handleSdkMessage( + context, + { + api_error_status: 529, + is_error: true, + modelUsage: {}, + permission_denials: [], + result: "API Error: 529", + session_id: "native-session-1", + stop_reason: null, + subtype: "success", + terminal_reason: "api_error", + total_cost_usd: 0, + type: "result", + usage: {}, + uuid: "result-api-error", + } as unknown as SDKMessage, + "run-1" as RunId, + ); + + expect(events()).toContainEqual( + expect.objectContaining({ + kind: "run.failed", + payload: expect.objectContaining({ + error: expect.objectContaining({ + details: { apiErrorStatus: 529, terminalReason: "api_error" }, + retryable: true, + }), + recoverable: true, + }), + }), + ); + expect(events().map(({ kind }) => kind)).not.toContain("run.completed"); + }); -describe("Claude Agent SDK provider fixtures", () => { test("marks an SDK tool error as failed", async () => { - const { context, events, logger, translator } = createHarness(); + const { events, handleMessages } = createHarness(); const messages = [ { message: { @@ -82,10 +465,7 @@ describe("Claude Agent SDK provider fixtures", () => { }, ] as unknown as SDKMessage[]; - for (const message of messages) { - await translator.handleSdkMessage(context, message, "run-1" as RunId); - } - await logger.destroy(); + await handleMessages(messages); expect(events()).toContainEqual( expect.objectContaining({ @@ -95,8 +475,210 @@ describe("Claude Agent SDK provider fixtures", () => { ); }); + test("classifies wrapper-level tool non-execution metadata", async () => { + const { events, handleMessages } = createHarness(); + const messages = [ + { + message: { + content: [{ id: "tool-1", input: {}, name: "Bash", type: "tool_use" }], + }, + type: "assistant", + uuid: "assistant-1", + }, + { + message: { + content: [ + { + content: "Request interrupted", + is_error: true, + tool_use_id: "tool-1", + type: "tool_result", + }, + ], + }, + tool_result_meta: [ + { + id: "tool-1", + non_execution_kind: "interrupted", + user_feedback: "Stop here", + }, + ], + tool_use_result: { + resourceLinks: [ + { + mimeType: "application/pdf", + name: "report.pdf", + uri: "file:///workspace/report.pdf", + }, + ], + usage: { output_tokens_details: { thinking_tokens: 3 } }, + }, + type: "user", + uuid: "user-1", + }, + ] as unknown as SDKMessage[]; + + await handleMessages(messages); + + expect(events()).toContainEqual( + expect.objectContaining({ + kind: "tool.call.updated", + payload: expect.objectContaining({ + nonExecutionKind: "interrupted", + status: "cancelled", + structuredOutput: { + resourceLinks: [ + { + mimeType: "application/pdf", + name: "report.pdf", + uri: "file:///workspace/report.pdf", + }, + ], + usage: { output_tokens_details: { thinking_tokens: 3 } }, + }, + toolCallId: "tool-1", + userFeedback: "Stop here", + }), + }), + ); + }); + + test("materializes authoritative result permission denials without a tool result", async () => { + const { context, events, translator } = createHarness(); + const nativeAgentId = `agent-${"a".repeat(300)}`; + + await translator.handleSdkMessage( + context, + { + agent_id: nativeAgentId, + decision_reason: "Blocked by policy X", + decision_reason_type: "rule", + message: "Denied by policy X", + subtype: "permission_denied", + tool_name: "Bash", + tool_use_id: "tool-denied", + type: "system", + uuid: "denial-advisory", + } as unknown as SDKMessage, + "run-1" as RunId, + ); + await translator.handleSdkMessage( + context, + { + is_error: false, + modelUsage: {}, + permission_denials: [ + { tool_input: { command: "pwd" }, tool_name: "Bash", tool_use_id: "tool-denied" }, + ], + result: "done", + subtype: "success", + total_cost_usd: 0, + type: "result", + usage: {}, + uuid: "result-1", + } as unknown as SDKMessage, + "run-1" as RunId, + ); + + expect(events()).toContainEqual( + expect.objectContaining({ + kind: "tool.call.updated", + payload: expect.objectContaining({ + rawInput: '{"command":"pwd"}', + content: "Denied by policy X", + decisionReason: "Blocked by policy X", + decisionReasonType: "rule", + status: "failed", + title: "Bash", + toolCallId: "tool-denied", + }), + }), + ); + const denial = events().findLast( + (event) => + event.kind === "tool.call.updated" && + isRecord(event.payload) && + event.payload["toolCallId"] === "tool-denied", + ); + const agentId = + denial !== undefined && isRecord(denial.payload) ? denial.payload["agentId"] : null; + expect(agentId).toMatch(/^rid1_[A-Za-z0-9_-]{43}$/); + expect(agentId).not.toBe(nativeAgentId); + expect(events().filter(({ kind }) => kind === "item.started")).toHaveLength(1); + expect(events().filter(({ kind }) => kind === "item.completed")).toHaveLength(1); + }); + + test("lets result permission denials override earlier tool terminals", async () => { + const { events, handleMessages } = createHarness(); + const messages = [ + { + message: { + content: [ + { id: "tool-completed", input: {}, name: "Read", type: "tool_use" }, + { id: "tool-cancelled", input: {}, name: "Bash", type: "tool_use" }, + ], + }, + type: "assistant", + uuid: "assistant-1", + }, + { + message: { + content: [ + { content: "ok", tool_use_id: "tool-completed", type: "tool_result" }, + { + content: "interrupted", + is_error: true, + tool_use_id: "tool-cancelled", + type: "tool_result", + }, + ], + }, + tool_result_meta: [{ id: "tool-cancelled", non_execution_kind: "cancelled" }], + type: "user", + uuid: "user-1", + }, + { + is_error: false, + modelUsage: {}, + permission_denials: [ + { tool_input: {}, tool_name: "Read", tool_use_id: "tool-completed" }, + { tool_input: {}, tool_name: "Bash", tool_use_id: "tool-cancelled" }, + ], + result: "done", + subtype: "success", + total_cost_usd: 0, + type: "result", + usage: {}, + uuid: "result-1", + }, + ] as unknown as SDKMessage[]; + + await handleMessages(messages); + + for (const toolCallId of ["tool-completed", "tool-cancelled"]) { + const statuses = events().flatMap((event) => { + if (event.kind !== "tool.call.updated" || !isRecord(event.payload)) { + return []; + } + return event.payload["toolCallId"] === toolCallId && + typeof event.payload["status"] === "string" + ? [event.payload["status"]] + : []; + }); + expect(statuses.at(-1)).toBe("failed"); + expect( + events().filter( + (event) => + event.kind === "item.completed" && + isRecord(event.payload) && + event.payload["itemId"] === toolCallId, + ), + ).toHaveLength(1); + } + }); + test("rotates assistant identity across a tool boundary and marks the final message", async () => { - const { context, events, logger, translator } = createHarness(); + const { events, handleMessages } = createHarness(); const messages = [ { message: { @@ -138,10 +720,7 @@ describe("Claude Agent SDK provider fixtures", () => { }, ] as unknown as SDKMessage[]; - for (const message of messages) { - await translator.handleSdkMessage(context, message, "run-1" as RunId); - } - await logger.destroy(); + await handleMessages(messages); const textMessages = events().flatMap((event) => { if (event.kind !== "message.delta" || !isRecord(event.payload)) { @@ -167,7 +746,7 @@ describe("Claude Agent SDK provider fixtures", () => { }); test("uses the complete assistant message to repair an incomplete stream snapshot", async () => { - const { context, events, logger, translator } = createHarness(); + const { events, handleMessages } = createHarness(); const messages = [ { event: { @@ -207,10 +786,7 @@ describe("Claude Agent SDK provider fixtures", () => { }, ] as unknown as SDKMessage[]; - for (const message of messages) { - await translator.handleSdkMessage(context, message, "run-1" as RunId); - } - await logger.destroy(); + await handleMessages(messages); const runCompleted = events().find((event) => event.kind === "run.completed"); const payload = @@ -223,7 +799,8 @@ describe("Claude Agent SDK provider fixtures", () => { const completedIndex = translated.findIndex((event) => event.kind === "message.completed"); const terminalIndex = translated.findIndex((event) => event.kind === "run.completed"); - expect(payload?.["finalMessageText"]).toBe("完整最终回答"); + expect(payload).not.toHaveProperty("finalMessageText"); + expect(messageText(events(), payload?.["finalMessageId"])).toBe("完整最终回答"); expect(snapshot).toMatchObject({ kind: "message.added", payload: { @@ -241,7 +818,7 @@ describe("Claude Agent SDK provider fixtures", () => { }); test("drops thought and tool updates after their terminal events", async () => { - const { context, events, logger, translator } = createHarness(); + const { events, handleMessages } = createHarness(); const messages = [ { event: { @@ -323,10 +900,7 @@ describe("Claude Agent SDK provider fixtures", () => { }, ] as unknown as SDKMessage[]; - for (const message of messages) { - await translator.handleSdkMessage(context, message, "run-1" as RunId); - } - await logger.destroy(); + await handleMessages(messages); const translated = events(); const thoughtCompletedIndex = translated.findIndex( @@ -352,7 +926,7 @@ describe("Claude Agent SDK provider fixtures", () => { }); test("ignores streamed text arriving after its assistant message completed", async () => { - const { context, events, logger, translator } = createHarness(); + const { events, handleMessages } = createHarness(); const messages = [ { message: { @@ -380,10 +954,7 @@ describe("Claude Agent SDK provider fixtures", () => { }, ] as unknown as SDKMessage[]; - for (const message of messages) { - await translator.handleSdkMessage(context, message, "run-1" as RunId); - } - await logger.destroy(); + await handleMessages(messages); const translated = events(); const completed = translated.find((event) => event.kind === "message.completed"); @@ -403,13 +974,18 @@ describe("Claude Agent SDK provider fixtures", () => { event.payload["messageId"] === messageId, ), ).toEqual([]); - expect(translated.find((event) => event.kind === "run.completed")).toMatchObject({ - payload: expect.objectContaining({ finalMessageText: "complete" }), - }); + const terminal = translated.find((event) => event.kind === "run.completed"); + expect(terminal?.payload).not.toHaveProperty("finalMessageText"); + expect( + messageText( + translated, + isRecord(terminal?.payload) ? terminal.payload["finalMessageId"] : null, + ), + ).toBe("complete"); }); test("repairs streamed tool input with a lossless assistant snapshot", async () => { - const { context, events, logger, translator } = createHarness(); + const { events, handleMessages } = createHarness(); const messages = [ { event: { @@ -438,11 +1014,26 @@ describe("Claude Agent SDK provider fixtures", () => { }, ] as unknown as SDKMessage[]; - for (const message of messages) { - await translator.handleSdkMessage(context, message, "run-1" as RunId); - } - await logger.destroy(); + await handleMessages(messages); + expect( + events().filter( + (event) => + event.kind === "tool.call.updated" && + event.delivery === "best_effort" && + isRecord(event.payload) && + event.payload["toolCallId"] === "tool-1", + ), + ).toContainEqual( + expect.objectContaining({ + payload: expect.objectContaining({ rawInputDelta: '{"path":"partial' }), + }), + ); + expect(events()).not.toContainEqual( + expect.objectContaining({ + payload: expect.objectContaining({ rawInput: "{}", toolCallId: "tool-1" }), + }), + ); expect( events().filter( (event) => @@ -461,7 +1052,7 @@ describe("Claude Agent SDK provider fixtures", () => { test.each(["success", "error"] as const)( "closes every open item before a %s result terminal", async (outcome) => { - const { context, events, logger, translator } = createHarness(); + const { events, handleMessages } = createHarness(); const messages = [ { event: { @@ -509,17 +1100,18 @@ describe("Claude Agent SDK provider fixtures", () => { }, ] as unknown as SDKMessage[]; - for (const message of messages) { - await translator.handleSdkMessage(context, message, "run-1" as RunId); - } - await logger.destroy(); + await handleMessages(messages); const translated = events(); const terminalIndex = translated.findIndex((event) => ["run.completed", "run.failed"].includes(event.kind), ); expect(terminalIndex).toBeGreaterThan(-1); - for (const kind of ["message.completed", "thought.completed", "item.completed"] as const) { + const closureKinds = + outcome === "success" + ? (["message.completed", "thought.completed", "item.completed"] as const) + : (["message.failed", "thought.cancelled", "item.completed"] as const); + for (const kind of closureKinds) { expect(translated.findIndex((event) => event.kind === kind)).toBeGreaterThan(-1); expect(translated.findIndex((event) => event.kind === kind)).toBeLessThan(terminalIndex); } @@ -540,7 +1132,7 @@ describe("Claude Agent SDK provider fixtures", () => { // envelope uuid, and the aggregated assistant envelope (with the native // message id) arrives before message_stop. The reply must stay one // message instead of rendering as "P" / "ong. …" / full-text duplicates. - const { context, events, logger, translator } = createHarness(); + const { events, handleMessages } = createHarness(); const messages = [ { event: { @@ -588,10 +1180,7 @@ describe("Claude Agent SDK provider fixtures", () => { }, ] as unknown as SDKMessage[]; - for (const message of messages) { - await translator.handleSdkMessage(context, message, "run-1" as RunId); - } - await logger.destroy(); + await handleMessages(messages); const translated = events(); const textMessages = translated.flatMap((event) => { @@ -628,13 +1217,16 @@ describe("Claude Agent SDK provider fixtures", () => { { text: "Pong. What would you like to work on?", type: "text" }, ]); expect(payload?.["finalMessageId"]).toBe(textMessages[0]?.messageId); - expect(payload?.["finalMessageText"]).toBe("Pong. What would you like to work on?"); + expect(payload).not.toHaveProperty("finalMessageText"); + expect(messageText(translated, payload?.["finalMessageId"])).toBe( + "Pong. What would you like to work on?", + ); }); test("anchors uuid-fractured stream fragments to one closed assistant message", async () => { // One scope streams one message at a time; per-envelope uuids must not // fracture a burst whose message_start frame was lost (YEF-884). - const { context, events, logger, translator } = createHarness(); + const { events, handleMessages } = createHarness(); const messages = [ { event: { @@ -664,10 +1256,7 @@ describe("Claude Agent SDK provider fixtures", () => { }, ] as unknown as SDKMessage[]; - for (const message of messages) { - await translator.handleSdkMessage(context, message, "run-1" as RunId); - } - await logger.destroy(); + await handleMessages(messages); const translated = events(); const started = translated @@ -692,7 +1281,7 @@ describe("Claude Agent SDK provider fixtures", () => { // envelope uuid; the aggregated assistant envelope that follows in the // same scope is that burst's own aggregation and must not mint a // duplicate message (YEF-884). - const { context, events, logger, translator } = createHarness(); + const { events, handleMessages } = createHarness(); const messages = [ { event: { @@ -724,10 +1313,7 @@ describe("Claude Agent SDK provider fixtures", () => { }, ] as unknown as SDKMessage[]; - for (const message of messages) { - await translator.handleSdkMessage(context, message, "run-1" as RunId); - } - await logger.destroy(); + await handleMessages(messages); const textMessages = events().flatMap((event) => { if (event.kind !== "message.delta" || !isRecord(event.payload)) { @@ -752,14 +1338,142 @@ describe("Claude Agent SDK provider fixtures", () => { expect(textMessages.map((entry) => entry.contentDelta)).toEqual(["相同文本"]); expect(snapshotPayload?.["messageId"]).toBe(textMessages[0]?.messageId); expect(payload?.["finalMessageId"]).toBe(textMessages[0]?.messageId); - expect(payload?.["finalMessageText"]).toBe("相同文本"); + expect(payload).not.toHaveProperty("finalMessageText"); + expect(messageText(events(), payload?.["finalMessageId"])).toBe("相同文本"); + }); + + test("keeps a confirmed streamed assistant replay on its original message", async () => { + const { events, handleMessages } = createHarness(); + const assistant = { + message: { + content: [{ text: "canonical", type: "text" }], + id: "native-message", + }, + type: "assistant", + uuid: "wire-assistant", + }; + const messages = [ + { + event: { + message: { id: "native-message" }, + type: "message_start", + }, + type: "stream_event", + uuid: "stream-start", + }, + { + event: { + delta: { text: "canonical", type: "text_delta" }, + type: "content_block_delta", + }, + type: "stream_event", + uuid: "stream-delta", + }, + { + event: { type: "message_stop" }, + type: "stream_event", + uuid: "stream-stop", + }, + assistant, + assistant, + { + result: "canonical", + subtype: "success", + total_cost_usd: 0, + type: "result", + usage: {}, + uuid: "result-1", + }, + ] as unknown as SDKMessage[]; + + await handleMessages(messages); + + const started = events().filter((event) => event.kind === "message.started"); + const completed = events().filter((event) => event.kind === "message.completed"); + const snapshots = events().filter((event) => event.kind === "message.added"); + const runCompleted = events().find((event) => event.kind === "run.completed"); + const startedMessageId = isRecord(started[0]?.payload) + ? started[0].payload["messageId"] + : undefined; + + expect(started).toHaveLength(1); + expect(completed).toHaveLength(1); + expect(snapshots).toHaveLength(1); + expect(runCompleted?.payload).toMatchObject({ + finalMessageId: startedMessageId, + }); + expect(runCompleted?.payload).not.toHaveProperty("finalMessageText"); + expect(messageText(events(), startedMessageId)).toBe("canonical"); + }); + + test("keeps distinct live assistant envelopes that share a native message id", async () => { + const { events, handleMessages } = createHarness(); + const messages = [ + { + event: { message: { id: "shared-native" }, type: "message_start" }, + type: "stream_event", + uuid: "stream-start", + }, + { + event: { + delta: { text: "first", type: "text_delta" }, + type: "content_block_delta", + }, + type: "stream_event", + uuid: "stream-first", + }, + { + message: { content: [{ text: "first", type: "text" }], id: "shared-native" }, + type: "assistant", + uuid: "wire-first", + }, + { + event: { + delta: { text: "second", type: "text_delta" }, + type: "content_block_delta", + }, + type: "stream_event", + uuid: "stream-second", + }, + { + message: { content: [{ text: "second", type: "text" }], id: "shared-native" }, + type: "assistant", + uuid: "wire-second", + }, + { + result: "second", + subtype: "success", + total_cost_usd: 0, + type: "result", + usage: {}, + uuid: "result-1", + }, + ] as unknown as SDKMessage[]; + + await handleMessages(messages); + + const snapshots = events().filter((event) => event.kind === "message.added"); + const messageIds = snapshots.flatMap((event) => + isRecord(event.payload) && typeof event.payload["messageId"] === "string" + ? [event.payload["messageId"]] + : [], + ); + const runCompleted = events().find((event) => event.kind === "run.completed"); + + expect(snapshots).toHaveLength(2); + expect(new Set(messageIds).size).toBe(2); + expect(runCompleted?.payload).toMatchObject({ + finalMessageId: messageIds.at(-1), + }); + expect(runCompleted?.payload).not.toHaveProperty("finalMessageText"); + expect(messageText(events(), messageIds.at(-1))).toBe("second"); }); test("keeps duplicate text on distinct messages when the stream identity is confirmed", async () => { // A message_start frame proves the streamed message's native id, so an // assistant envelope with a different native id is a genuinely separate // message even when the text repeats. - const { context, events, logger, translator } = createHarness(); + const { events, handleMessages } = createHarness(); const messages = [ { event: { @@ -800,10 +1514,7 @@ describe("Claude Agent SDK provider fixtures", () => { }, ] as unknown as SDKMessage[]; - for (const message of messages) { - await translator.handleSdkMessage(context, message, "run-1" as RunId); - } - await logger.destroy(); + await handleMessages(messages); const textMessages = events().flatMap((event) => { if (event.kind !== "message.delta" || !isRecord(event.payload)) { @@ -823,6 +1534,7 @@ describe("Claude Agent SDK provider fixtures", () => { expect(textMessages.map((entry) => entry.contentDelta)).toEqual(["相同文本", "相同文本"]); expect(new Set(textMessages.map((entry) => entry.messageId)).size).toBe(2); expect(payload?.["finalMessageId"]).toBe(textMessages.at(-1)?.messageId); - expect(payload?.["finalMessageText"]).toBe("相同文本"); + expect(payload).not.toHaveProperty("finalMessageText"); + expect(messageText(events(), payload?.["finalMessageId"])).toBe("相同文本"); }); }); diff --git a/tests/claude-agent-sdk-query-options.test.ts b/tests/claude-agent-sdk-query-options.test.ts index 2c878fa..24ec3c9 100644 --- a/tests/claude-agent-sdk-query-options.test.ts +++ b/tests/claude-agent-sdk-query-options.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { createBufferedSinkLogger } from "../src/observability"; +import { createDisabledLogger } from "../src/observability"; import { createDriverStartInputFromBootPayload } from "../src/protocol/start"; import { createAgentDriverContext } from "../src/core/agent-driver-backend"; import { @@ -22,14 +22,6 @@ async function createRuntimeHome(): Promise { return runtimeHome; } -function createTestLogger() { - return createBufferedSinkLogger({ - level: "debug", - service: "claude-agent-sdk-query-options-test", - sink: async () => {}, - }); -} - afterEach(async () => { await Promise.all( runtimeHomes.map((runtimeHome) => rm(runtimeHome, { force: true, recursive: true })), @@ -176,7 +168,6 @@ describe("Claude Agent SDK query options", () => { runtime: "claude-agent-sdk", runtimeTransport: "claude-agent-sdk", }); - const logger = createTestLogger(); const permission = Promise.withResolvers<"allow_once" | "reject_once">(); let permissionInput: | Parameters["ports"]["permission"]["request"]>[0] @@ -184,9 +175,10 @@ describe("Claude Agent SDK query options", () => { let permissionSignal: AbortSignal | undefined; const context = createAgentDriverContext({ eventSink: { + currentRunId: () => null, pushEvents: async () => ({ accepted: [] }), }, - logger, + logger: createDisabledLogger(), payload, permission: { request: async (input, signal) => { @@ -229,10 +221,20 @@ describe("Claude Agent SDK query options", () => { ); const abortController = new AbortController(); + const nativeAgentId = `subagent-${"a".repeat(300)}`; const result = options.canUseTool?.( "Bash", { command: "pwd" }, { + agentID: nativeAgentId, + blockedPath: "/workspace/secret", + decisionReason: "Path is outside the allowed roots.", + description: "Read access to /workspace/secret", + matchedAskRule: { + ruleContent: "Bash(*)", + source: "project", + toolName: "Bash", + }, requestId: "permission-request-1", signal: abortController.signal, toolUseID: "tool-1", @@ -252,11 +254,21 @@ describe("Claude Agent SDK query options", () => { await drainClaudeTasks(permissionTasks); expect(permissionTasks.size).toBe(0); expect(permissionInput).toMatchObject({ + blockedPath: "/workspace/secret", + decisionReason: "Path is outside the allowed roots.", + description: "Read access to /workspace/secret", + matchedAskRule: { + ruleContent: "Bash(*)", + source: "project", + toolName: "Bash", + }, requestId: "permission-request-1", toolCallId: "tool-1", }); + const publicAgentId = (permissionInput as { agentId?: string } | null)?.agentId; + expect(publicAgentId).toMatch(/^rid1_[A-Za-z0-9_-]{43}$/); + expect(publicAgentId).not.toBe(nativeAgentId); expect(permissionSignal).toBe(abortController.signal); - await logger.destroy(); }); test("retains an early permission rejection for terminal cleanup", async () => { @@ -279,12 +291,12 @@ describe("Claude Agent SDK query options", () => { runtimeTransport: "claude-agent-sdk", }); const permissionError = new Error("permission delivery failed"); - const logger = createTestLogger(); const context = createAgentDriverContext({ eventSink: { + currentRunId: () => null, pushEvents: async () => ({ accepted: [] }), }, - logger, + logger: createDisabledLogger(), payload, permission: { request: async () => { @@ -315,6 +327,5 @@ describe("Claude Agent SDK query options", () => { expect(permissionTasks.size).toBe(1); await expect(drainClaudeTasks(permissionTasks)).rejects.toBe(permissionError); expect(permissionTasks.size).toBe(0); - await logger.destroy(); }); }); diff --git a/tests/claude-agent-sdk-task-events.test.ts b/tests/claude-agent-sdk-task-events.test.ts new file mode 100644 index 0000000..1ff72f7 --- /dev/null +++ b/tests/claude-agent-sdk-task-events.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test } from "bun:test"; + +import type { SDKBackgroundTasksChangedMessage } from "@anthropic-ai/claude-agent-sdk"; + +import { + claudeBackgroundTasksClosedEvent, + projectClaudeBackgroundTasksSnapshot, +} from "../src/runtimes/claude/agent-sdk-task-events"; + +function backgroundTasks( + tasks: SDKBackgroundTasksChangedMessage["tasks"], +): SDKBackgroundTasksChangedMessage { + return { + session_id: "session-1", + subtype: "background_tasks_changed", + tasks, + type: "system", + uuid: "00000000-0000-0000-0000-000000000001", + }; +} + +function task( + taskId: string, + description = "Inspect the repository", + ambient = false, +): SDKBackgroundTasksChangedMessage["tasks"][number] { + return { + ...(ambient ? { ambient: true } : {}), + description, + task_id: taskId, + task_type: "local_agent", + }; +} + +function projection(tasks: SDKBackgroundTasksChangedMessage["tasks"]) { + return projectClaudeBackgroundTasksSnapshot(backgroundTasks(tasks)); +} + +function snapshot(tasks: SDKBackgroundTasksChangedMessage["tasks"]) { + const event = projection(tasks).snapshot; + if (event === undefined) { + throw new Error("Expected a Claude task snapshot."); + } + return event; +} + +describe("Claude Agent SDK task snapshots", () => { + test("projects every SDK snapshot as one complete replacement", () => { + const initial = { + delivery: "lossless", + kind: "agent.tasks.replaced", + payload: { + tasks: [ + { + taskId: "task-1", + taskType: "local_agent", + title: "Inspect the repository", + }, + ], + }, + visibility: "participant", + } as const; + + expect(snapshot([task("task-1")])).toEqual(initial); + expect(snapshot([task("task-1")])).toEqual(initial); + expect(snapshot([task("task-1", "Inspect tests")])).toEqual({ + ...initial, + payload: { tasks: [{ ...initial.payload.tasks[0], title: "Inspect tests" }] }, + }); + }); + + test("filters ambient tasks and publishes membership removal as an empty snapshot", () => { + const event = snapshot([task("task-1"), task("ambient-task", "private ambient task", true)]); + + expect(event).toMatchObject({ + kind: "agent.tasks.replaced", + payload: { tasks: [{ taskId: "task-1" }] }, + }); + expect(JSON.stringify(event)).not.toContain("private"); + expect(snapshot([task("task-1", "private", true)])).toEqual(claudeBackgroundTasksClosedEvent()); + }); + + test("bounds task IDs, text, counts, and aggregate event size", () => { + const bounded = snapshot([ + task("界".repeat(257), `${"x".repeat(4_095)}😀tail`), + { description: "", task_id: "empty-metadata", task_type: "" }, + ]); + expect(bounded).toMatchObject({ + payload: { + tasks: [ + { + taskId: expect.stringMatching(/^rid1_[A-Za-z0-9_-]{43}$/), + title: expect.stringMatching(/^x{4095}$/), + }, + { taskId: "empty-metadata" }, + ], + }, + }); + + const maximum = snapshot(Array.from({ length: 256 }, (_, index) => task(`task-${index}`))); + expect(maximum).toMatchObject({ kind: "agent.tasks.replaced" }); + expect((maximum.payload as { tasks: unknown[] }).tasks).toHaveLength(256); + + const tooManyVisible = projection( + Array.from({ length: 257 }, (_, index) => task(`visible-${index}`)), + ); + expect(tooManyVisible).toMatchObject({ + diagnostic: { + kind: "diagnostic.reported", + payload: { code: "claude.visible_background_tasks_too_many" }, + visibility: "owner_debug", + }, + }); + expect(tooManyVisible.snapshot).toBeUndefined(); + const unreadTail = task("unread-tail"); + Object.defineProperty(unreadTail, "ambient", { + get() { + throw new Error("Tasks after the visible limit must not be inspected."); + }, + }); + const unread = projection([ + ...Array.from({ length: 257 }, (_, index) => task(`bounded-${index}`)), + unreadTail, + ]); + expect(unread).toMatchObject({ + diagnostic: { payload: { code: "claude.visible_background_tasks_too_many" } }, + }); + expect(unread.snapshot).toBeUndefined(); + const tooManyEntries = projection( + Array.from({ length: 1_025 }, (_, index) => task(`ambient-${index}`, "x", true)), + ); + expect(tooManyEntries).toMatchObject({ + diagnostic: { payload: { code: "claude.background_tasks_snapshot_too_large" } }, + }); + expect(tooManyEntries.snapshot).toBeUndefined(); + expect( + projection([ + ...Array.from({ length: 1_023 }, (_, index) => task(`ambient-${index}`, "x", true)), + task("visible"), + ]), + ).toMatchObject({ snapshot: { payload: { tasks: [{ taskId: "visible" }] } } }); + expect(projection(Array.from({ length: 1_024 }, () => task("duplicate")))).toMatchObject({ + snapshot: { payload: { tasks: [{ taskId: "duplicate" }] } }, + }); + const oversizedMetadata = projection( + Array.from({ length: 100 }, (_, index) => ({ + description: "界".repeat(4_096), + task_id: `large-${index}`, + task_type: "界".repeat(4_096), + })), + ); + expect(oversizedMetadata).toMatchObject({ + diagnostic: { + kind: "diagnostic.reported", + payload: { code: "claude.tasks_snapshot_too_large" }, + }, + snapshot: { + delivery: "lossless", + kind: "agent.tasks.replaced", + visibility: "participant", + }, + }); + if (oversizedMetadata.snapshot === undefined) { + throw new Error("Expected a membership-only Claude task snapshot."); + } + expect( + (oversizedMetadata.snapshot.payload as { tasks: Array> }).tasks, + ).toEqual(Array.from({ length: 100 }, (_, index) => ({ taskId: `large-${index}` }))); + }); + + test("uses an authoritative empty replacement for turn and process closure", () => { + expect(claudeBackgroundTasksClosedEvent()).toEqual({ + delivery: "lossless", + kind: "agent.tasks.replaced", + payload: { tasks: [] }, + visibility: "participant", + }); + }); +}); diff --git a/tests/claude-agent-sdk-test-helpers.ts b/tests/claude-agent-sdk-test-helpers.ts new file mode 100644 index 0000000..ed15237 --- /dev/null +++ b/tests/claude-agent-sdk-test-helpers.ts @@ -0,0 +1,62 @@ +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; + +import type { AgentDriverContext } from "../src/core/agent-driver-backend"; +import { createAgentDriverContext } from "../src/core/agent-driver-backend"; +import { createDisabledLogger } from "../src/observability"; +import type { DriverEventInput } from "../src/protocol/events"; +import type { RunId } from "../src/protocol/id"; +import { ClaudeAgentSdkMessageTranslator } from "../src/runtimes/claude/agent-sdk-message-translator"; +import { driverStartInput as bootPayload } from "./driver-boot-payload-fixture"; + +export { isRecord } from "../src/runtimes/claude/agent-sdk-json"; +export { messageText } from "./driver-event-test-helpers"; + +export function createClaudeAgentSdkHarness( + publicToolCallId: (nativeToolCallId: string) => string = (id) => id, +) { + const driverEvents: DriverEventInput[] = []; + const nativeSessionIds: string[] = []; + const nativeSessionResets: Array = []; + const context: AgentDriverContext = createAgentDriverContext({ + eventSink: { + currentRunId: () => "run-1" as RunId, + pushEvents: async () => ({ accepted: [] }), + }, + logger: createDisabledLogger(), + payload: bootPayload, + permission: { request: async () => "allow_once" }, + }); + const translator = new ClaudeAgentSdkMessageTranslator({ + publicToolCallId, + push: async (_context, _reason, events) => { + driverEvents.push(...events); + }, + pushTerminal: async (_context, _reason, closures, terminal) => { + driverEvents.push(...closures, terminal); + }, + recordNativeSessionId: async (_context, sessionId) => { + nativeSessionIds.push(sessionId); + }, + replaceNativeSessionId: async (_context, previousSessionId, nextSessionId) => { + nativeSessionResets.push([previousSessionId, nextSessionId]); + }, + sessionId: context.payload.execution.run.sessionId, + }); + const handleMessages = async ( + messages: readonly SDKMessage[], + runId: RunId = "run-1" as RunId, + ): Promise => { + for (const message of messages) { + await translator.handleSdkMessage(context, message, runId); + } + }; + + return { + context, + events: () => driverEvents, + handleMessages, + nativeSessionIds, + nativeSessionResets, + translator, + }; +} diff --git a/tests/claude-contract-adapter-permission.test.ts b/tests/claude-contract-adapter-permission.test.ts deleted file mode 100644 index 809028b..0000000 --- a/tests/claude-contract-adapter-permission.test.ts +++ /dev/null @@ -1,500 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { CanUseTool } from "@anthropic-ai/claude-agent-sdk"; - -import { - AuthorityOutcomeUnknownError, - applyCommittedMutation, - interactionSchema, - validateSessionSnapshot, -} from "../src/contract"; -import type { - AuthorityOperation, - CommittedMutation, - InteractionResolution, - Run, - SessionSnapshot, -} from "../src/contract"; -import { ClaudeContractAdapter } from "../src/runtimes/claude/contract-adapter"; -import type { - ContractAuthorityUpdate, - ContractPreviewUpdate, -} from "../src/runtimes/contract-projection"; - -const SESSION_ID = protocolId(1); -const RUN_ID = protocolId(2); - -function protocolId(value: number): string { - return value.toString().padStart(26, "0"); -} - -function activeRun(startedAt: string): Run { - return { - id: RUN_ID, - input: [{ text: "hello", type: "text" }], - origin: "user", - startedAt, - status: "active", - }; -} - -function createInitialSnapshot(capturedAt: string): SessionSnapshot { - return validateSessionSnapshot({ - capturedAt, - interactions: [], - items: [], - protocolVersion: 2, - revision: 0, - runs: [activeRun(capturedAt)], - session: { - capabilities: { - "interaction.permission": {}, - "item.artifact": {}, - "item.change": {}, - "item.plan": {}, - "item.reasoning": {}, - "item.terminal": {}, - }, - config: [], - createdAt: capturedAt, - id: SESSION_ID, - status: "open", - updatedAt: capturedAt, - }, - }); -} - -function createHarness( - interactionTimeoutMs = 5 * 60 * 1_000, - maxToolInputBytes?: number, - maxPendingPermissionBytes?: number, - onAuthority?: (update: ContractAuthorityUpdate) => Promise | void, -) { - let nowMs = Date.parse("2026-07-16T08:00:00.000Z"); - let snapshot = createInitialSnapshot(new Date(nowMs).toISOString()); - let nextId = 100; - const authority: ContractAuthorityUpdate[] = []; - const previews: ContractPreviewUpdate[] = []; - const commit = (cause: CommittedMutation["cause"], operations: AuthorityOperation[]): void => { - const revision = snapshot.revision + 1; - const mutation: CommittedMutation = { - baseRevision: snapshot.revision, - cause, - committedAt: new Date(nowMs).toISOString(), - mutationId: protocolId(1_000 + revision), - operations, - revision, - sessionId: SESSION_ID, - }; - snapshot = applyCommittedMutation(snapshot, mutation); - }; - const adapter = new ClaudeContractAdapter({ - authority: async (update) => { - authority.push(update); - await onAuthority?.(update); - commit(update.cause, [...update.operations] as AuthorityOperation[]); - }, - createId: () => protocolId(nextId++), - interactionTimeoutMs, - maxPendingPermissionBytes, - maxToolInputBytes, - now: () => new Date(nowMs), - preview: (update) => previews.push(update), - sessionId: SESSION_ID, - }); - - return { - adapter, - advance(milliseconds: number) { - nowMs += milliseconds; - }, - authority, - previews, - settleInteraction(interactionId: string, resolution?: InteractionResolution) { - const interaction = snapshot.interactions.find((entry) => entry.id === interactionId); - - if (interaction === undefined || interaction.status !== "open") { - throw new Error("The test interaction must be open."); - } - - if (resolution !== undefined && resolution.kind !== interaction.kind) { - throw new Error("The test resolution kind must match the interaction kind."); - } - - const endedAt = new Date(nowMs).toISOString(); - commit({ commandId: protocolId(2_000 + snapshot.revision + 1), type: "command" }, [ - { - entity: "interaction", - op: "put", - value: interactionSchema.parse( - resolution === undefined - ? { ...interaction, endedAt, status: "expired" } - : { - ...interaction, - endedAt, - resolution: resolution.value, - status: "resolved", - }, - ), - }, - ]); - }, - snapshot: () => snapshot, - }; -} - -async function registerRun(adapter: ClaudeContractAdapter): Promise { - adapter.attachRun(activeRun("2026-07-16T08:00:00.000Z")); -} - -function permissionOptions(requestId: string, toolUseID: string) { - return { - requestId, - signal: new AbortController().signal, - title: "Run command?", - toolUseID, - } satisfies Parameters[2]; -} - -function permissionBytes( - input: Record, - options: Parameters[2], -): number { - return new TextEncoder().encode( - JSON.stringify({ - input, - options: Object.fromEntries(Object.entries(options).filter(([name]) => name !== "signal")), - toolName: "Bash", - }), - ).byteLength; -} - -describe("Claude Contract adapter", () => { - test("bounds pending permission payloads and restores budget after resolution", async () => { - const input = { command: "pwd" }; - const firstOptions = permissionOptions("request-1", "tool-1"); - const secondOptions = permissionOptions("request-2", "tool-2"); - const maxPendingPermissionBytes = permissionBytes(input, firstOptions); - const harness = createHarness(5 * 60 * 1_000, undefined, maxPendingPermissionBytes); - await registerRun(harness.adapter); - const interactionId = await harness.adapter.openPermission(RUN_ID, "Bash", input, firstOptions); - const authorityCount = harness.authority.length; - - await expect( - harness.adapter.openPermission(RUN_ID, "Bash", input, secondOptions), - ).rejects.toThrow("pending permission budget"); - expect(harness.authority).toHaveLength(authorityCount); - - const resolution = { - kind: "permission", - value: { type: "cancelled" }, - } satisfies InteractionResolution; - harness.settleInteraction(interactionId, resolution); - await harness.adapter.resolveInteraction(interactionId, resolution); - await expect( - harness.adapter.openPermission(RUN_ID, "Bash", input, secondOptions), - ).resolves.toBeDefined(); - }); - - test("reserves permission budget before the first Authority await", async () => { - const input = { command: "pwd" }; - const firstOptions = permissionOptions("request-concurrent-1", "tool-concurrent-1"); - const secondOptions = permissionOptions("request-concurrent-2", "tool-concurrent-2"); - const firstAuthority = Promise.withResolvers(); - const releaseAuthority = Promise.withResolvers(); - let toolCommits = 0; - const harness = createHarness( - 5 * 60 * 1_000, - undefined, - permissionBytes(input, firstOptions), - async (update) => { - if (update.event === "permission/requested.tool" && ++toolCommits === 1) { - firstAuthority.resolve(); - await releaseAuthority.promise; - } - }, - ); - await registerRun(harness.adapter); - const first = harness.adapter.openPermission(RUN_ID, "Bash", input, firstOptions); - await firstAuthority.promise; - - try { - await expect( - harness.adapter.openPermission(RUN_ID, "Bash", input, secondOptions), - ).rejects.toThrow("pending permission budget"); - } finally { - releaseAuthority.resolve(); - } - - await expect(first).resolves.toBeDefined(); - expect(toolCommits).toBe(1); - }); - - test("rolls back a permission reservation when Authority rejects it", async () => { - const input = { command: "pwd" }; - const firstOptions = permissionOptions("request-failed", "tool-failed"); - const secondOptions = permissionOptions("request-retry", "tool-retry"); - let rejectNext = true; - const harness = createHarness( - 5 * 60 * 1_000, - undefined, - permissionBytes(input, firstOptions), - (update) => { - if (rejectNext && update.event === "permission/requested.tool") { - rejectNext = false; - throw new Error("Authority unavailable"); - } - }, - ); - await registerRun(harness.adapter); - - await expect( - harness.adapter.openPermission(RUN_ID, "Bash", input, firstOptions), - ).rejects.toThrow("Authority unavailable"); - await expect( - harness.adapter.openPermission(RUN_ID, "Bash", input, secondOptions), - ).resolves.toBeDefined(); - }); - - test.each(["permission/requested.tool", "permission/requested"] as const)( - "retries the exact %s write after an unknown Authority outcome", - async (event) => { - const writes: ContractAuthorityUpdate[] = []; - const harness = createHarness(5 * 60 * 1_000, undefined, undefined, (update) => { - if (update.event !== event) { - return; - } - - writes.push(update); - if (writes.length === 1) { - throw new AuthorityOutcomeUnknownError("Authority response was lost"); - } - }); - await registerRun(harness.adapter); - - await expect( - harness.adapter.openPermission( - RUN_ID, - "Bash", - { command: "pwd" }, - permissionOptions(`request-unknown-${event}`, `tool-unknown-${event}`), - ), - ).resolves.toBeDefined(); - - expect(writes).toHaveLength(2); - expect(writes[1]?.mutationId).toBe(writes[0]?.mutationId); - expect(writes[1]?.operations).toEqual(writes[0]?.operations); - expect(harness.snapshot().interactions).toHaveLength(1); - }, - ); - - test("coalesces concurrent redelivery of the same permission request", async () => { - const input = { command: "pwd" }; - const options = permissionOptions("request-redelivered", "tool-redelivered"); - const firstAuthority = Promise.withResolvers(); - const releaseAuthority = Promise.withResolvers(); - let toolCommits = 0; - const harness = createHarness(5 * 60 * 1_000, undefined, undefined, async (update) => { - if (update.event === "permission/requested.tool" && ++toolCommits === 1) { - firstAuthority.resolve(); - await releaseAuthority.promise; - } - }); - await registerRun(harness.adapter); - const first = harness.adapter.openPermission(RUN_ID, "Bash", input, options); - await firstAuthority.promise; - const replay = harness.adapter.openPermission( - RUN_ID, - "Bash", - { ...input }, - { - ...options, - signal: new AbortController().signal, - }, - ); - releaseAuthority.resolve(); - - const [interactionId, replayedInteractionId] = await Promise.all([first, replay]); - expect(replayedInteractionId).toBe(interactionId); - expect(toolCommits).toBe(1); - expect(harness.snapshot().interactions).toHaveLength(1); - }); - - test("cancels an opening permission when a replay signal aborts", async () => { - const interactionWriting = Promise.withResolvers(); - const releaseInteraction = Promise.withResolvers(); - const harness = createHarness(5 * 60 * 1_000, undefined, undefined, async (update) => { - if (update.event === "permission/requested") { - interactionWriting.resolve(); - await releaseInteraction.promise; - } - }); - await registerRun(harness.adapter); - const firstController = new AbortController(); - const replayController = new AbortController(); - const options = { - ...permissionOptions("request-opening-replay", "tool-opening-replay"), - signal: firstController.signal, - }; - const first = harness.adapter.openPermission(RUN_ID, "Bash", { command: "pwd" }, options); - await interactionWriting.promise; - const replay = harness.adapter.openPermission( - RUN_ID, - "Bash", - { command: "pwd" }, - { - ...options, - signal: replayController.signal, - }, - ); - - replayController.abort(); - releaseInteraction.resolve(); - - expect(await Promise.allSettled([first, replay])).toMatchObject([ - { reason: { name: "AbortError" }, status: "rejected" }, - { reason: { name: "AbortError" }, status: "rejected" }, - ]); - expect(harness.snapshot().interactions).toMatchObject([ - { resolution: { type: "cancelled" }, status: "resolved" }, - ]); - }); - - test("does not allow a pending permission after a replay signal aborts", async () => { - const harness = createHarness(); - await registerRun(harness.adapter); - const options = permissionOptions("request-pending-replay", "tool-pending-replay"); - const interactionId = await harness.adapter.openPermission( - RUN_ID, - "Bash", - { command: "pwd" }, - options, - ); - const replayController = new AbortController(); - await harness.adapter.openPermission( - RUN_ID, - "Bash", - { command: "pwd" }, - { - ...options, - signal: replayController.signal, - }, - ); - const interaction = harness.snapshot().interactions[0]; - const allowOnceId = - interaction?.kind === "permission" - ? interaction.request.options.find( - (option) => option.effect === "allow" && option.scope === "once", - )?.id - : undefined; - - replayController.abort(); - await expect( - harness.adapter.resolveInteraction(interactionId, { - kind: "permission", - value: { optionId: allowOnceId!, type: "selected" }, - }), - ).resolves.toBeNull(); - expect(harness.snapshot().interactions[0]).toMatchObject({ - resolution: { type: "cancelled" }, - status: "resolved", - }); - }); - - test("rejects an already-aborted permission without creating Authority state", async () => { - const harness = createHarness(); - await registerRun(harness.adapter); - const controller = new AbortController(); - controller.abort(); - - await expect( - harness.adapter.openPermission( - RUN_ID, - "Bash", - { command: "pwd" }, - { - requestId: "request-aborted", - signal: controller.signal, - toolUseID: "tool-aborted", - }, - ), - ).rejects.toMatchObject({ name: "AbortError" }); - expect(harness.authority).toHaveLength(0); - expect(harness.snapshot().items).toHaveLength(0); - expect(harness.snapshot().interactions).toHaveLength(0); - }); - - test("resolves and releases a pending permission when its SDK signal aborts", async () => { - const input = { command: "pwd" }; - const controller = new AbortController(); - const options = { - ...permissionOptions("request-abort-pending", "tool-abort-pending"), - signal: controller.signal, - }; - const harness = createHarness(5 * 60 * 1_000, undefined, permissionBytes(input, options)); - await registerRun(harness.adapter); - const interactionId = await harness.adapter.openPermission(RUN_ID, "Bash", input, options); - - controller.abort(); - await expect( - harness.adapter.resolveInteraction(interactionId, { - kind: "permission", - value: { type: "cancelled" }, - }), - ).resolves.toBeNull(); - - expect(harness.snapshot().interactions[0]).toMatchObject({ - id: interactionId, - resolution: { type: "cancelled" }, - status: "resolved", - }); - await expect( - harness.adapter.openPermission( - RUN_ID, - "Bash", - input, - permissionOptions("request-abort-reuse", "tool-abort-reuse"), - ), - ).resolves.toBeDefined(); - }); - - test("retries an aborted permission after a rejected Authority write", async () => { - const controller = new AbortController(); - const firstAbort = Promise.withResolvers(); - let rejectAbort = true; - const harness = createHarness(5 * 60 * 1_000, undefined, undefined, (update) => { - if (update.event === "permission/aborted" && rejectAbort) { - rejectAbort = false; - firstAbort.resolve(); - throw new Error("Authority unavailable"); - } - }); - await registerRun(harness.adapter); - const interactionId = await harness.adapter.openPermission( - RUN_ID, - "Bash", - { command: "pwd" }, - { - requestId: "request-abort-retry", - signal: controller.signal, - toolUseID: "tool-abort-retry", - }, - ); - - controller.abort(); - await firstAbort.promise; - await Promise.resolve(); - await Promise.resolve(); - expect(harness.snapshot().interactions[0]?.status).toBe("open"); - - await expect( - harness.adapter.resolveInteraction(interactionId, { - kind: "permission", - value: { type: "cancelled" }, - }), - ).resolves.toBeNull(); - expect(harness.snapshot().interactions[0]).toMatchObject({ - resolution: { type: "cancelled" }, - status: "resolved", - }); - }); -}); diff --git a/tests/claude-contract-adapter-run-lifecycle.test.ts b/tests/claude-contract-adapter-run-lifecycle.test.ts deleted file mode 100644 index ecd4cb2..0000000 --- a/tests/claude-contract-adapter-run-lifecycle.test.ts +++ /dev/null @@ -1,523 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; - -import { - applyCommittedMutation, - interactionSchema, - validateSessionSnapshot, -} from "../src/contract"; -import type { - AuthorityOperation, - CommittedMutation, - InteractionResolution, - Run, - SessionSnapshot, -} from "../src/contract"; -import { ClaudeContractAdapter } from "../src/runtimes/claude/contract-adapter"; -import type { - ContractAuthorityUpdate, - ContractPreviewUpdate, -} from "../src/runtimes/contract-projection"; - -const SESSION_ID = protocolId(1); -const RUN_ID = protocolId(2); - -function protocolId(value: number): string { - return value.toString().padStart(26, "0"); -} - -function sdkMessage(value: unknown): SDKMessage { - return value as SDKMessage; -} - -function activeRun(startedAt: string): Run { - return { - id: RUN_ID, - input: [{ text: "hello", type: "text" }], - origin: "user", - startedAt, - status: "active", - }; -} - -function createInitialSnapshot(capturedAt: string): SessionSnapshot { - return validateSessionSnapshot({ - capturedAt, - interactions: [], - items: [], - protocolVersion: 2, - revision: 0, - runs: [activeRun(capturedAt)], - session: { - capabilities: { - "interaction.permission": {}, - "item.artifact": {}, - "item.change": {}, - "item.plan": {}, - "item.reasoning": {}, - "item.terminal": {}, - }, - config: [], - createdAt: capturedAt, - id: SESSION_ID, - status: "open", - updatedAt: capturedAt, - }, - }); -} - -function createHarness( - interactionTimeoutMs = 5 * 60 * 1_000, - maxToolInputBytes?: number, - maxPendingPermissionBytes?: number, - onAuthority?: (update: ContractAuthorityUpdate) => Promise | void, -) { - let nowMs = Date.parse("2026-07-16T08:00:00.000Z"); - let snapshot = createInitialSnapshot(new Date(nowMs).toISOString()); - let nextId = 100; - const authority: ContractAuthorityUpdate[] = []; - const previews: ContractPreviewUpdate[] = []; - const commit = (cause: CommittedMutation["cause"], operations: AuthorityOperation[]): void => { - const revision = snapshot.revision + 1; - const mutation: CommittedMutation = { - baseRevision: snapshot.revision, - cause, - committedAt: new Date(nowMs).toISOString(), - mutationId: protocolId(1_000 + revision), - operations, - revision, - sessionId: SESSION_ID, - }; - snapshot = applyCommittedMutation(snapshot, mutation); - }; - const adapter = new ClaudeContractAdapter({ - authority: async (update) => { - authority.push(update); - await onAuthority?.(update); - commit(update.cause, [...update.operations] as AuthorityOperation[]); - }, - createId: () => protocolId(nextId++), - interactionTimeoutMs, - maxPendingPermissionBytes, - maxToolInputBytes, - now: () => new Date(nowMs), - preview: (update) => previews.push(update), - sessionId: SESSION_ID, - }); - - return { - adapter, - advance(milliseconds: number) { - nowMs += milliseconds; - }, - authority, - previews, - settleInteraction(interactionId: string, resolution?: InteractionResolution) { - const interaction = snapshot.interactions.find((entry) => entry.id === interactionId); - - if (interaction === undefined || interaction.status !== "open") { - throw new Error("The test interaction must be open."); - } - - if (resolution !== undefined && resolution.kind !== interaction.kind) { - throw new Error("The test resolution kind must match the interaction kind."); - } - - const endedAt = new Date(nowMs).toISOString(); - commit({ commandId: protocolId(2_000 + snapshot.revision + 1), type: "command" }, [ - { - entity: "interaction", - op: "put", - value: interactionSchema.parse( - resolution === undefined - ? { ...interaction, endedAt, status: "expired" } - : { - ...interaction, - endedAt, - resolution: resolution.value, - status: "resolved", - }, - ), - }, - ]); - }, - snapshot: () => snapshot, - }; -} - -async function registerRun(adapter: ClaudeContractAdapter): Promise { - adapter.attachRun(activeRun("2026-07-16T08:00:00.000Z")); -} - -describe("Claude Contract adapter", () => { - test("does not let a late stream stop overwrite authoritative tool input", async () => { - const harness = createHarness(); - await registerRun(harness.adapter); - await harness.adapter.handleMessage( - sdkMessage({ - event: { - content_block: { id: "tool-1", input: {}, name: "Read", type: "tool_use" }, - index: 0, - type: "content_block_start", - }, - parent_tool_use_id: null, - session_id: "native-session-1", - type: "stream_event", - uuid: "assistant-1", - }), - RUN_ID, - ); - await harness.adapter.handleMessage( - sdkMessage({ - event: { - delta: { partial_json: '{"value":1}', type: "input_json_delta" }, - index: 0, - type: "content_block_delta", - }, - parent_tool_use_id: null, - session_id: "native-session-1", - type: "stream_event", - uuid: "assistant-1", - }), - RUN_ID, - ); - await harness.adapter.handleMessage( - sdkMessage({ - message: { - content: [{ id: "tool-1", input: { value: 2 }, name: "Read", type: "tool_use" }], - }, - parent_tool_use_id: null, - session_id: "native-session-1", - type: "assistant", - uuid: "assistant-1", - }), - RUN_ID, - ); - await harness.adapter.handleMessage( - sdkMessage({ - event: { index: 0, type: "content_block_stop" }, - parent_tool_use_id: null, - session_id: "native-session-1", - type: "stream_event", - uuid: "assistant-1", - }), - RUN_ID, - ); - - expect(harness.snapshot().items).toContainEqual( - expect.objectContaining({ id: "tool:tool-1", input: { value: 2 } }), - ); - }); - - test("rejects oversized permission input before creating Authority state", async () => { - const harness = createHarness(5 * 60 * 1_000, 8); - await registerRun(harness.adapter); - - await expect( - harness.adapter.openPermission( - RUN_ID, - "Bash", - { payload: "too-large" }, - { - requestId: "oversized-request", - signal: new AbortController().signal, - toolUseID: "tool-oversized", - }, - ), - ).rejects.toThrow("exceeds its byte limit"); - expect(harness.snapshot().items).toHaveLength(0); - expect(harness.snapshot().interactions).toHaveLength(0); - }); - - test.each([Number.POSITIVE_INFINITY, 1.5])("rejects invalid tool input limit %p", (value) => { - expect(() => createHarness(5 * 60 * 1_000, value)).toThrow( - "limits must be finite and positive", - ); - }); - - test.each([Number.POSITIVE_INFINITY, 1.5])( - "rejects invalid pending permission limit %p", - (value) => { - expect(() => createHarness(5 * 60 * 1_000, undefined, value)).toThrow( - "limits must be finite and positive", - ); - }, - ); - - test("preserves first-seen assistant block order", async () => { - const harness = createHarness(); - await registerRun(harness.adapter); - await harness.adapter.handleMessage( - sdkMessage({ - message: { - content: [ - { text: "before tool", type: "text" }, - { id: "tool-1", input: {}, name: "Read", type: "tool_use" }, - { thinking: "after tool", type: "thinking" }, - ], - }, - parent_tool_use_id: null, - session_id: "native-session-1", - type: "assistant", - uuid: "assistant-1", - }), - RUN_ID, - ); - - expect(harness.snapshot().items.map((item) => item.kind)).toEqual([ - "message", - "tool", - "reasoning", - ]); - }); - - test("isolates streamed tool indexes by assistant and drops invalid JSON fragments", async () => { - const harness = createHarness(); - await registerRun(harness.adapter); - - for (const [uuid, toolId] of [ - ["assistant-a", "tool-a"], - ["assistant-b", "tool-b"], - ["assistant-c", "tool-c"], - ] as const) { - await harness.adapter.handleMessage( - sdkMessage({ - event: { - content_block: { id: toolId, name: "Read", type: "tool_use" }, - index: 0, - type: "content_block_start", - }, - parent_tool_use_id: null, - session_id: "native-session-1", - type: "stream_event", - uuid, - }), - RUN_ID, - ); - } - - for (const [uuid, partialJson] of [ - ["assistant-a", '{"a":1}'], - ["assistant-b", '{"b":2}'], - ["assistant-c", "{"], - ] as const) { - await harness.adapter.handleMessage( - sdkMessage({ - event: { - delta: { partial_json: partialJson, type: "input_json_delta" }, - index: 0, - type: "content_block_delta", - }, - parent_tool_use_id: null, - session_id: "native-session-1", - type: "stream_event", - uuid, - }), - RUN_ID, - ); - } - - for (const uuid of ["assistant-a", "assistant-b", "assistant-c"] as const) { - await harness.adapter.handleMessage( - sdkMessage({ - event: { index: 0, type: "content_block_stop" }, - parent_tool_use_id: null, - session_id: "native-session-1", - type: "stream_event", - uuid, - }), - RUN_ID, - ); - } - - expect(harness.snapshot().items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: "tool:tool-a", input: { a: 1 } }), - expect.objectContaining({ id: "tool:tool-b", input: { b: 2 } }), - ]), - ); - const invalid = harness.snapshot().items.find((item) => item.id === "tool:tool-c"); - expect(invalid).toMatchObject({ id: "tool:tool-c", kind: "tool" }); - expect(invalid?.kind === "tool" ? invalid.input : null).toBeUndefined(); - }); - - test("avoids phantom messages and cancels unresolved items at a successful result boundary", async () => { - const harness = createHarness(); - await registerRun(harness.adapter); - await harness.adapter.handleMessage( - sdkMessage({ - event: { type: "message_start" }, - parent_tool_use_id: null, - session_id: "native-session-1", - type: "stream_event", - uuid: "assistant-empty", - }), - RUN_ID, - ); - expect(harness.snapshot().items).toHaveLength(0); - - await harness.adapter.handleMessage( - sdkMessage({ - elapsed_time_seconds: 1, - session_id: "native-session-1", - tool_name: "Read", - tool_use_id: "tool-pending", - type: "tool_progress", - uuid: "progress-1", - }), - RUN_ID, - ); - await harness.adapter.handleMessage( - sdkMessage({ - is_error: false, - modelUsage: {}, - num_turns: 1, - permission_denials: [], - result: "done", - session_id: "native-session-1", - stop_reason: null, - subtype: "success", - terminal_reason: "background_requested", - total_cost_usd: 0, - type: "result", - usage: { input_tokens: 1, output_tokens: 1 }, - uuid: "result-1", - }), - RUN_ID, - ); - - expect(harness.snapshot().runs[0]).toMatchObject({ - finishReason: "other", - status: "completed", - }); - expect(harness.snapshot().items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: "tool:tool-pending", status: "cancelled" }), - expect.objectContaining({ content: [{ text: "done", type: "text" }], status: "completed" }), - ]), - ); - }); - - test("fences native sessions", async () => { - const harness = createHarness(); - await registerRun(harness.adapter); - await harness.adapter.handleMessage( - sdkMessage({ - mcp_servers: [], - model: "sonnet", - permissionMode: "default", - session_id: "native-session-1", - subtype: "init", - tools: [], - type: "system", - uuid: "init-1", - }), - RUN_ID, - ); - await expect( - harness.adapter.handleMessage( - sdkMessage({ - mcp_servers: [], - model: "sonnet", - permissionMode: "default", - session_id: "native-session-2", - subtype: "init", - tools: [], - type: "system", - uuid: "init-2", - }), - RUN_ID, - ), - ).rejects.toThrow("different native session"); - }); - - test.each([ - ["error_max_turns", "max_turns"], - ["error_max_budget_usd", "budget_exhausted"], - ["error_max_structured_output_retries", "structured_output_retry_exhausted"], - ] as const)("treats %s as a limit", async (subtype, terminalReason) => { - const harness = createHarness(); - await registerRun(harness.adapter); - await harness.adapter.handleMessage( - sdkMessage({ - errors: [`Run stopped at ${terminalReason}`], - is_error: true, - modelUsage: {}, - num_turns: 10, - permission_denials: [], - session_id: "native-session-1", - stop_reason: null, - subtype, - terminal_reason: terminalReason, - total_cost_usd: 0, - type: "result", - usage: { input_tokens: 1, output_tokens: 1 }, - uuid: "result-1", - }), - RUN_ID, - ); - - expect(harness.snapshot().runs[0]).toMatchObject({ - finishReason: "limit", - status: "completed", - }); - }); - - test("waits for task notification before making a task immutable", async () => { - const harness = createHarness(); - await registerRun(harness.adapter); - await harness.adapter.handleMessage( - sdkMessage({ - description: "Research", - session_id: "native-session-1", - subtype: "task_started", - task_id: "task-1", - type: "system", - uuid: "task-start-1", - }), - RUN_ID, - ); - await harness.adapter.handleMessage( - sdkMessage({ - description: "Research replay", - session_id: "native-session-1", - subtype: "task_started", - task_id: "task-1", - type: "system", - uuid: "task-start-2", - }), - RUN_ID, - ); - await harness.adapter.handleMessage( - sdkMessage({ - patch: { status: "completed" }, - session_id: "native-session-1", - subtype: "task_updated", - task_id: "task-1", - type: "system", - uuid: "task-update-1", - }), - RUN_ID, - ); - expect(harness.snapshot().items[0]?.status).toBe("active"); - - await harness.adapter.handleMessage( - sdkMessage({ - output_file: "/tmp/task-1.output", - session_id: "native-session-1", - status: "completed", - subtype: "task_notification", - summary: "Research complete", - task_id: "task-1", - type: "system", - usage: { duration_ms: 10, tool_uses: 1, total_tokens: 20 }, - uuid: "task-result-1", - }), - RUN_ID, - ); - expect(harness.snapshot().items[0]).toMatchObject({ - output: [{ text: "Research complete", type: "text" }], - status: "completed", - }); - }); -}); diff --git a/tests/claude-contract-adapter-terminal.test.ts b/tests/claude-contract-adapter-terminal.test.ts deleted file mode 100644 index 6170072..0000000 --- a/tests/claude-contract-adapter-terminal.test.ts +++ /dev/null @@ -1,516 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { CanUseTool, SDKMessage } from "@anthropic-ai/claude-agent-sdk"; - -import { - AuthorityOutcomeUnknownError, - applyCommittedMutation, - interactionSchema, - validateSessionSnapshot, -} from "../src/contract"; -import type { - AuthorityOperation, - CommittedMutation, - InteractionResolution, - Run, - SessionSnapshot, -} from "../src/contract"; -import { ClaudeContractAdapter } from "../src/runtimes/claude/contract-adapter"; -import type { - ContractAuthorityUpdate, - ContractPreviewUpdate, -} from "../src/runtimes/contract-projection"; - -const SESSION_ID = protocolId(1); -const RUN_ID = protocolId(2); - -function protocolId(value: number): string { - return value.toString().padStart(26, "0"); -} - -function sdkMessage(value: unknown): SDKMessage { - return value as SDKMessage; -} - -function activeRun(startedAt: string): Run { - return { - id: RUN_ID, - input: [{ text: "hello", type: "text" }], - origin: "user", - startedAt, - status: "active", - }; -} - -function createInitialSnapshot(capturedAt: string): SessionSnapshot { - return validateSessionSnapshot({ - capturedAt, - interactions: [], - items: [], - protocolVersion: 2, - revision: 0, - runs: [activeRun(capturedAt)], - session: { - capabilities: { - "interaction.permission": {}, - "item.artifact": {}, - "item.change": {}, - "item.plan": {}, - "item.reasoning": {}, - "item.terminal": {}, - }, - config: [], - createdAt: capturedAt, - id: SESSION_ID, - status: "open", - updatedAt: capturedAt, - }, - }); -} - -function createHarness( - interactionTimeoutMs = 5 * 60 * 1_000, - maxToolInputBytes?: number, - maxPendingPermissionBytes?: number, - onAuthority?: (update: ContractAuthorityUpdate) => Promise | void, -) { - let nowMs = Date.parse("2026-07-16T08:00:00.000Z"); - let snapshot = createInitialSnapshot(new Date(nowMs).toISOString()); - let nextId = 100; - const authority: ContractAuthorityUpdate[] = []; - const previews: ContractPreviewUpdate[] = []; - const commit = (cause: CommittedMutation["cause"], operations: AuthorityOperation[]): void => { - const revision = snapshot.revision + 1; - const mutation: CommittedMutation = { - baseRevision: snapshot.revision, - cause, - committedAt: new Date(nowMs).toISOString(), - mutationId: protocolId(1_000 + revision), - operations, - revision, - sessionId: SESSION_ID, - }; - snapshot = applyCommittedMutation(snapshot, mutation); - }; - const adapter = new ClaudeContractAdapter({ - authority: async (update) => { - authority.push(update); - await onAuthority?.(update); - commit(update.cause, [...update.operations] as AuthorityOperation[]); - }, - createId: () => protocolId(nextId++), - interactionTimeoutMs, - maxPendingPermissionBytes, - maxToolInputBytes, - now: () => new Date(nowMs), - preview: (update) => previews.push(update), - sessionId: SESSION_ID, - }); - - return { - adapter, - advance(milliseconds: number) { - nowMs += milliseconds; - }, - authority, - previews, - settleInteraction(interactionId: string, resolution?: InteractionResolution) { - const interaction = snapshot.interactions.find((entry) => entry.id === interactionId); - - if (interaction === undefined || interaction.status !== "open") { - throw new Error("The test interaction must be open."); - } - - if (resolution !== undefined && resolution.kind !== interaction.kind) { - throw new Error("The test resolution kind must match the interaction kind."); - } - - const endedAt = new Date(nowMs).toISOString(); - commit({ commandId: protocolId(2_000 + snapshot.revision + 1), type: "command" }, [ - { - entity: "interaction", - op: "put", - value: interactionSchema.parse( - resolution === undefined - ? { ...interaction, endedAt, status: "expired" } - : { - ...interaction, - endedAt, - resolution: resolution.value, - status: "resolved", - }, - ), - }, - ]); - }, - snapshot: () => snapshot, - }; -} - -async function registerRun(adapter: ClaudeContractAdapter): Promise { - adapter.attachRun(activeRun("2026-07-16T08:00:00.000Z")); -} - -function permissionOptions(requestId: string, toolUseID: string) { - return { - requestId, - signal: new AbortController().signal, - title: "Run command?", - toolUseID, - } satisfies Parameters[2]; -} - -function permissionBytes( - input: Record, - options: Parameters[2], -): number { - return new TextEncoder().encode( - JSON.stringify({ - input, - options: Object.fromEntries(Object.entries(options).filter(([name]) => name !== "signal")), - toolName: "Bash", - }), - ).byteLength; -} - -describe("Claude Contract adapter", () => { - test("retries the exact cancelled snapshot after an unknown Authority outcome", async () => { - const controller = new AbortController(); - const firstAbort = Promise.withResolvers(); - const aborts: ContractAuthorityUpdate[] = []; - const harness = createHarness(5 * 60 * 1_000, undefined, undefined, (update) => { - if (update.event !== "permission/aborted") { - return; - } - - aborts.push(update); - if (aborts.length === 1) { - firstAbort.resolve(); - throw new AuthorityOutcomeUnknownError("Authority response was lost"); - } - }); - await registerRun(harness.adapter); - const interactionId = await harness.adapter.openPermission( - RUN_ID, - "Bash", - { command: "pwd" }, - { - requestId: "request-abort-unknown", - signal: controller.signal, - toolUseID: "tool-abort-unknown", - }, - ); - - controller.abort(); - await firstAbort.promise; - await Promise.resolve(); - await expect( - harness.adapter.resolveInteraction(interactionId, { - kind: "permission", - value: { type: "cancelled" }, - }), - ).resolves.toBeNull(); - - expect(aborts).toHaveLength(2); - expect(aborts[1]?.mutationId).toBe(aborts[0]?.mutationId); - expect(aborts[1]?.operations).toEqual(aborts[0]?.operations); - }); - - test("converges a permission aborted during its opening Authority write", async () => { - const interactionWriting = Promise.withResolvers(); - const releaseInteraction = Promise.withResolvers(); - const aborted = Promise.withResolvers(); - const harness = createHarness(5 * 60 * 1_000, undefined, undefined, async (update) => { - if (update.event === "permission/requested") { - interactionWriting.resolve(); - await releaseInteraction.promise; - } - if (update.event === "permission/aborted") { - aborted.resolve(); - } - }); - await registerRun(harness.adapter); - const controller = new AbortController(); - const opening = harness.adapter.openPermission( - RUN_ID, - "Bash", - { command: "pwd" }, - { - requestId: "request-abort-opening", - signal: controller.signal, - toolUseID: "tool-abort-opening", - }, - ); - await interactionWriting.promise; - - controller.abort(); - releaseInteraction.resolve(); - await expect(opening).rejects.toMatchObject({ name: "AbortError" }); - await aborted.promise; - expect(harness.snapshot().interactions[0]).toMatchObject({ - resolution: { type: "cancelled" }, - status: "resolved", - }); - }); - - test("retains an opening permission when its cancellation write is rejected", async () => { - const input = { command: "pwd" }; - const controller = new AbortController(); - const options = { - ...permissionOptions("request-orphan-1", "tool-orphan-1"), - signal: controller.signal, - }; - const interactionWriting = Promise.withResolvers(); - const releaseInteraction = Promise.withResolvers(); - let rejectCancellation = true; - const requested: ContractAuthorityUpdate[] = []; - const harness = createHarness( - 5 * 60 * 1_000, - undefined, - permissionBytes(input, options), - async (update) => { - if (update.event === "permission/requested") { - requested.push(update); - interactionWriting.resolve(); - await releaseInteraction.promise; - } - if (update.event === "permission/aborted" && rejectCancellation) { - rejectCancellation = false; - throw new Error("Authority unavailable"); - } - }, - ); - await registerRun(harness.adapter); - const opening = harness.adapter.openPermission(RUN_ID, "Bash", input, options); - await interactionWriting.promise; - - const retryOptions = permissionOptions("request-orphan-2", "tool-orphan-2"); - controller.abort(); - releaseInteraction.resolve(); - await expect(opening).rejects.toThrow("Authority unavailable"); - - const interactionId = harness.snapshot().interactions[0]?.id; - expect(interactionId).toBeDefined(); - await expect( - harness.adapter.openPermission(RUN_ID, "Bash", input, retryOptions), - ).rejects.toThrow("pending permission budget"); - expect(requested).toHaveLength(1); - await expect( - harness.adapter.resolveInteraction(interactionId!, { - kind: "permission", - value: { type: "cancelled" }, - }), - ).resolves.toBeNull(); - expect(harness.snapshot().interactions[0]).toMatchObject({ - id: interactionId, - resolution: { type: "cancelled" }, - status: "resolved", - }); - await expect( - harness.adapter.openPermission(RUN_ID, "Bash", input, retryOptions), - ).resolves.toBeDefined(); - }); - - test("releases an opening permission reservation when its run finishes", async () => { - const input = { command: "pwd" }; - const options = permissionOptions("request-late", "tool-late"); - const interactionAuthority = Promise.withResolvers(); - const releaseAuthority = Promise.withResolvers(); - const harness = createHarness( - 5 * 60 * 1_000, - undefined, - permissionBytes(input, options), - async (update) => { - if (update.event === "permission/requested") { - interactionAuthority.resolve(); - await releaseAuthority.promise; - } - }, - ); - await registerRun(harness.adapter); - const opening = harness.adapter.openPermission(RUN_ID, "Bash", input, options); - await interactionAuthority.promise; - - const finishing = harness.adapter.handleMessage( - sdkMessage({ - is_error: false, - modelUsage: {}, - num_turns: 1, - permission_denials: [], - result: "done", - session_id: "native-session-1", - stop_reason: "end_turn", - subtype: "success", - total_cost_usd: 0, - type: "result", - usage: { input_tokens: 1, output_tokens: 1 }, - uuid: "result-late-permission", - }), - RUN_ID, - ); - releaseAuthority.resolve(); - await finishing; - - await expect(opening).rejects.toThrow("active Run"); - await expect( - harness.adapter.openPermission( - RUN_ID, - "Bash", - input, - permissionOptions("request-next", "tool-next"), - ), - ).rejects.toThrow("unknown run"); - }); - - test("translates a Coordinator expiry without rechecking its local deadline", async () => { - const harness = createHarness(1_000); - await registerRun(harness.adapter); - const interactionId = await harness.adapter.openPermission( - RUN_ID, - "Bash", - { command: "pwd" }, - { - requestId: "request-expired", - signal: new AbortController().signal, - toolUseID: "tool-expired", - }, - ); - harness.advance(1_001); - harness.settleInteraction(interactionId); - - await expect( - harness.adapter.resolveInteraction(interactionId, { - kind: "permission", - value: { type: "cancelled" }, - }), - ).resolves.toMatchObject({ behavior: "deny", interrupt: true }); - expect(harness.snapshot().interactions[0]).toMatchObject({ status: "expired" }); - }); - - test("turns aborted result frames into a cancelled run and flushes active Preview", async () => { - const harness = createHarness(); - await registerRun(harness.adapter); - await harness.adapter.handleMessage( - sdkMessage({ - event: { type: "message_start" }, - parent_tool_use_id: null, - session_id: "native-session-1", - type: "stream_event", - uuid: "assistant-1", - }), - RUN_ID, - ); - await harness.adapter.handleMessage( - sdkMessage({ - event: { - delta: { text: "partial", type: "text_delta" }, - index: 0, - type: "content_block_delta", - }, - parent_tool_use_id: null, - session_id: "native-session-1", - type: "stream_event", - uuid: "assistant-1", - }), - RUN_ID, - ); - await harness.adapter.handleMessage( - sdkMessage({ - errors: ["aborted"], - is_error: true, - modelUsage: {}, - num_turns: 1, - permission_denials: [], - session_id: "native-session-1", - stop_reason: null, - subtype: "error_during_execution", - terminal_reason: "aborted_streaming", - total_cost_usd: 0, - type: "result", - usage: { input_tokens: 1, output_tokens: 1 }, - uuid: "result-1", - }), - RUN_ID, - ); - - expect(harness.snapshot().runs[0]?.status).toBe("cancelled"); - expect(harness.snapshot().items).toContainEqual( - expect.objectContaining({ - content: [{ text: "partial", type: "text" }], - kind: "message", - status: "cancelled", - }), - ); - }); - - test("bounds streamed and authoritative tool input and normalizes empty SDK labels", async () => { - const harness = createHarness(5 * 60 * 1_000, 8); - await registerRun(harness.adapter); - await harness.adapter.handleMessage( - sdkMessage({ - event: { - content_block: { id: "tool-1", input: {}, name: "", type: "tool_use" }, - index: 0, - type: "content_block_start", - }, - parent_tool_use_id: null, - session_id: "native-session-1", - type: "stream_event", - uuid: "assistant-1", - }), - RUN_ID, - ); - await harness.adapter.handleMessage( - sdkMessage({ - event: { - delta: { partial_json: '{"payload":"too-large"}', type: "input_json_delta" }, - index: 0, - type: "content_block_delta", - }, - parent_tool_use_id: null, - session_id: "native-session-1", - type: "stream_event", - uuid: "assistant-1", - }), - RUN_ID, - ); - await harness.adapter.handleMessage( - sdkMessage({ - event: { index: 0, type: "content_block_stop" }, - parent_tool_use_id: null, - session_id: "native-session-1", - type: "stream_event", - uuid: "assistant-1", - }), - RUN_ID, - ); - await expect( - harness.adapter.handleMessage( - sdkMessage({ - message: { - content: [ - { - id: "tool-1", - input: { payload: "authoritative" }, - name: "", - type: "tool_use", - }, - ], - }, - parent_tool_use_id: null, - session_id: "native-session-1", - type: "assistant", - uuid: "assistant-1", - }), - RUN_ID, - ), - ).rejects.toThrow("exceeds its byte limit"); - - expect(harness.snapshot().items).toContainEqual( - expect.objectContaining({ - input: {}, - kind: "tool", - name: "Tool", - }), - ); - }); -}); diff --git a/tests/claude-contract-adapter-transcript.test.ts b/tests/claude-contract-adapter-transcript.test.ts deleted file mode 100644 index 0cc583d..0000000 --- a/tests/claude-contract-adapter-transcript.test.ts +++ /dev/null @@ -1,502 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { CanUseTool, SDKMessage } from "@anthropic-ai/claude-agent-sdk"; - -import { - applyCommittedMutation, - interactionSchema, - validateSessionSnapshot, -} from "../src/contract"; -import type { - AuthorityOperation, - CommittedMutation, - InteractionResolution, - Run, - SessionSnapshot, -} from "../src/contract"; -import { ClaudeContractAdapter } from "../src/runtimes/claude/contract-adapter"; -import type { - ContractAuthorityUpdate, - ContractPreviewUpdate, -} from "../src/runtimes/contract-projection"; - -const SESSION_ID = protocolId(1); -const RUN_ID = protocolId(2); - -function protocolId(value: number): string { - return value.toString().padStart(26, "0"); -} - -function sdkMessage(value: unknown): SDKMessage { - return value as SDKMessage; -} - -function resultMessage(usage: unknown, totalCostUsd: number): SDKMessage { - return sdkMessage({ - is_error: false, - modelUsage: {}, - num_turns: 1, - permission_denials: [], - result: "", - session_id: "native-session-1", - stop_reason: null, - subtype: "success", - total_cost_usd: totalCostUsd, - type: "result", - usage, - uuid: "result-1", - }); -} - -function activeRun(startedAt: string): Run { - return { - id: RUN_ID, - input: [{ text: "hello", type: "text" }], - origin: "user", - startedAt, - status: "active", - }; -} - -function createInitialSnapshot(capturedAt: string): SessionSnapshot { - return validateSessionSnapshot({ - capturedAt, - interactions: [], - items: [], - protocolVersion: 2, - revision: 0, - runs: [activeRun(capturedAt)], - session: { - capabilities: { - "interaction.permission": {}, - "item.artifact": {}, - "item.change": {}, - "item.plan": {}, - "item.reasoning": {}, - "item.terminal": {}, - }, - config: [], - createdAt: capturedAt, - id: SESSION_ID, - status: "open", - updatedAt: capturedAt, - }, - }); -} - -function createHarness( - interactionTimeoutMs = 5 * 60 * 1_000, - maxToolInputBytes?: number, - maxPendingPermissionBytes?: number, - onAuthority?: (update: ContractAuthorityUpdate) => Promise | void, -) { - let nowMs = Date.parse("2026-07-16T08:00:00.000Z"); - let snapshot = createInitialSnapshot(new Date(nowMs).toISOString()); - let nextId = 100; - const authority: ContractAuthorityUpdate[] = []; - const previews: ContractPreviewUpdate[] = []; - const commit = (cause: CommittedMutation["cause"], operations: AuthorityOperation[]): void => { - const revision = snapshot.revision + 1; - const mutation: CommittedMutation = { - baseRevision: snapshot.revision, - cause, - committedAt: new Date(nowMs).toISOString(), - mutationId: protocolId(1_000 + revision), - operations, - revision, - sessionId: SESSION_ID, - }; - snapshot = applyCommittedMutation(snapshot, mutation); - }; - const adapter = new ClaudeContractAdapter({ - authority: async (update) => { - authority.push(update); - await onAuthority?.(update); - commit(update.cause, [...update.operations] as AuthorityOperation[]); - }, - createId: () => protocolId(nextId++), - interactionTimeoutMs, - maxPendingPermissionBytes, - maxToolInputBytes, - now: () => new Date(nowMs), - preview: (update) => previews.push(update), - sessionId: SESSION_ID, - }); - - return { - adapter, - advance(milliseconds: number) { - nowMs += milliseconds; - }, - authority, - previews, - settleInteraction(interactionId: string, resolution?: InteractionResolution) { - const interaction = snapshot.interactions.find((entry) => entry.id === interactionId); - - if (interaction === undefined || interaction.status !== "open") { - throw new Error("The test interaction must be open."); - } - - if (resolution !== undefined && resolution.kind !== interaction.kind) { - throw new Error("The test resolution kind must match the interaction kind."); - } - - const endedAt = new Date(nowMs).toISOString(); - commit({ commandId: protocolId(2_000 + snapshot.revision + 1), type: "command" }, [ - { - entity: "interaction", - op: "put", - value: interactionSchema.parse( - resolution === undefined - ? { ...interaction, endedAt, status: "expired" } - : { - ...interaction, - endedAt, - resolution: resolution.value, - status: "resolved", - }, - ), - }, - ]); - }, - snapshot: () => snapshot, - }; -} - -async function registerRun(adapter: ClaudeContractAdapter): Promise { - adapter.attachRun(activeRun("2026-07-16T08:00:00.000Z")); -} - -describe("Claude Contract adapter", () => { - test("ignores model-call usage deltas until the Run-level result snapshot", async () => { - const harness = createHarness(); - await registerRun(harness.adapter); - const before = harness.authority.length; - - await harness.adapter.handleMessage( - sdkMessage({ - event: { - delta: { stop_reason: "end_turn", stop_sequence: null }, - type: "message_delta", - usage: { input_tokens: 10, output_tokens: 20 }, - }, - parent_tool_use_id: null, - session_id: "native-session-1", - type: "stream_event", - uuid: "assistant-1", - }), - RUN_ID, - ); - - expect(harness.authority).toHaveLength(before); - expect(harness.snapshot().runs[0]?.usage).toBeUndefined(); - }); - - test.each([ - { - expected: undefined, - label: "MAX_VALUE input and output", - usage: { - cache_read_input_tokens: Number.MAX_VALUE, - input_tokens: Number.MAX_VALUE, - output_tokens: Number.MAX_VALUE, - }, - }, - { - expected: { cachedInput: 3, output: 2, total: 2 }, - label: "negative input", - usage: { cache_read_input_tokens: 3, input_tokens: -1, output_tokens: 2 }, - }, - { - expected: { cachedInput: 3, input: 2, total: 2 }, - label: "fractional output", - usage: { cache_read_input_tokens: 3, input_tokens: 2, output_tokens: 1.5 }, - }, - { - expected: { output: 2, total: 2 }, - label: "unsafe input and cached input", - usage: { - cache_read_input_tokens: Number.MAX_SAFE_INTEGER + 1, - input_tokens: Number.MAX_SAFE_INTEGER + 1, - output_tokens: 2, - }, - }, - { - expected: { input: Number.MAX_SAFE_INTEGER, output: 1 }, - label: "unsafe derived total", - usage: { - cache_read_input_tokens: Number.NaN, - input_tokens: Number.MAX_SAFE_INTEGER, - output_tokens: 1, - }, - }, - ])("drops $label without blocking Run completion", async ({ expected, usage }) => { - const harness = createHarness(); - await registerRun(harness.adapter); - - await harness.adapter.handleMessage(resultMessage(usage, Number.NaN), RUN_ID); - - expect(harness.snapshot().runs[0]).toMatchObject({ status: "completed" }); - expect(harness.snapshot().runs[0]?.usage).toEqual(expected); - }); - - test.each([ - { label: "negative", value: -0.01 }, - { label: "Infinity", value: Infinity }, - { label: "-Infinity", value: -Infinity }, - { label: "NaN", value: Number.NaN }, - ])("drops $label cost without blocking Run completion", async ({ value }) => { - const harness = createHarness(); - await registerRun(harness.adapter); - - await harness.adapter.handleMessage( - resultMessage({ input_tokens: 1, output_tokens: 2 }, value), - RUN_ID, - ); - - expect(harness.snapshot().runs[0]).toMatchObject({ - status: "completed", - usage: { input: 1, output: 2, total: 3 }, - }); - expect(harness.snapshot().runs[0]?.usage?.cost).toBeUndefined(); - }); - - test("repairs streamed text from the authoritative message and keeps source time as metadata", async () => { - const harness = createHarness(); - await registerRun(harness.adapter); - await harness.adapter.handleMessage( - sdkMessage({ - event: { type: "message_start" }, - parent_tool_use_id: null, - session_id: "native-session-1", - type: "stream_event", - uuid: "assistant-1", - }), - RUN_ID, - ); - await harness.adapter.handleMessage( - sdkMessage({ - event: { - delta: { text: "hel", type: "text_delta" }, - index: 0, - type: "content_block_delta", - }, - parent_tool_use_id: null, - session_id: "native-session-1", - type: "stream_event", - uuid: "assistant-1", - }), - RUN_ID, - ); - await harness.adapter.handleMessage( - sdkMessage({ - message: { - content: [{ text: "hello", type: "text" }], - }, - parent_tool_use_id: null, - session_id: "native-session-1", - supersedes: ["assistant-old"], - timestamp: "2020-01-01T00:00:00.000Z", - type: "assistant", - uuid: "assistant-1", - }), - RUN_ID, - ); - await harness.adapter.handleMessage( - sdkMessage({ - is_error: false, - modelUsage: {}, - num_turns: 1, - permission_denials: [], - result: "hello", - session_id: "native-session-1", - stop_reason: "max_tokens", - structured_output: { answer: 42 }, - subtype: "success", - total_cost_usd: 0.01, - type: "result", - usage: { - cache_read_input_tokens: 2, - input_tokens: 3, - output_tokens: 5, - }, - uuid: "result-1", - }), - RUN_ID, - ); - - expect(harness.previews.map((entry) => entry.update)).toMatchObject([ - { op: "append", text: "hel" }, - ]); - expect(harness.snapshot().items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - content: [{ text: "hello", type: "text" }], - extensions: { - "anthropic.agent-sdk/source-timestamp": "2020-01-01T00:00:00.000Z", - "anthropic.agent-sdk/supersedes": ["assistant-old"], - }, - id: "message:assistant-1", - kind: "message", - status: "completed", - }), - expect.objectContaining({ - content: [{ type: "json", value: { answer: 42 } }], - kind: "artifact", - name: "structured-output.json", - status: "completed", - }), - ]), - ); - expect(harness.snapshot().runs[0]).toMatchObject({ - finishReason: "limit", - status: "completed", - usage: { - cachedInput: 2, - cost: { amount: 0.01, currency: "USD" }, - input: 3, - output: 5, - total: 8, - }, - }); - }); - - test("round-trips permission decisions and keeps only session-scoped suggestions", async () => { - const harness = createHarness(); - await registerRun(harness.adapter); - const input = { command: "pwd" }; - const options = { - requestId: "request-1", - signal: new AbortController().signal, - suggestions: [ - { - behavior: "allow", - destination: "session", - rules: [{ toolName: "Bash" }], - type: "addRules", - }, - { - behavior: "allow", - destination: "userSettings", - rules: [{ toolName: "Bash" }], - type: "addRules", - }, - ], - title: "Run command?", - toolUseID: "tool-1", - } satisfies Parameters[2]; - const interactionId = await harness.adapter.openPermission(RUN_ID, "Bash", input, options); - expect( - await harness.adapter.openPermission( - RUN_ID, - "Bash", - { command: "pwd" }, - { - ...options, - signal: new AbortController().signal, - }, - ), - ).toBe(interactionId); - expect(harness.snapshot().interactions).toHaveLength(1); - const interaction = harness.snapshot().interactions.find((entry) => entry.id === interactionId); - const allowSessionId = - interaction?.kind === "permission" - ? interaction.request.options.find((option) => option.scope === "session")?.id - : undefined; - const resolution = { - kind: "permission", - value: { optionId: allowSessionId!, type: "selected" }, - } satisfies InteractionResolution; - - input.command = "rm -rf /"; - options.suggestions[0]!.rules[0]!.toolName = "Changed"; - harness.settleInteraction(interactionId, resolution); - await expect(harness.adapter.resolveInteraction(interactionId, resolution)).resolves.toEqual({ - behavior: "allow", - toolUseID: "tool-1", - updatedInput: { command: "pwd" }, - updatedPermissions: [ - { - behavior: "allow", - destination: "session", - rules: [{ toolName: "Bash" }], - type: "addRules", - }, - ], - }); - await harness.adapter.handleMessage( - sdkMessage({ - message: { - content: [ - { - content: [{ text: "/workspace", type: "text" }], - tool_use_id: "tool-1", - type: "tool_result", - }, - ], - }, - parent_tool_use_id: null, - session_id: "native-session-1", - timestamp: "2020-01-01T00:00:00.000Z", - type: "user", - uuid: "user-1", - }), - RUN_ID, - ); - await harness.adapter.handleMessage( - sdkMessage({ - is_error: false, - modelUsage: {}, - num_turns: 1, - permission_denials: [], - result: "done", - session_id: "native-session-1", - stop_reason: "end_turn", - subtype: "success", - total_cost_usd: 0, - type: "result", - usage: { input_tokens: 1, output_tokens: 1 }, - uuid: "result-1", - }), - RUN_ID, - ); - - expect(harness.snapshot().items).toContainEqual( - expect.objectContaining({ - id: "tool:tool-1", - kind: "tool", - output: [{ text: "/workspace", type: "text" }], - status: "completed", - }), - ); - expect(harness.snapshot().interactions[0]?.status).toBe("resolved"); - }); - - test.each([ - ["tool name", "Read", { command: "pwd" }, {}], - ["tool input", "Bash", { command: "rm -rf /" }, {}], - ["prompt metadata", "Bash", { command: "pwd" }, { title: "Changed prompt" }], - ] as const)( - "rejects a permission replay with changed %s", - async (_name, replayToolName, replayInput, optionChanges) => { - const harness = createHarness(); - await registerRun(harness.adapter); - const options = { - requestId: "request-replay", - signal: new AbortController().signal, - title: "Run command?", - toolUseID: "tool-replay", - } satisfies Parameters[2]; - await harness.adapter.openPermission(RUN_ID, "Bash", { command: "pwd" }, options); - const before = harness.authority.length; - - await expect( - harness.adapter.openPermission(RUN_ID, replayToolName, replayInput, { - ...options, - ...optionChanges, - }), - ).rejects.toThrow("changed identity"); - expect(harness.authority).toHaveLength(before); - expect(harness.snapshot().interactions).toHaveLength(1); - }, - ); -}); diff --git a/tests/cma-http-dispatch.test.ts b/tests/cma-http-dispatch.test.ts index 3c1c3dc..54b06fa 100644 --- a/tests/cma-http-dispatch.test.ts +++ b/tests/cma-http-dispatch.test.ts @@ -68,7 +68,7 @@ function messageEvent( occurredAt: "2026-01-01T00:00:01.000Z", origin: "driver", payload, - schemaVersion: "2026-05-26", + schemaVersion: "2026-08-29", sessionId, visibility: "participant", }); diff --git a/tests/cma-http-request.test.ts b/tests/cma-http-request.test.ts index d48c761..ef69744 100644 --- a/tests/cma-http-request.test.ts +++ b/tests/cma-http-request.test.ts @@ -8,6 +8,7 @@ import { CMA_DEFAULT_BETA_HEADER_VALUE, createCmaHttpHandler, } from "../src/surfaces/cma-http"; +import { readCmaJsonBody } from "../src/surfaces/cma-http/request"; function cmaRequest(path: string, init: RequestInit = {}): Request { const headers = new Headers(init.headers); @@ -180,6 +181,21 @@ describe("CMA HTTP surface", () => { expect(canceled).toBe(true); }); + test("propagates request cancellation while reading a streaming body", async () => { + const controller = new AbortController(); + const reason = new Error("client disconnected"); + const request = new Request("https://driver.test/v1/agents", { + body: new ReadableStream({ pull() {} }), + method: "POST", + signal: controller.signal, + }); + const pending = readCmaJsonBody(request); + + controller.abort(reason); + + await expect(pending).rejects.toBe(reason); + }); + test("rejects an inbound event whose admitted record exceeds the wire limit", async () => { let dispatches = 0; const store = createCmaMemoryStore({ sessions: [{ id: "session-1" }] }); @@ -206,33 +222,43 @@ describe("CMA HTTP surface", () => { }); test("does not persist an oversized settlement result", async () => { - const store = createCmaMemoryStore({ sessions: [{ id: "session-1" }] }); + let dispatches = 0; + let now = new Date("2026-01-01T00:00:00.000Z"); + const store = createCmaMemoryStore({ + now: () => now, + sessions: [{ id: "session-1" }], + }); const handler = createCmaHttpHandler({ - dispatchDriverCommand: async () => ({ - outputText: "x".repeat(8 * CMA_MAX_EVENT_BYTES), - requestId: "request-1", - serverId: "server-1", - toolName: "tool-1", - }), + dispatchDriverCommand: async () => { + dispatches += 1; + return { + outputText: "x".repeat(8 * CMA_MAX_EVENT_BYTES), + requestId: "request-1", + serverId: "server-1", + toolName: "tool-1", + }; + }, store, }); - const response = await handler( - jsonRequest("/v1/sessions/session-1/events", "POST", { - argumentsJson: "{}", - commandId: "command-1", - requestId: "request-1", - serverId: "server-1", - toolCallId: "tool-call-1", - toolName: "tool-1", - type: "user.custom_tool_result", - }), - ); + const event = { + argumentsJson: "{}", + commandId: "command-1", + requestId: "request-1", + serverId: "server-1", + toolCallId: "tool-call-1", + toolName: "tool-1", + type: "user.custom_tool_result", + }; + const response = await handler(jsonRequest("/v1/sessions/session-1/events", "POST", event)); expect(response.status).toBe(413); expect(await readJson(response)).toMatchObject({ error: { code: "CMA_RESOURCE_LIMIT" } }); - expect(await store.listSessionEvents("session-1")).toMatchObject([ - { commandStatus: "accepted" }, - ]); + expect(await store.listSessionEvents("session-1")).toMatchObject([{ commandStatus: "failed" }]); + + now = new Date(now.getTime() + 31_000); + const retry = await handler(jsonRequest("/v1/sessions/session-1/events", "POST", event)); + expect(retry.status).toBe(502); + expect(dispatches).toBe(1); }); test("creates, lists, retrieves, archives, and deletes environments", async () => { diff --git a/tests/cma-http-sse.test.ts b/tests/cma-http-sse.test.ts index d6f943e..f002649 100644 --- a/tests/cma-http-sse.test.ts +++ b/tests/cma-http-sse.test.ts @@ -53,7 +53,7 @@ function runFailedEvent(sessionId: string, recoverable = false) { recoverable, }, runId: createDriverId(), - schemaVersion: "2026-05-26", + schemaVersion: "2026-08-29", sessionId, visibility: "participant", }); @@ -129,7 +129,7 @@ describe("CMA HTTP surface", () => { await events.return?.(); }); - test("reclaims an accepted command after its worker lease expires", async () => { + test("does not redispatch an accepted command after its worker lease expires", async () => { let now = new Date(); let dispatches = 0; const store = createCmaMemoryStore({ @@ -154,11 +154,11 @@ describe("CMA HTTP surface", () => { ); expect(response.status).toBe(202); - expect(dispatches).toBe(1); + expect(dispatches).toBe(0); expect(await readJson(response)).toMatchObject({ data: { event: { - commandStatus: "completed", + commandStatus: "accepted", id: abandoned.event.id, }, }, @@ -211,6 +211,38 @@ describe("CMA HTTP surface", () => { expect(dispatches).toBe(1); }); + test("commits a fulfilled dispatch even when the request aborts before settlement", async () => { + const controller = new AbortController(); + let dispatches = 0; + const handler = createCmaHttpHandler({ + dispatchDriverCommand: async () => { + dispatches += 1; + controller.abort(new Error("client disconnected")); + }, + store: createCmaMemoryStore({ sessions: [{ id: "session-1" }] }), + }); + const event = { commandId: "command-1", type: "user.interrupt" }; + const request = new Request("https://driver.test/v1/sessions/session-1/events", { + body: JSON.stringify(event), + headers: { + [CMA_DEFAULT_BETA_HEADER_NAME]: CMA_DEFAULT_BETA_HEADER_VALUE, + "content-type": "application/json", + }, + method: "POST", + signal: controller.signal, + }); + + const response = await handler(request); + expect(response.status).toBe(202); + expect(await readJson(response)).toMatchObject({ + data: { event: { commandStatus: "completed" } }, + }); + + const retry = await handler(jsonRequest("/v1/sessions/session-1/events", "POST", event)); + expect(retry.status).toBe(202); + expect(dispatches).toBe(1); + }); + test("joins concurrent SSE encode-error and cancel cleanup", async () => { const cleanupEntered = Promise.withResolvers(); const releaseCleanup = Promise.withResolvers(); diff --git a/tests/cma-projection.test.ts b/tests/cma-projection.test.ts index edd3c21..30b81dd 100644 --- a/tests/cma-projection.test.ts +++ b/tests/cma-projection.test.ts @@ -2,8 +2,76 @@ import { describe, expect, test } from "bun:test"; import { CmaInvalidEventError, CmaUnsupportedFieldError } from "../src/projections/cma"; import { projectCmaInboundToDriverCommand, projectDriverEventToCma } from "../src/projections/cma"; +import { ingestRuntimeEventInput } from "../src/runtime-events"; +import { createDriverId } from "../src/protocol/id"; +import type { EventId, RunId, SessionId } from "../src/protocol/id"; describe("CMA projection", () => { + test("rejects malformed Claude terminal and structured payload fields", () => { + const context = { + createId: () => createDriverId() as EventId, + occurredAt: "2026-08-12T00:00:00.000Z", + sessionId: createDriverId() as SessionId, + } as const; + + for (const input of [ + { kind: "message.failed", payload: { messageId: "message-1" } }, + { + kind: "message.failed", + payload: { error: { code: "failed" }, messageId: "message-1" }, + }, + { + kind: "message.added", + payload: { + content: [{ text: "message", type: "text" }], + messageId: "message-1", + preventContinuation: "yes", + }, + }, + { + kind: "message.added", + payload: { content: "message", messageId: "message-1", phase: "final_answer" }, + }, + { + kind: "message.added", + payload: { content: "message", memoryCitation: Symbol("bad"), messageId: "message-1" }, + }, + { + kind: "tool.call.updated", + payload: { nonExecutionKind: 1, status: "failed", toolCallId: "tool-1" }, + }, + { + kind: "tool.call.updated", + payload: { status: "completed", structuredOutput: Symbol("bad"), toolCallId: "tool-1" }, + }, + ]) { + expect(ingestRuntimeEventInput(context, input).status).toBe("rejected"); + } + }); + + test("admits message snapshot phase and memory citations", () => { + const context = { + createId: () => createDriverId() as EventId, + occurredAt: "2026-08-12T00:00:00.000Z", + sessionId: createDriverId() as SessionId, + } as const; + const memoryCitation = { + entries: [{ lineEnd: 2, lineStart: 1, path: "MEMORY.md" }], + threadIds: ["thread-1"], + }; + const result = ingestRuntimeEventInput(context, { + kind: "message.added", + payload: { content: "answer", memoryCitation, messageId: "message-1", phase: "final" }, + }); + + expect(result).toMatchObject({ + event: { + payload: { content: "answer", memoryCitation, messageId: "message-1", phase: "final" }, + }, + status: "accepted", + }); + }); + test("projects user messages to input.start commands", () => { expect( projectCmaInboundToDriverCommand({ @@ -109,7 +177,16 @@ describe("CMA projection", () => { projectDriverEventToCma({ kind: "permission.requested", payload: { + agentId: "subagent-1", + blockedPath: "/workspace/secret", + decisionReason: "Path is outside the allowed roots.", details: '{"command":"vp test"}', + description: "Read access to /workspace/secret", + matchedAskRule: { + ruleContent: "Read(/workspace/secret/**)", + source: "project", + toolName: "Read", + }, requestId: "permission-1", targetItemId: "tool-1", title: "Approve command", @@ -122,7 +199,16 @@ describe("CMA projection", () => { ).toEqual([ { requiresAction: { + agentId: "subagent-1", + blockedPath: "/workspace/secret", + decisionReason: "Path is outside the allowed roots.", details: '{"command":"vp test"}', + description: "Read access to /workspace/secret", + matchedAskRule: { + ruleContent: "Read(/workspace/secret/**)", + source: "project", + toolName: "Read", + }, requestId: "permission-1", targetItemId: "tool-1", title: "Approve command", @@ -155,6 +241,43 @@ describe("CMA projection", () => { ).toEqual([]); }); + test("projects structured final output without flattening it into text", () => { + const runId = createDriverId() as RunId; + const context = { + createId: () => createDriverId() as EventId, + occurredAt: "2026-08-13T00:00:00.000Z", + runId, + sessionId: createDriverId() as SessionId, + } as const; + const event = { + kind: "run.completed" as const, + payload: { + stopReason: "end_turn", + structuredOutput: { answer: 42, citations: ["source-1"] }, + }, + runId, + }; + + expect(ingestRuntimeEventInput(context, event).status).toBe("accepted"); + expect( + ingestRuntimeEventInput(context, { + ...event, + payload: { ...event.payload, structuredOutput: Symbol("invalid") }, + }).status, + ).toBe("rejected"); + expect(projectDriverEventToCma(event)).toEqual([ + { + metadata: { + stopReason: "end_turn", + structuredOutput: { answer: 42, citations: ["source-1"] }, + }, + sessionStatus: "idle", + sourceEventKind: "run.completed", + type: "session.status_idle", + }, + ]); + }); + test("projects driver event families to CMA outbound events", () => { expect( projectDriverEventToCma({ @@ -170,18 +293,57 @@ describe("CMA projection", () => { type: "agent.message", }, ]); + expect( + projectDriverEventToCma({ + kind: "message.failed", + payload: { error: { code: "provider.failed" }, messageId: "message-1" }, + }), + ).toMatchObject([ + { + message: { error: { code: "provider.failed" }, messageId: "message-1" }, + sourceEventKind: "message.failed", + type: "agent.message", + }, + ]); + expect( + projectDriverEventToCma({ + kind: "tool.call.updated", + payload: { + status: "completed", + structuredOutput: { + usage: { output_tokens_details: { thinking_tokens: 3 } }, + }, + toolCallId: "tool-1", + }, + }), + ).toMatchObject([ + { + message: { + structuredOutput: { + usage: { output_tokens_details: { thinking_tokens: 3 } }, + }, + }, + type: "agent.tool_use", + }, + ]); expect( projectDriverEventToCma({ kind: "run.failed", payload: { error: { code: "driver.failed", + details: { apiErrorStatus: 529, terminalReason: "api_error" }, message: "failed", }, }, }), ).toMatchObject([ { + error: expect.objectContaining({ + error: expect.objectContaining({ + details: { apiErrorStatus: 529, terminalReason: "api_error" }, + }), + }), sessionStatus: "terminated", sourceEventKind: "run.failed", type: "session.error", @@ -191,6 +353,7 @@ describe("CMA projection", () => { projectDriverEventToCma({ kind: "usage.updated", payload: { + cachedWriteTokens: 3, inputTokens: 1, outputTokens: 2, }, @@ -199,6 +362,11 @@ describe("CMA projection", () => { { sourceEventKind: "usage.updated", type: "session.usage", + usage: { + cachedWriteTokens: 3, + inputTokens: 1, + outputTokens: 2, + }, }, ]); }); diff --git a/tests/cma-sdk.test.ts b/tests/cma-sdk.test.ts index 9b83781..90198c0 100644 --- a/tests/cma-sdk.test.ts +++ b/tests/cma-sdk.test.ts @@ -9,8 +9,8 @@ import { CMA_DEFAULT_BETA_HEADER_VALUE, createCmaHttpHandler, } from "../src/surfaces/cma-http"; -import type { CmaSdkError } from "../src/surfaces/cma-sdk"; -import { createCmaSdkClient } from "../src/surfaces/cma-sdk"; +import { CmaSdkClient, type CmaSdkError } from "../src/surfaces/cma-sdk"; +import { promiseWithTimeout } from "../src/utils/async"; describe("CMA SDK client", () => { test("sends the default beta header and decodes JSON data responses", async () => { @@ -20,7 +20,7 @@ describe("CMA SDK client", () => { dispatchDriverCommand: async () => undefined, store, }); - const client = createCmaSdkClient({ + const client = new CmaSdkClient({ baseUrl: "https://driver.test", fetch: async (input, init) => { const request = new Request(input, init); @@ -47,7 +47,7 @@ describe("CMA SDK client", () => { dispatchDriverCommand: async () => undefined, store, }); - const client = createCmaSdkClient({ + const client = new CmaSdkClient({ baseUrl: "https://driver.test", fetch: async (input, init) => handler(new Request(input, init)), }); @@ -58,6 +58,90 @@ describe("CMA SDK client", () => { } satisfies Partial); }); + test.each(["client", "request"] as const)( + "propagates %s cancellation through fetch", + async (scope) => { + const controller = new AbortController(); + const entered = Promise.withResolvers(); + const reason = new Error(`${scope} cancelled`); + let fetchSignal: AbortSignal | undefined; + const client = new CmaSdkClient({ + baseUrl: "https://driver.test", + fetch: async (_input, init) => { + fetchSignal = init?.signal ?? undefined; + entered.resolve(); + return new Promise(() => {}); + }, + ...(scope === "client" ? { signal: controller.signal } : {}), + }); + const pending = client.listAgents( + scope === "request" ? { signal: controller.signal } : undefined, + ); + await entered.promise; + + controller.abort(reason); + + await expect(pending).rejects.toBe(reason); + expect(fetchSignal?.aborted).toBe(true); + }, + ); + + test("times out an unresponsive fetch", async () => { + let fetchSignal: AbortSignal | undefined; + const client = new CmaSdkClient({ + baseUrl: "https://driver.test", + fetch: async (_input, init) => { + fetchSignal = init?.signal ?? undefined; + return new Promise(() => {}); + }, + timeoutMs: 0, + }); + + await expect( + promiseWithTimeout(client.listAgents(), { + label: "CMA SDK timeout", + timeoutMs: 100, + }), + ).rejects.toMatchObject({ name: "TimeoutError" }); + expect(fetchSignal?.aborted).toBe(true); + }); + + test.each([ + ["content-length", "cooperative"], + ["content-length", "stalled"], + ["stream", "cooperative"], + ["stream", "stalled"], + ] as const)("bounds %s JSON responses with %s cancellation", async (boundary, cancellation) => { + let canceled = false; + const client = new CmaSdkClient({ + baseUrl: "https://driver.test", + fetch: async () => + new Response( + new ReadableStream({ + cancel() { + canceled = true; + return cancellation === "stalled" ? new Promise(() => {}) : undefined; + }, + start(controller) { + controller.enqueue(new TextEncoder().encode('{"data":["too large"]}')); + }, + }), + boundary === "content-length" ? { headers: { "content-length": "9" } } : undefined, + ), + maxResponseBytes: 8, + }); + + await expect( + promiseWithTimeout(client.listAgents(), { + label: "bounded CMA SDK response", + timeoutMs: 250, + }), + ).rejects.toMatchObject({ + code: "CMA_SDK_RESPONSE_TOO_LARGE", + }); + expect(canceled).toBe(true); + }); + test("streams server-sent session event replay through fetch", async () => { const sessionId = createDriverId(); const store = createCmaMemoryStore({ @@ -71,7 +155,7 @@ describe("CMA SDK client", () => { dispatchDriverCommand: async () => undefined, store, }); - const client = createCmaSdkClient({ + const client = new CmaSdkClient({ baseUrl: "https://driver.test", fetch: async (input, init) => handler(new Request(input, init)), }); @@ -88,7 +172,7 @@ describe("CMA SDK client", () => { content: "hello", messageId: "message-1", }, - schemaVersion: "2026-05-26", + schemaVersion: "2026-08-29", sessionId, visibility: "participant", }), @@ -119,7 +203,7 @@ describe("CMA SDK client", () => { dispatchDriverCommand: async () => undefined, store, }); - const client = createCmaSdkClient({ + const client = new CmaSdkClient({ baseUrl: "https://driver.test", fetch: async (input, init) => handler(new Request(input, init)), }); @@ -133,7 +217,7 @@ describe("CMA SDK client", () => { occurredAt: "2026-01-01T00:00:01.000Z", origin: "driver", payload: { messageId: "message-1" }, - schemaVersion: "2026-05-26", + schemaVersion: "2026-08-29", sessionId, visibility: "participant", }), @@ -148,14 +232,17 @@ describe("CMA SDK client", () => { occurredAt: "2026-01-01T00:00:02.000Z", origin: "driver", payload: { messageId: "message-2" }, - schemaVersion: "2026-05-26", + schemaVersion: "2026-08-29", sessionId, visibility: "participant", }), ); const resumed = []; - for await (const event of client.streamSessionEvents(sessionId, first?.cursor)) { + for await (const event of client.streamSessionEvents( + sessionId, + first ? { afterCursor: first.cursor } : {}, + )) { resumed.push(event); break; } @@ -163,6 +250,50 @@ describe("CMA SDK client", () => { expect(resumed).toEqual([second]); }); + test("does not yield buffered server-sent events after cancellation", async () => { + const controller = new AbortController(); + const record = (id: string) => ({ + command: null, + commandResult: null, + commandStatus: null, + createdAt: "2026-01-01T00:00:00.000Z", + cursor: createDriverId(), + direction: "outbound", + event: { + message: { content: id }, + sourceEventKind: "message.completed", + type: "agent.message", + }, + id, + sessionId: "session-1", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + const bytes = new TextEncoder().encode( + [record("event-1"), record("event-2")] + .map((event) => `data: ${JSON.stringify(event)}\n\n`) + .join(""), + ); + const client = new CmaSdkClient({ + baseUrl: "https://driver.test", + fetch: async () => + new Response( + new ReadableStream({ + start(stream) { + stream.enqueue(bytes); + }, + }), + ), + }); + const iterator = client + .streamSessionEvents("session-1", { signal: controller.signal }) + [Symbol.asyncIterator](); + + await expect(iterator.next()).resolves.toMatchObject({ value: { id: "event-1" } }); + const reason = new Error("stop buffered delivery"); + controller.abort(reason); + await expect(iterator.next()).rejects.toBe(reason); + }); + test.each( ["\r\n", "\r", "\n"].flatMap((lineEnding) => ["\r\n", "\r", "\n"] @@ -190,7 +321,7 @@ describe("CMA SDK client", () => { .map((event) => `data: ${JSON.stringify(event)}${lineEnding}${blankLineEnding}`) .join(""); const bytes = new TextEncoder().encode(frames); - const client = createCmaSdkClient({ + const client = new CmaSdkClient({ baseUrl: "https://driver.test", fetch: async () => new Response( @@ -241,7 +372,7 @@ describe("CMA SDK client", () => { const contentBytes = CMA_MAX_EVENT_BYTES + extraBytes - encoder.encode(emptyFrame).byteLength; const frame = `data: ${JSON.stringify(record(`界${"x".repeat(contentBytes - 3)}`))}\n\r\n`; const bytes = encoder.encode(frame); - const client = createCmaSdkClient({ + const client = new CmaSdkClient({ baseUrl: "https://driver.test", fetch: async () => new Response( @@ -285,7 +416,7 @@ describe("CMA SDK client", () => { occurredAt: "2026-01-01T00:00:01.000Z", origin: "driver", payload: { content, messageId: "message-1" }, - schemaVersion: "2026-05-26", + schemaVersion: "2026-08-29", sessionId, visibility: "participant", }); @@ -306,7 +437,7 @@ describe("CMA SDK client", () => { dispatchDriverCommand: async () => undefined, store, }); - const client = createCmaSdkClient({ + const client = new CmaSdkClient({ baseUrl: "https://driver.test", fetch: async (input, init) => handler(new Request(input, init)), }); @@ -325,7 +456,7 @@ describe("CMA SDK client", () => { let canceled = false; let sent = false; const bytes = new Uint8Array(CMA_MAX_EVENT_BYTES + 1).fill("x".charCodeAt(0)); - const client = createCmaSdkClient({ + const client = new CmaSdkClient({ baseUrl: "https://driver.test", fetch: async () => new Response( @@ -353,4 +484,54 @@ describe("CMA SDK client", () => { await expect(stream.next()).rejects.toMatchObject({ code: "CMA_SDK_FRAME_TOO_LARGE" }); expect(canceled).toBe(true); }); + + test.each(["settles", "stalls"] as const)( + "return interrupts a pending SSE read when underlying cancellation %s", + async (cancelMode) => { + const cancelEntered = Promise.withResolvers(); + const readEntered = Promise.withResolvers(); + let fetchSignal: AbortSignal | undefined; + const client = new CmaSdkClient({ + baseUrl: "https://driver.test", + fetch: async (_input, init) => { + fetchSignal = init?.signal ?? undefined; + return new Response( + new ReadableStream( + { + cancel() { + cancelEntered.resolve(); + return cancelMode === "stalls" ? new Promise(() => {}) : undefined; + }, + pull() { + readEntered.resolve(); + }, + }, + { highWaterMark: 0 }, + ), + ); + }, + }); + const iterator = client.streamSessionEvents("session-1")[Symbol.asyncIterator](); + const pending = iterator.next(); + await readEntered.promise; + + const returned = iterator.return?.(); + + if (!returned) { + throw new Error("Expected the CMA stream iterator to support return()."); + } + + await expect( + promiseWithTimeout(pending, { label: "pending CMA stream read", timeoutMs: 100 }), + ).rejects.toMatchObject({ name: "AbortError" }); + await expect( + promiseWithTimeout(returned, { label: "CMA stream return", timeoutMs: 100 }), + ).resolves.toMatchObject({ done: true }); + await promiseWithTimeout(cancelEntered.promise, { + label: "CMA stream cancellation", + timeoutMs: 100, + }); + expect(fetchSignal?.aborted).toBe(true); + }, + ); }); diff --git a/tests/cma-store-claims.test.ts b/tests/cma-store-claims.test.ts index ecae9e7..454de24 100644 --- a/tests/cma-store-claims.test.ts +++ b/tests/cma-store-claims.test.ts @@ -1,9 +1,8 @@ import { describe, expect, test } from "bun:test"; -import type { CmaInboundEvent } from "../src/projections/cma"; +import type { CmaInboundEvent, CmaProjectedDriverCommand } from "../src/projections/cma"; import { createDriverId } from "../src/protocol/id"; import { parseRuntimeEventEnvelope } from "../src/runtime-events"; -import type { RuntimeCommand } from "../src/runtime-command"; import { CmaStoreConflictError } from "../src/stores/cma-store"; import { createCmaMemoryStore } from "../src/stores/memory"; @@ -33,7 +32,7 @@ function driverEvent( origin: "driver", payload, ...(options.runId === undefined ? {} : { runId: options.runId }), - schemaVersion: "2026-05-26", + schemaVersion: "2026-08-29", sessionId, ...(options.sourceEventId === undefined ? {} : { sourceEventId: options.sourceEventId }), visibility: options.visibility ?? "participant", @@ -44,7 +43,7 @@ function interrupt( commandId: string, reason?: string, ): { - readonly command: RuntimeCommand; + readonly command: CmaProjectedDriverCommand; readonly event: CmaInboundEvent; } { return { @@ -106,7 +105,7 @@ describe("CMA memory store lifecycle", () => { ).rejects.toBeInstanceOf(CmaStoreConflictError); }); - test("reclaims expired command leases and rejects stale settlers", async () => { + test("never reclaims an expired command with an ambiguous effect", async () => { let now = new Date("2026-01-01T00:00:00.000Z"); const sessionId = createDriverId(); const store = createCmaMemoryStore({ @@ -121,14 +120,12 @@ describe("CMA memory store lifecycle", () => { } now = new Date("2026-01-01T00:00:31.000Z"); - const reclaimed = await store.claimInboundEvent(input); + const retry = await store.claimInboundEvent(input); - if (!reclaimed.claimed) { - throw new Error("Expected the expired command claim to be reclaimed."); - } - - expect(reclaimed.event.id).toBe(first.event.id); - expect(reclaimed.lease.id).not.toBe(first.lease.id); + expect(retry).toMatchObject({ + claimed: false, + event: { commandStatus: "accepted", id: first.event.id }, + }); await expect( store.settleInboundEvent({ commandId: "command-1", @@ -138,15 +135,6 @@ describe("CMA memory store lifecycle", () => { status: "completed", }), ).rejects.toBeInstanceOf(CmaStoreConflictError); - await expect( - store.settleInboundEvent({ - commandId: "command-1", - commandResult: null, - leaseId: reclaimed.lease.id, - sessionId, - status: "completed", - }), - ).resolves.toMatchObject({ commandStatus: "completed" }); }); test.each([ @@ -182,7 +170,7 @@ describe("CMA memory store lifecycle", () => { }); await expect(attempt).rejects.toBeInstanceOf(CmaStoreConflictError); - await expect(store.claimInboundEvent(input)).resolves.toMatchObject({ claimed: true }); + await expect(store.claimInboundEvent(input)).resolves.toMatchObject({ claimed: false }); }); test("keeps a completed settlement idempotent after its lease expires", async () => { diff --git a/tests/cma-store-events.test.ts b/tests/cma-store-events.test.ts index f67301d..5c77608 100644 --- a/tests/cma-store-events.test.ts +++ b/tests/cma-store-events.test.ts @@ -36,7 +36,7 @@ function driverEvent( origin: "driver", payload, ...(options.runId === undefined ? {} : { runId: options.runId }), - schemaVersion: "2026-05-26", + schemaVersion: "2026-08-29", sessionId, ...(options.sourceEventId === undefined ? {} : { sourceEventId: options.sourceEventId }), visibility: options.visibility ?? "participant", @@ -44,6 +44,49 @@ function driverEvent( } describe("CMA memory store lifecycle", () => { + test("preserves dangerous payload keys as data without changing failure semantics", async () => { + const sessionId = createDriverId(); + const runId = createDriverId(); + const sourceEventId = createDriverId(); + const store = createCmaMemoryStore({ sessions: [{ id: sessionId }] }); + const payload = JSON.parse( + '{"__proto__":{"recoverable":true},"error":{"code":"fatal","details":{},"message":"failed","retryable":false},"recoverable":false}', + ); + const event = driverEvent(sessionId, "run.failed", payload, { runId, sourceEventId }); + const eventPayload = event.payload as Record; + + expect(Object.hasOwn(eventPayload, "__proto__")).toBe(true); + expect(eventPayload["recoverable"]).toBe(false); + const first = await store.appendDriverEvent(sessionId, event); + + expect(first).toMatchObject([ + { event: { sessionStatus: "terminated", type: "session.error" } }, + ]); + await expect(store.appendDriverEvent(sessionId, event)).resolves.toEqual(first); + expect(await store.getSession(sessionId)).toMatchObject({ status: "terminated" }); + }); + + test.each([Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY])( + "rejects non-JSON error detail number %s", + (value) => { + expect(() => + driverEvent( + createDriverId(), + "run.failed", + { + error: { + code: "fatal", + details: { value }, + message: "failed", + retryable: false, + }, + }, + { runId: createDriverId() }, + ), + ).toThrow("must be JSON-serializable"); + }, + ); + test("deduplicates stable source events and rejects changed or cross-session identities", async () => { const sessionId = createDriverId(); const otherSessionId = createDriverId(); @@ -97,7 +140,10 @@ describe("CMA memory store lifecycle", () => { ), ).rejects.toBeInstanceOf(CmaStoreConflictError); await expect( - store.appendDriverEvent(sessionId, driverEvent(otherSessionId, "message.completed", {})), + store.appendDriverEvent( + sessionId, + driverEvent(otherSessionId, "message.completed", { messageId: "message-1" }), + ), ).rejects.toThrow("sessionId"); expect(await store.listSessionEvents(otherSessionId)).toHaveLength(0); }); @@ -251,10 +297,12 @@ describe("CMA memory store lifecycle", () => { expect(encodeCmaSseRecord(record)).toHaveLength(CMA_MAX_EVENT_BYTES); } else { await expect(result).rejects.toThrow("SSE event frame exceeds"); - expect(await store.listSessionEvents(sessionId)).toEqual([claim.event]); + expect(await store.listSessionEvents(sessionId)).toMatchObject([ + { commandResult: null, commandStatus: "failed" }, + ]); await expect(store.claimInboundEvent(input)).resolves.toMatchObject({ claimed: false, - event: { commandStatus: "accepted" }, + event: { commandStatus: "failed" }, }); } }, diff --git a/tests/cma-store-stream.test.ts b/tests/cma-store-stream.test.ts index 7d97327..b2a382e 100644 --- a/tests/cma-store-stream.test.ts +++ b/tests/cma-store-stream.test.ts @@ -1,9 +1,8 @@ import { describe, expect, test } from "bun:test"; -import type { CmaInboundEvent } from "../src/projections/cma"; +import type { CmaInboundEvent, CmaProjectedDriverCommand } from "../src/projections/cma"; import { createDriverId } from "../src/protocol/id"; import { parseRuntimeEventEnvelope } from "../src/runtime-events"; -import type { RuntimeCommand } from "../src/runtime-command"; import { CMA_MAX_EVENT_BYTES, CMA_MAX_REPLAY_BYTES, @@ -37,7 +36,7 @@ function driverEvent( origin: "driver", payload, ...(options.runId === undefined ? {} : { runId: options.runId }), - schemaVersion: "2026-05-26", + schemaVersion: "2026-08-29", sessionId, ...(options.sourceEventId === undefined ? {} : { sourceEventId: options.sourceEventId }), visibility: options.visibility ?? "participant", @@ -48,7 +47,7 @@ function interrupt( commandId: string, reason?: string, ): { - readonly command: RuntimeCommand; + readonly command: CmaProjectedDriverCommand; readonly event: CmaInboundEvent; } { return { diff --git a/tests/contract-executor-mutation.test.ts b/tests/contract-executor-mutation.test.ts index e5296fa..cd262dd 100644 --- a/tests/contract-executor-mutation.test.ts +++ b/tests/contract-executor-mutation.test.ts @@ -46,7 +46,7 @@ function session(id = createDriverId()): Session { function snapshot(sessionValue = session()): SessionSnapshot { return validateSessionSnapshot({ - protocolVersion: 2, + protocolVersion: 3, revision: 0, capturedAt: time, session: sessionValue, @@ -754,7 +754,7 @@ describe("contract Preview lane", () => { describe("contract closed core", () => { test("keeps Coordinator resource policies out of peer negotiation", () => { const initialize = { - protocolVersion: 2, + protocolVersion: 3, role: "observer", implementation: { name: "observer", version: "1" }, capabilities: {}, @@ -780,7 +780,7 @@ describe("contract closed core", () => { expect( initializeResultSchema.safeParse({ - protocolVersion: 2, + protocolVersion: 3, implementation: { name: "coordinator", version: "1" }, capabilities: {}, limits, diff --git a/tests/contract-invariant.test.ts b/tests/contract-invariant.test.ts index a4d47ac..cda2c38 100644 --- a/tests/contract-invariant.test.ts +++ b/tests/contract-invariant.test.ts @@ -46,7 +46,7 @@ function session(id = createDriverId()): Session { function snapshot(sessionValue = session()): SessionSnapshot { return validateSessionSnapshot({ - protocolVersion: 2, + protocolVersion: 3, revision: 0, capturedAt: time, session: sessionValue, diff --git a/tests/contract-preview.test.ts b/tests/contract-preview.test.ts index cf1b859..20392ee 100644 --- a/tests/contract-preview.test.ts +++ b/tests/contract-preview.test.ts @@ -47,7 +47,7 @@ function session(id = createDriverId()): Session { function snapshot(sessionValue = session()): SessionSnapshot { return validateSessionSnapshot({ - protocolVersion: 2, + protocolVersion: 3, revision: 0, capturedAt: time, session: sessionValue, @@ -178,7 +178,7 @@ describe("contract closed core", () => { maxSnapshotBytes: 1, }); const result = { - protocolVersion: 2, + protocolVersion: 3, implementation: { name: "coordinator", version: "1" }, capabilities: {}, limits, @@ -192,7 +192,7 @@ describe("contract closed core", () => { test("reserves the complete JSON-RPC envelope in frame limits", () => { const result = { - protocolVersion: 2, + protocolVersion: 3, implementation: { name: "coordinator", version: "1" }, capabilities: {}, limits: protocolLimits({ maxCommandBytes: 4_095, maxFrameBytes: 4_096 }), @@ -225,7 +225,7 @@ describe("contract closed core", () => { capabilities: {}, implementation: { name: "coordinator", version: "1" }, limits: protocolLimits({ maxFrameBytes, maxSnapshotBytes }), - protocolVersion: 2, + protocolVersion: 3, }); expect(initializeResultSchema.safeParse(result(maxFrameBytes - envelopeBytes)).success).toBe( diff --git a/tests/contract-projection-authority.test.ts b/tests/contract-projection-authority.test.ts deleted file mode 100644 index 1ccbced..0000000 --- a/tests/contract-projection-authority.test.ts +++ /dev/null @@ -1,692 +0,0 @@ -import { expect, test } from "bun:test"; - -import { AuthorityOutcomeUnknownError, interactionSchema, itemSchema } from "../src/contract"; -import type { Item, Run } from "../src/contract"; -import { isDriverId } from "../src/protocol/id"; -import { - ContractProjection, - type ContractAuthorityUpdate, -} from "../src/runtimes/contract-projection"; - -function protocolId(value: number): string { - return value.toString().padStart(26, "0"); -} - -const SESSION_ID = protocolId(1); -const RUN_ID = protocolId(2); -const INTERACTION_ID = protocolId(3); - -function activeRun(startedAt: string, input = true): Run { - return { - id: RUN_ID, - input: input ? [{ text: "hello", type: "text" }] : [], - origin: input ? "user" : "system", - startedAt, - status: "active", - }; -} - -function activeMessage(id: string, timestamp: string): Extract { - return itemSchema.parse({ - audience: "participants", - content: [], - createdAt: timestamp, - id, - kind: "message", - role: "agent", - runId: RUN_ID, - status: "active", - updatedAt: timestamp, - }) as Extract; -} - -test.each([Number.NaN, 1.5])("Contract projection rejects invalid limit %p", (value) => { - expect( - () => - new ContractProjection({ - authority: async () => {}, - preview: () => {}, - previewCheckpointBytes: value, - sessionId: SESSION_ID, - }), - ).toThrow("finite and positive"); -}); - -test.each(["append", "replace"] as const)( - "Contract projection treats a throwing Preview callback as best-effort for %s", - async (mode) => { - const timestamp = "2026-07-16T08:00:00.000Z"; - let previews = 0; - const projection = new ContractProjection({ - authority: async () => {}, - preview: () => { - previews += 1; - throw new Error("subscriber failed"); - }, - sessionId: SESSION_ID, - }); - projection.attachRun(activeRun(timestamp)); - const item = - mode === "append" - ? activeMessage("item-1", timestamp) - : itemSchema.parse({ - audience: "participants", - createdAt: timestamp, - id: "item-1", - kind: "terminal", - runId: RUN_ID, - status: "active", - stderr: [], - stdout: [], - updatedAt: timestamp, - }); - await projection.putItem(RUN_ID, "item/started", { name: "item", type: "system" }, item); - - if (mode === "append") { - await expect( - projection.appendText({ - cause: { providerEventId: "delta-1", type: "provider" }, - channel: "message.text", - delta: "x", - event: "message/delta", - itemId: item.id, - runId: RUN_ID, - }), - ).resolves.toBeUndefined(); - expect(projection.materializedText(RUN_ID, item.id, "message.text")).toBe("x"); - } else { - await expect( - projection.replacePreview({ - channel: "terminal.stdout", - itemId: item.id, - runId: RUN_ID, - text: "x", - }), - ).resolves.toBeUndefined(); - expect(projection.materializedText(RUN_ID, item.id, "terminal.stdout")).toBe("x"); - } - - expect(previews).toBe(1); - }, -); - -test.each(["item", "interaction", "run"] as const)( - "Contract projection isolates an in-flight Authority %s submission", - async (entity) => { - const entered = Promise.withResolvers(); - const release = Promise.withResolvers(); - const timestamp = "2026-07-16T08:00:00.000Z"; - let captured: ContractAuthorityUpdate | undefined; - const projection = new ContractProjection({ - authority: async (update) => { - captured = update; - entered.resolve(); - await release.promise; - }, - preview: () => {}, - sessionId: SESSION_ID, - }); - projection.attachRun(activeRun(timestamp)); - const interaction = interactionSchema.parse({ - audience: "participants", - blocking: true, - createdAt: timestamp, - expiresAt: "2026-07-16T08:05:00.000Z", - id: INTERACTION_ID, - kind: "permission", - request: { - options: [{ effect: "deny", id: "deny", label: "Deny", scope: "once" }], - subject: { operation: "execute", targets: ["workspace"], type: "resource" }, - title: "Run command?", - }, - runId: RUN_ID, - status: "open", - }); - const write = - entity === "item" - ? projection.putItem( - RUN_ID, - "item/put", - { name: "item", type: "system" }, - { - ...activeMessage("message-1", timestamp), - content: [ - { text: "safe", type: "text" }, - { data: "c2FmZQ==", mediaType: "text/plain", type: "inline_blob" }, - ], - }, - ) - : entity === "interaction" - ? projection.putInteraction( - RUN_ID, - "interaction/put", - { name: "interaction", type: "system" }, - interaction, - ) - : projection.updateUsage( - RUN_ID, - "run/usage", - { name: "usage", type: "system" }, - { cost: { amount: 1, currency: "USD" }, input: 1, total: 1 }, - ); - await entered.promise; - const corrupt = () => { - const operation = captured?.operations[0]; - - if (operation?.op !== "put") { - throw new Error("Expected a put operation."); - } - - if (operation.entity === "item" && operation.value.kind === "message") { - operation.value.role = "user"; - const text = operation.value.content[0]; - const blob = operation.value.content[1]; - if (text?.type === "text") { - text.text = "mutated"; - } - if (blob?.type === "inline_blob") { - blob.data = "bXV0YXRlZA=="; - } - } else if (operation.entity === "interaction" && operation.value.kind === "permission") { - operation.value.request.title = "Mutated"; - const option = operation.value.request.options[0]; - if (option !== undefined) { - option.label = "Mutated"; - } - } else if (operation.entity === "run" && operation.value.usage !== undefined) { - operation.value.usage.input = 999; - if (operation.value.usage.cost !== undefined) { - operation.value.usage.cost.amount = 999; - } - } - }; - corrupt(); - release.resolve(); - await write; - corrupt(); - - if (entity === "item") { - expect(projection.item(RUN_ID, "message-1")).toMatchObject({ - content: [ - { text: "safe", type: "text" }, - { data: "c2FmZQ==", type: "inline_blob" }, - ], - role: "agent", - }); - } else if (entity === "interaction") { - expect(projection.interaction(INTERACTION_ID)).toMatchObject({ - request: { options: [{ label: "Deny" }], title: "Run command?" }, - }); - } else { - expect(projection.run(RUN_ID)?.usage).toEqual({ - cost: { amount: 1, currency: "USD" }, - input: 1, - total: 1, - }); - } - }, -); - -test("Contract projection keeps its private Authority intent across a mutated unknown submission", async () => { - const writes: ContractAuthorityUpdate[] = []; - const timestamp = "2026-07-16T08:00:00.000Z"; - const projection = new ContractProjection({ - authority: async (update) => { - writes.push(update); - if (writes.length === 1) { - const operation = update.operations[0]; - if (operation?.op === "put" && operation.entity === "item") { - operation.value.updatedAt = "2026-07-16T08:00:01.000Z"; - } - throw new AuthorityOutcomeUnknownError("result lost"); - } - }, - preview: () => {}, - sessionId: SESSION_ID, - }); - projection.attachRun(activeRun(timestamp)); - const item = activeMessage("message-1", timestamp); - const write = () => - projection.putItem(RUN_ID, "item/put", { name: "item", type: "system" }, item); - - await expect(write()).rejects.toThrow("result lost"); - await write(); - - expect(writes[1]?.mutationId).toBe(writes[0]?.mutationId); - expect(writes[1]?.operations).toMatchObject([ - { entity: "item", op: "put", value: { updatedAt: timestamp } }, - ]); - expect(projection.item(RUN_ID, item.id)?.updatedAt).toBe(timestamp); -}); - -test("Contract projection disposal preserves an in-flight result and fences queued writes", async () => { - const writeEntered = Promise.withResolvers(); - const releaseWrite = Promise.withResolvers(); - const events: string[] = []; - const projection = new ContractProjection({ - authority: async ({ event }) => { - events.push(event); - writeEntered.resolve(); - await releaseWrite.promise; - }, - preview: () => {}, - sessionId: SESSION_ID, - }); - projection.attachRun(activeRun("2026-07-16T08:00:00.000Z")); - const write = projection.putItem( - RUN_ID, - "message/started", - { providerEventId: "message-1", type: "provider" }, - itemSchema.parse({ - audience: "participants", - content: [], - createdAt: "2026-07-16T08:00:00.000Z", - id: "message-1", - kind: "message", - role: "agent", - runId: RUN_ID, - status: "active", - updatedAt: "2026-07-16T08:00:00.000Z", - }), - ); - await writeEntered.promise; - const queued = projection.putItem( - RUN_ID, - "message/queued", - { providerEventId: "message-2", type: "provider" }, - activeMessage("message-2", "2026-07-16T08:00:00.000Z"), - ); - const queuedResult = queued.then( - () => ({ status: "fulfilled" as const }), - (reason: unknown) => ({ reason, status: "rejected" as const }), - ); - - projection.dispose(); - projection.dispose(); - expect( - await Promise.race([ - queuedResult, - new Promise((resolve) => setTimeout(() => resolve({ status: "pending" }), 10)), - ]), - ).toMatchObject({ reason: { message: "Contract projection is disposed." }, status: "rejected" }); - releaseWrite.resolve(); - - await expect(write).resolves.toMatchObject({ id: "message-1" }); - expect(await queuedResult).toMatchObject({ status: "rejected" }); - expect(events).toEqual(["message/started"]); - expect(() => projection.run(RUN_ID)).toThrow("disposed"); -}); - -test("Contract projection preserves an in-flight unknown Authority result after disposal", async () => { - const writeEntered = Promise.withResolvers(); - const releaseWrite = Promise.withResolvers(); - const projection = new ContractProjection({ - authority: async () => { - writeEntered.resolve(); - await releaseWrite.promise; - throw new AuthorityOutcomeUnknownError("result lost after disposal"); - }, - preview: () => {}, - sessionId: SESSION_ID, - }); - projection.attachRun(activeRun("2026-07-16T08:00:00.000Z")); - const write = projection.putItem( - RUN_ID, - "message/started", - { providerEventId: "message-1", type: "provider" }, - activeMessage("message-1", "2026-07-16T08:00:00.000Z"), - ); - await writeEntered.promise; - projection.dispose(); - releaseWrite.resolve(); - - await expect(write).rejects.toMatchObject({ - message: "result lost after disposal", - name: "AuthorityOutcomeUnknownError", - }); -}); - -test("Contract projection bounds and releases its queued mutation count", async () => { - const writeEntered = Promise.withResolvers(); - const releaseWrite = Promise.withResolvers(); - const timestamp = "2026-07-16T08:00:00.000Z"; - const projection = new ContractProjection({ - authority: async ({ event }) => { - if (event === "message/active") { - writeEntered.resolve(); - await releaseWrite.promise; - } - }, - preview: () => {}, - sessionId: SESSION_ID, - }); - projection.attachRun(activeRun(timestamp)); - const active = projection.putItem( - RUN_ID, - "message/active", - { name: "active", type: "system" }, - activeMessage("active", timestamp), - ); - await writeEntered.promise; - const queued = Array.from({ length: 1_023 }, (_, index) => - projection.putItem( - RUN_ID, - "message/queued", - { name: `queued-${index}`, type: "system" }, - activeMessage(`queued-${index}`, timestamp), - ), - ); - const queuedResults = Promise.allSettled(queued); - - await expect( - projection.putItem( - RUN_ID, - "message/overflow", - { name: "overflow", type: "system" }, - activeMessage("overflow", timestamp), - ), - ).rejects.toThrow("1024 entries"); - projection.dispose(); - expect((await queuedResults).every((result) => result.status === "rejected")).toBe(true); - releaseWrite.resolve(); - await expect(active).resolves.toMatchObject({ id: "active" }); -}); - -test("Contract projection bounds queued mutation UTF-8 bytes", async () => { - const writeEntered = Promise.withResolvers(); - const releaseWrite = Promise.withResolvers(); - const timestamp = "2026-07-16T08:00:00.000Z"; - const projection = new ContractProjection({ - authority: async ({ event }) => { - if (event === "message/active") { - writeEntered.resolve(); - await releaseWrite.promise; - } - }, - preview: () => {}, - sessionId: SESSION_ID, - }); - projection.attachRun(activeRun(timestamp)); - await projection.putItem( - RUN_ID, - "message/started", - { name: "started", type: "system" }, - activeMessage("message-1", timestamp), - ); - const active = projection.putItem( - RUN_ID, - "message/active", - { name: "active", type: "system" }, - activeMessage("message-2", timestamp), - ); - await writeEntered.promise; - - await expect( - projection.appendText({ - cause: { providerEventId: "large-delta", type: "provider" }, - channel: "message.text", - delta: "x".repeat(32 * 1_024 * 1_024), - event: "message/delta", - itemId: "message-1", - runId: RUN_ID, - }), - ).rejects.toThrow("33554432 UTF-8 bytes"); - releaseWrite.resolve(); - await active; -}); - -test("Contract projection gives every Authority write a protocol mutation ID", async () => { - const mutationIds: string[] = []; - const projection = new ContractProjection({ - authority: async ({ mutationId }) => { - mutationIds.push(mutationId); - }, - preview: () => {}, - sessionId: SESSION_ID, - }); - projection.attachRun(activeRun("2026-07-16T08:00:00.000Z")); - - await projection.putItem( - RUN_ID, - "message/started", - { providerEventId: "message-1", type: "provider" }, - activeMessage("message-1", "2026-07-16T08:00:00.000Z"), - ); - - expect(mutationIds).toHaveLength(1); - expect(isDriverId(mutationIds[0])).toBe(true); -}); - -test("Contract projection reuses a mutation ID after an ambiguous Authority failure", async () => { - const mutationIds: string[] = []; - const projection = new ContractProjection({ - authority: async ({ mutationId }) => { - mutationIds.push(mutationId); - - if (mutationIds.length === 1) { - throw new AuthorityOutcomeUnknownError("response lost after commit"); - } - }, - preview: () => {}, - sessionId: SESSION_ID, - }); - projection.attachRun(activeRun("2026-07-16T08:00:00.000Z")); - const item = activeMessage("message-1", "2026-07-16T08:00:00.000Z"); - const write = () => - projection.putItem( - RUN_ID, - "message/started", - { providerEventId: "message-1", type: "provider" }, - item, - ); - - await expect(write()).rejects.toThrow("response lost"); - await expect( - projection.putItem( - RUN_ID, - "message/started", - { providerEventId: "message-1", type: "provider" }, - { ...item, content: [{ text: "changed", type: "text" }] }, - ), - ).rejects.toThrow("changed while its outcome was unknown"); - await projection.putItem( - RUN_ID, - "message/started", - { type: "provider", providerEventId: "message-1" }, - item, - ); - await write(); - - expect(mutationIds).toEqual([mutationIds[0], mutationIds[0]]); - expect(projection.item(RUN_ID, item.id)).toEqual(item); -}); - -test("Contract projection retries a derived terminal intent without changing it", async () => { - let nowMs = Date.parse("2026-07-16T08:00:00.000Z"); - const writes: ContractAuthorityUpdate[] = []; - const projection = new ContractProjection({ - authority: async (update) => { - writes.push(update); - - if (writes.length === 1) { - throw new AuthorityOutcomeUnknownError("response lost after commit"); - } - }, - now: () => new Date(nowMs), - preview: () => {}, - sessionId: SESSION_ID, - }); - projection.attachRun(activeRun(new Date(nowMs).toISOString())); - const finish = () => - projection.finishRun({ - cause: { providerEventId: "turn-1", type: "provider" }, - event: "turn/completed", - runId: RUN_ID, - status: "completed", - }); - - await expect(finish()).rejects.toThrow("response lost"); - nowMs += 1; - await finish(); - - expect(writes.map((write) => write.mutationId)).toEqual([ - writes[0]?.mutationId, - writes[0]?.mutationId, - ]); - expect(writes[1]?.operations).toEqual(writes[0]?.operations); -}); - -test("Contract projection retries a checkpoint with its first timestamp", async () => { - let nowMs = Date.parse("2026-07-16T08:00:00.000Z"); - const writes: ContractAuthorityUpdate[] = []; - const projection = new ContractProjection({ - authority: async (update) => { - if (update.event === "message/delta.checkpoint") { - writes.push(update); - - if (writes.length === 1) { - throw new AuthorityOutcomeUnknownError("response lost after commit"); - } - } - }, - now: () => new Date(nowMs), - preview: () => {}, - previewCheckpointBytes: 1, - sessionId: SESSION_ID, - }); - projection.attachRun(activeRun(new Date(nowMs).toISOString())); - await projection.putItem( - RUN_ID, - "message/started", - { name: "start", type: "system" }, - activeMessage("message-1", new Date(nowMs).toISOString()), - ); - const append = () => - projection.appendText({ - cause: { providerEventId: "delta-1", type: "provider" }, - channel: "message.text", - delta: "x", - event: "message/delta", - itemId: "message-1", - runId: RUN_ID, - }); - - await expect(append()).rejects.toThrow("response lost"); - nowMs += 1; - await append(); - - expect(writes[1]?.mutationId).toBe(writes[0]?.mutationId); - expect(writes[1]?.operations).toEqual(writes[0]?.operations); - expect(projection.item(RUN_ID, "message-1")).toMatchObject({ - content: [{ text: "x", type: "text" }], - }); -}); - -test("Contract projection serializes Authority writes and local state", async () => { - const firstEntered = Promise.withResolvers(); - const releaseFirst = Promise.withResolvers(); - const events: string[] = []; - const projection = new ContractProjection({ - authority: async ({ event }) => { - events.push(event); - if (event === "first") { - firstEntered.resolve(); - await releaseFirst.promise; - } - }, - preview: () => {}, - sessionId: SESSION_ID, - }); - const timestamp = "2026-07-16T08:00:00.000Z"; - projection.attachRun(activeRun(timestamp)); - const first = projection.putItem( - RUN_ID, - "first", - { type: "system", name: "first" }, - { ...activeMessage("message-1", timestamp), content: [{ text: "first", type: "text" }] }, - ); - await firstEntered.promise; - const second = projection.putItem( - RUN_ID, - "second", - { type: "system", name: "second" }, - { ...activeMessage("message-1", timestamp), content: [{ text: "second", type: "text" }] }, - ); - await Promise.resolve(); - - expect(events).toEqual(["first"]); - releaseFirst.resolve(); - await Promise.all([first, second]); - - expect(events).toEqual(["first", "second"]); - expect(projection.item(RUN_ID, "message-1")).toMatchObject({ - content: [{ text: "second", type: "text" }], - }); -}); - -test.each([ - ["a queued checkpoint", "b", "cd", ["ab", "abcd"]], - ["a queued Preview append", "bc", "d", ["abc"]], -] as const)( - "Contract projection preserves text across %s", - async (_name, firstDelta, followerDelta, expectedCheckpoints) => { - const checkpointEntered = Promise.withResolvers(); - const releaseCheckpoint = Promise.withResolvers(); - const checkpoints: string[] = []; - const timestamp = "2026-07-16T08:00:00.000Z"; - const projection = new ContractProjection({ - authority: async ({ event, operations }) => { - if (!event.endsWith(".checkpoint")) { - return; - } - - const operation = operations[0]; - if (operation?.op === "put" && operation.entity === "item") { - checkpoints.push( - operation.value.kind === "message" - ? operation.value.content - .flatMap((block) => (block.type === "text" ? [block.text] : [])) - .join("") - : "", - ); - } - - if (checkpoints.length === 1) { - checkpointEntered.resolve(); - await releaseCheckpoint.promise; - } - }, - now: () => new Date(timestamp), - preview: () => {}, - previewCheckpointBytes: 2, - sessionId: SESSION_ID, - }); - projection.attachRun(activeRun(timestamp)); - await projection.putItem( - RUN_ID, - "message/started", - { name: "start", type: "system" }, - activeMessage("message-1", timestamp), - ); - const append = (delta: string, event: string) => - projection.appendText({ - cause: { providerEventId: event, type: "provider" }, - channel: "message.text", - delta, - event, - itemId: "message-1", - runId: RUN_ID, - }); - await append("a", "delta-a"); - const first = append(firstDelta, "delta-first"); - await checkpointEntered.promise; - const follower = append(followerDelta, "delta-follower"); - releaseCheckpoint.resolve(); - await Promise.all([first, follower]); - - expect(checkpoints).toEqual(expectedCheckpoints); - expect(projection.materializedText(RUN_ID, "message-1", "message.text")).toBe( - `a${firstDelta}${followerDelta}`, - ); - }, -); diff --git a/tests/contract-projection-preview.test.ts b/tests/contract-projection-preview.test.ts deleted file mode 100644 index 907a879..0000000 --- a/tests/contract-projection-preview.test.ts +++ /dev/null @@ -1,662 +0,0 @@ -import { expect, test } from "bun:test"; - -import { AuthorityOutcomeUnknownError, interactionSchema, itemSchema } from "../src/contract"; -import type { Item, Run } from "../src/contract"; -import { - ContractProjection, - type ContractAuthorityUpdate, -} from "../src/runtimes/contract-projection"; - -function protocolId(value: number): string { - return value.toString().padStart(26, "0"); -} - -const SESSION_ID = protocolId(1); -const RUN_ID = protocolId(2); -const INTERACTION_ID = protocolId(3); - -type FinishRunInput = Parameters[0]; - -function activeRun(startedAt: string, input = true): Run { - return { - id: RUN_ID, - input: input ? [{ text: "hello", type: "text" }] : [], - origin: input ? "user" : "system", - startedAt, - status: "active", - }; -} - -function activeMessage(id: string, timestamp: string): Extract { - return itemSchema.parse({ - audience: "participants", - content: [], - createdAt: timestamp, - id, - kind: "message", - role: "agent", - runId: RUN_ID, - status: "active", - updatedAt: timestamp, - }) as Extract; -} - -test("Contract projection keeps Preview appended while checkpointText awaits Authority", async () => { - const checkpointEntered = Promise.withResolvers(); - const releaseCheckpoint = Promise.withResolvers(); - const timestamp = "2026-07-16T08:00:00.000Z"; - const projection = new ContractProjection({ - authority: async ({ event }) => { - if (event === "message/checkpoint") { - checkpointEntered.resolve(); - await releaseCheckpoint.promise; - } - }, - now: () => new Date(timestamp), - preview: () => {}, - sessionId: SESSION_ID, - }); - projection.attachRun(activeRun(timestamp)); - await projection.putItem( - RUN_ID, - "message/started", - { name: "start", type: "system" }, - activeMessage("message-1", timestamp), - ); - const append = (delta: string, event: string) => - projection.appendText({ - cause: { providerEventId: event, type: "provider" }, - channel: "message.text", - delta, - event, - itemId: "message-1", - runId: RUN_ID, - }); - await append("a", "delta-a"); - const checkpoint = projection.checkpointText({ - cause: { providerEventId: "checkpoint-a", type: "provider" }, - channel: "message.text", - event: "message/checkpoint", - itemId: "message-1", - runId: RUN_ID, - }); - await checkpointEntered.promise; - let appendSettled = false; - const follower = append("b", "delta-b").finally(() => { - appendSettled = true; - }); - await Promise.resolve(); - const settledBeforeCheckpoint = appendSettled; - releaseCheckpoint.resolve(); - await Promise.all([checkpoint, follower]); - - expect(settledBeforeCheckpoint).toBe(false); - expect(projection.materializedText(RUN_ID, "message-1", "message.text")).toBe("ab"); -}); - -test("Contract projection rejects Preview followers until an unknown checkpoint is retried", async () => { - const checkpointEntered = Promise.withResolvers(); - const rejectCheckpoint = Promise.withResolvers(); - const timestamp = "2026-07-16T08:00:00.000Z"; - let attempts = 0; - const projection = new ContractProjection({ - authority: async ({ event }) => { - if (event === "message/checkpoint" && ++attempts === 1) { - checkpointEntered.resolve(); - await rejectCheckpoint.promise; - throw new AuthorityOutcomeUnknownError("checkpoint result lost"); - } - }, - now: () => new Date(timestamp), - preview: () => {}, - sessionId: SESSION_ID, - }); - projection.attachRun(activeRun(timestamp)); - await projection.putItem( - RUN_ID, - "message/started", - { name: "start", type: "system" }, - activeMessage("message-1", timestamp), - ); - const append = (delta: string, event: string) => - projection.appendText({ - cause: { providerEventId: event, type: "provider" }, - channel: "message.text", - delta, - event, - itemId: "message-1", - runId: RUN_ID, - }); - const checkpoint = () => - projection.checkpointText({ - cause: { providerEventId: "checkpoint-a", type: "provider" }, - channel: "message.text", - event: "message/checkpoint", - itemId: "message-1", - runId: RUN_ID, - }); - await append("a", "delta-a"); - const first = checkpoint(); - await checkpointEntered.promise; - const follower = append("b", "delta-b"); - const results = Promise.allSettled([first, follower]); - rejectCheckpoint.resolve(); - - const [checkpointResult, followerResult] = await results; - expect(checkpointResult).toMatchObject({ - reason: { message: "checkpoint result lost" }, - status: "rejected", - }); - expect(followerResult).toMatchObject({ status: "rejected" }); - expect(followerResult.status === "rejected" ? followerResult.reason : undefined).toBeInstanceOf( - AuthorityOutcomeUnknownError, - ); - await checkpoint(); - await append("b", "delta-b"); - - expect(projection.materializedText(RUN_ID, "message-1", "message.text")).toBe("ab"); -}); - -test("Contract projection derives finishRun after an in-flight Item update", async () => { - const updateEntered = Promise.withResolvers(); - const releaseUpdate = Promise.withResolvers(); - const writes: ContractAuthorityUpdate[] = []; - const timestamp = "2026-07-16T08:00:00.000Z"; - const projection = new ContractProjection({ - authority: async (update) => { - writes.push(update); - if (update.event === "message/updated") { - updateEntered.resolve(); - await releaseUpdate.promise; - } - }, - now: () => new Date(timestamp), - preview: () => {}, - sessionId: SESSION_ID, - }); - projection.attachRun(activeRun(timestamp)); - await projection.putItem( - RUN_ID, - "message/started", - { name: "start", type: "system" }, - activeMessage("message-1", timestamp), - ); - const updated = projection.putItem( - RUN_ID, - "message/updated", - { name: "update", type: "system" }, - { - ...activeMessage("message-1", timestamp), - content: [{ text: "latest", type: "text" }], - }, - ); - await updateEntered.promise; - const finished = projection.finishRun({ - cause: { name: "finish", type: "system" }, - event: "run/finished", - runId: RUN_ID, - status: "completed", - }); - releaseUpdate.resolve(); - await Promise.all([updated, finished]); - - const finishItem = writes - .find((write) => write.event === "run/finished") - ?.operations.find((operation) => operation.op === "put" && operation.entity === "item"); - expect(finishItem?.value).toMatchObject({ - content: [{ text: "latest", type: "text" }], - status: "completed", - }); -}); - -test("Contract projection derives finishRun after an in-flight Interaction", async () => { - const interactionEntered = Promise.withResolvers(); - const releaseInteraction = Promise.withResolvers(); - const writes: ContractAuthorityUpdate[] = []; - const timestamp = "2026-07-16T08:00:00.000Z"; - const projection = new ContractProjection({ - authority: async (update) => { - writes.push(update); - if (update.event === "permission/requested") { - interactionEntered.resolve(); - await releaseInteraction.promise; - } - }, - now: () => new Date(timestamp), - preview: () => {}, - sessionId: SESSION_ID, - }); - projection.attachRun(activeRun(timestamp)); - const opened = projection.putInteraction( - RUN_ID, - "permission/requested", - { providerEventId: "permission-1", type: "provider" }, - interactionSchema.parse({ - audience: "participants", - blocking: true, - createdAt: timestamp, - expiresAt: "2026-07-16T08:05:00.000Z", - id: INTERACTION_ID, - kind: "permission", - request: { - options: [{ effect: "deny", id: "deny", label: "Deny", scope: "once" }], - subject: { operation: "execute", targets: ["workspace"], type: "resource" }, - title: "Run command?", - }, - runId: RUN_ID, - status: "open", - }), - ); - await interactionEntered.promise; - const finished = projection.finishRun({ - cause: { name: "finish", type: "system" }, - event: "run/finished", - runId: RUN_ID, - status: "completed", - }); - releaseInteraction.resolve(); - await Promise.all([opened, finished]); - - expect( - writes - .find((write) => write.event === "run/finished") - ?.operations.find((operation) => operation.op === "put" && operation.entity === "interaction") - ?.value, - ).toMatchObject({ id: INTERACTION_ID, status: "expired" }); -}); - -test("Contract projection derives finishRun after an in-flight text checkpoint", async () => { - const checkpointEntered = Promise.withResolvers(); - const releaseCheckpoint = Promise.withResolvers(); - const writes: ContractAuthorityUpdate[] = []; - const timestamp = "2026-07-16T08:00:00.000Z"; - const projection = new ContractProjection({ - authority: async (update) => { - writes.push(update); - if (update.event === "message/delta.checkpoint") { - checkpointEntered.resolve(); - await releaseCheckpoint.promise; - } - }, - now: () => new Date(timestamp), - preview: () => {}, - previewCheckpointBytes: 2, - sessionId: SESSION_ID, - }); - projection.attachRun(activeRun(timestamp)); - await projection.putItem( - RUN_ID, - "message/started", - { name: "start", type: "system" }, - activeMessage("message-1", timestamp), - ); - const append = (delta: string) => - projection.appendText({ - cause: { providerEventId: `delta-${delta}`, type: "provider" }, - channel: "message.text", - delta, - event: "message/delta", - itemId: "message-1", - runId: RUN_ID, - }); - await append("a"); - const checkpoint = append("b"); - await checkpointEntered.promise; - const finished = projection.finishRun({ - cause: { name: "finish", type: "system" }, - event: "run/finished", - runId: RUN_ID, - status: "completed", - }); - releaseCheckpoint.resolve(); - await Promise.all([checkpoint, finished]); - - const finishItem = writes - .find((write) => write.event === "run/finished") - ?.operations.find((operation) => operation.op === "put" && operation.entity === "item"); - expect(finishItem?.value).toMatchObject({ status: "completed" }); - expect( - finishItem?.value.kind === "message" - ? finishItem.value.content - .flatMap((block) => (block.type === "text" ? [block.text] : [])) - .join("") - : undefined, - ).toBe("ab"); -}); - -test.each([ - ["status", { status: "completed" }, { status: "failed" }], - [ - "error", - { - error: { code: "first", message: "first", retryable: false }, - status: "failed", - }, - { - error: { code: "second", message: "second", retryable: false }, - status: "failed", - }, - ], - [ - "terminalItems", - { status: "completed", terminalItems: [] }, - { - status: "completed", - terminalItems: [ - itemSchema.parse({ - ...activeMessage("message-1", "2026-07-16T08:00:00.000Z"), - endedAt: "2026-07-16T08:00:01.000Z", - status: "completed", - updatedAt: "2026-07-16T08:00:01.000Z", - }), - ], - }, - ], - [ - "activeItemStatus", - { activeItemStatus: "completed", status: "completed" }, - { activeItemStatus: "cancelled", status: "completed" }, - ], -] satisfies readonly [string, Partial, Partial][])( - "Contract projection rejects changed finishRun %s after an unknown result", - async (_name, firstPatch, changedPatch) => { - const writes: ContractAuthorityUpdate[] = []; - const timestamp = "2026-07-16T08:00:00.000Z"; - const projection = new ContractProjection({ - authority: async (update) => { - writes.push(update); - if (writes.length === 1) { - throw new AuthorityOutcomeUnknownError("finish result lost"); - } - }, - now: () => new Date(timestamp), - preview: () => {}, - sessionId: SESSION_ID, - }); - projection.attachRun(activeRun(timestamp)); - const base = { - cause: { providerEventId: "finish-1", type: "provider" as const }, - event: "run/finished", - runId: RUN_ID, - }; - const first = { ...base, ...firstPatch } as FinishRunInput; - const changed = { ...base, ...changedPatch } as FinishRunInput; - - await expect(projection.finishRun(first)).rejects.toThrow("finish result lost"); - await expect(projection.finishRun(changed)).rejects.toThrow( - "changed while its outcome was unknown", - ); - await projection.finishRun(first); - - expect(writes).toHaveLength(2); - expect(writes[1]?.mutationId).toBe(writes[0]?.mutationId); - expect(writes[1]?.operations).toEqual(writes[0]?.operations); - }, -); - -test("Contract projection fail-stops writes behind an unresolved Authority outcome", async () => { - const firstEntered = Promise.withResolvers(); - const rejectFirst = Promise.withResolvers(); - const events: string[] = []; - let firstAttempts = 0; - const projection = new ContractProjection({ - authority: async ({ event }) => { - events.push(event); - if (event === "first" && ++firstAttempts === 1) { - firstEntered.resolve(); - await rejectFirst.promise; - throw new AuthorityOutcomeUnknownError("response lost after commit"); - } - }, - preview: () => {}, - sessionId: SESSION_ID, - }); - const timestamp = "2026-07-16T08:00:00.000Z"; - projection.attachRun(activeRun(timestamp)); - const firstItem = { - ...activeMessage("message-1", timestamp), - content: [{ text: "first", type: "text" as const }], - }; - const secondItem = { - ...activeMessage("message-1", timestamp), - content: [{ text: "second", type: "text" as const }], - }; - const first = projection.putItem(RUN_ID, "first", { name: "first", type: "system" }, firstItem); - await firstEntered.promise; - const second = projection.putItem( - RUN_ID, - "second", - { name: "second", type: "system" }, - secondItem, - ); - const failures = Promise.allSettled([first, second]); - rejectFirst.resolve(); - - expect(await failures).toMatchObject([ - { reason: { message: "response lost after commit" }, status: "rejected" }, - { reason: { message: "response lost after commit" }, status: "rejected" }, - ]); - await expect( - projection.putItem( - RUN_ID, - "second", - { name: "second", type: "system" }, - { ...secondItem, content: [{ text: "changed", type: "text" }] }, - ), - ).rejects.toBeInstanceOf(AuthorityOutcomeUnknownError); - expect(events).toEqual(["first"]); - - await projection.putItem(RUN_ID, "first", { name: "first", type: "system" }, firstItem); - await projection.putItem(RUN_ID, "second", { name: "second", type: "system" }, secondItem); - - expect(events).toEqual(["first", "first", "second"]); - expect(projection.item(RUN_ID, "message-1")).toEqual(secondItem); -}); - -test("Contract projection lets an exact retry pass queued followers", async () => { - const firstEntered = Promise.withResolvers(); - const rejectFirst = Promise.withResolvers(); - const events: string[] = []; - let attempts = 0; - const projection = new ContractProjection({ - authority: async ({ event }) => { - events.push(event); - if (event === "first" && ++attempts === 1) { - firstEntered.resolve(); - await rejectFirst.promise; - throw new AuthorityOutcomeUnknownError("result lost"); - } - }, - preview: () => {}, - sessionId: SESSION_ID, - }); - const timestamp = "2026-07-16T08:00:00.000Z"; - const firstItem = activeMessage("message-1", timestamp); - const secondItem = activeMessage("message-2", timestamp); - projection.attachRun(activeRun(timestamp)); - const first = projection.putItem(RUN_ID, "first", { name: "first", type: "system" }, firstItem); - await firstEntered.promise; - const follower = projection.putItem( - RUN_ID, - "second", - { name: "second", type: "system" }, - secondItem, - ); - const retry = first.catch(() => - projection.putItem(RUN_ID, "first", { name: "first", type: "system" }, firstItem), - ); - const results = Promise.allSettled([first, follower, retry]); - rejectFirst.resolve(); - - expect(await results).toMatchObject([ - { status: "rejected" }, - { status: "rejected" }, - { status: "fulfilled" }, - ]); - expect(events).toEqual(["first", "first"]); - await projection.putItem(RUN_ID, "second", { name: "second", type: "system" }, secondItem); - expect(events).toEqual(["first", "first", "second"]); -}); - -test("Contract projection rejects an identical follower queued before an unknown result", async () => { - const firstEntered = Promise.withResolvers(); - const rejectFirst = Promise.withResolvers(); - const mutationIds: string[] = []; - let attempts = 0; - const projection = new ContractProjection({ - authority: async ({ mutationId }) => { - mutationIds.push(mutationId); - if (++attempts === 1) { - firstEntered.resolve(); - await rejectFirst.promise; - throw new AuthorityOutcomeUnknownError("result lost"); - } - }, - preview: () => {}, - sessionId: SESSION_ID, - }); - const timestamp = "2026-07-16T08:00:00.000Z"; - const item = activeMessage("message-1", timestamp); - projection.attachRun(activeRun(timestamp)); - const write = () => projection.putItem(RUN_ID, "first", { name: "first", type: "system" }, item); - const first = write(); - await firstEntered.promise; - const follower = write(); - const results = Promise.allSettled([first, follower]); - rejectFirst.resolve(); - - expect(await results).toMatchObject([{ status: "rejected" }, { status: "rejected" }]); - expect(attempts).toBe(1); - await write(); - - expect(attempts).toBe(2); - expect(mutationIds[1]).toBe(mutationIds[0]); -}); - -test("Contract projection continues queued writes after a definite Authority rejection", async () => { - const firstEntered = Promise.withResolvers(); - const rejectFirst = Promise.withResolvers(); - const events: string[] = []; - const projection = new ContractProjection({ - authority: async ({ event }) => { - events.push(event); - if (event === "first") { - firstEntered.resolve(); - await rejectFirst.promise; - throw new Error("mutation rejected"); - } - }, - preview: () => {}, - sessionId: SESSION_ID, - }); - const timestamp = "2026-07-16T08:00:00.000Z"; - projection.attachRun(activeRun(timestamp)); - const first = projection.putItem( - RUN_ID, - "first", - { name: "first", type: "system" }, - activeMessage("message-1", timestamp), - ); - await firstEntered.promise; - const secondItem = { - ...activeMessage("message-1", timestamp), - content: [{ text: "second", type: "text" as const }], - }; - const second = projection.putItem( - RUN_ID, - "second", - { name: "second", type: "system" }, - secondItem, - ); - const results = Promise.allSettled([first, second]); - rejectFirst.resolve(); - - expect(await results).toMatchObject([ - { reason: { message: "mutation rejected" }, status: "rejected" }, - { status: "fulfilled" }, - ]); - expect(events).toEqual(["first", "second"]); - expect(projection.item(RUN_ID, "message-1")).toEqual(secondItem); -}); - -test("Contract projection preserves sub-millisecond lifecycle order", async () => { - let terminalRun: Run | undefined; - const projection = new ContractProjection({ - authority: async ({ operations }) => { - const operation = operations.at(-1); - terminalRun = - operation?.op === "put" && operation.entity === "run" ? operation.value : undefined; - }, - preview: () => {}, - sessionId: SESSION_ID, - }); - projection.attachRun(activeRun("2026-07-16T08:00:00.0000009Z")); - - await projection.finishRun({ - cause: { type: "system", name: "finish" }, - endedAt: "2026-07-16T08:00:00.0000001Z", - event: "finish", - runId: RUN_ID, - status: "completed", - }); - - expect(terminalRun).toMatchObject({ endedAt: "2026-07-16T08:00:00.0000009Z" }); -}); - -test("Contract projection applies Authority admission before dispatch", async () => { - let calls = 0; - const projection = new ContractProjection({ - admissionLimits: { maxBytes: 1_024, maxInlineBytes: 1 }, - authority: async () => { - calls += 1; - }, - preview: () => {}, - sessionId: SESSION_ID, - }); - const timestamp = "2026-07-16T08:00:00.000Z"; - projection.attachRun(activeRun(timestamp)); - - await expect( - projection.putItem( - RUN_ID, - "image", - { type: "system", name: "image" }, - { - ...activeMessage("message-1", timestamp), - content: [{ data: "aGk=", mediaType: "text/plain", type: "inline_blob" }], - }, - ), - ).rejects.toThrow("inline Blob"); - expect(calls).toBe(0); - - const item = activeMessage("message-1", timestamp); - await expect( - projection.putItem(RUN_ID, "image", { name: "image", type: "system" }, item), - ).resolves.toEqual(item); - expect(calls).toBe(1); -}); - -test.each([ - ["an identical retry", (run: Run) => structuredClone(run), null], - [ - "changed state", - (run: Run) => ({ ...run, input: [{ text: "changed", type: "text" as const }] }), - "already attached with different state", - ], -] as const)("Contract projection handles %s for an attached Run", (_name, retry, error) => { - const projection = new ContractProjection({ - authority: async () => {}, - preview: () => {}, - sessionId: SESSION_ID, - }); - const run = activeRun("2026-07-16T08:00:00.000Z"); - projection.attachRun(run); - - if (error === null) { - expect(() => projection.attachRun(retry(run))).not.toThrow(); - expect(projection.run(RUN_ID)).toEqual(run); - } else { - expect(() => projection.attachRun(retry(run))).toThrow(error); - expect(projection.run(RUN_ID)).toEqual(run); - } -}); diff --git a/tests/contract-projection-state.test.ts b/tests/contract-projection-state.test.ts deleted file mode 100644 index 884e5a4..0000000 --- a/tests/contract-projection-state.test.ts +++ /dev/null @@ -1,845 +0,0 @@ -import { expect, test } from "bun:test"; - -import { - AuthorityOutcomeUnknownError, - applyCommittedMutation, - interactionSchema, - itemSchema, - validateSessionSnapshot, -} from "../src/contract"; -import type { - AuthorityOperation, - CommittedMutation, - Item, - Run, - SessionSnapshot, -} from "../src/contract"; -import { - ContractProjection, - type ContractAuthorityUpdate, -} from "../src/runtimes/contract-projection"; - -function protocolId(value: number): string { - return value.toString().padStart(26, "0"); -} - -const SESSION_ID = protocolId(1); -const RUN_ID = protocolId(2); -const INTERACTION_ID = protocolId(3); -const RESOLVED_INTERACTION_ID = protocolId(5); - -function activeRun(startedAt: string, input = true): Run { - return { - id: RUN_ID, - input: input ? [{ text: "hello", type: "text" }] : [], - origin: input ? "user" : "system", - startedAt, - status: "active", - }; -} - -function childRun(id: string, parentRunId: string, startedAt: string): Run { - return { - id, - input: [], - origin: "system", - parentRunId, - startedAt, - status: "active", - }; -} - -function runTreeProjection(runs: readonly Run[], capturedAt: string) { - const root = runs[0]; - - if (root === undefined) { - throw new Error("Run tree fixture requires a root."); - } - - let committedAt = capturedAt; - let snapshot: SessionSnapshot = validateSessionSnapshot({ - capturedAt, - interactions: [], - items: [], - protocolVersion: 2, - revision: 0, - runs, - session: { - capabilities: { "run.child": {} }, - config: [], - createdAt: root.startedAt, - id: SESSION_ID, - status: "open", - updatedAt: root.startedAt, - }, - }); - const projection = new ContractProjection({ - authority: async (update) => { - const revision = snapshot.revision + 1; - snapshot = applyCommittedMutation(snapshot, { - baseRevision: snapshot.revision, - cause: update.cause, - committedAt, - mutationId: protocolId(2_000 + revision), - operations: [...update.operations], - revision, - sessionId: SESSION_ID, - }); - }, - preview: () => {}, - sessionId: SESSION_ID, - }); - - for (const run of runs) { - projection.attachRun(run); - } - - return { - projection, - setCommittedAt(value: string) { - committedAt = value; - }, - snapshot: () => snapshot, - }; -} - -function activeMessage(id: string, timestamp: string): Extract { - return itemSchema.parse({ - audience: "participants", - content: [], - createdAt: timestamp, - id, - kind: "message", - role: "agent", - runId: RUN_ID, - status: "active", - updatedAt: timestamp, - }) as Extract; -} - -test("Contract projection commits terminal snapshots and remaining cleanup atomically", async () => { - const startedAt = "2026-07-16T08:00:00.000Z"; - const endedAt = "2026-07-16T08:00:01.000Z"; - const commits: AuthorityOperation[][] = []; - const projection = new ContractProjection({ - authority: async ({ operations }) => { - commits.push([...operations]); - }, - now: () => new Date(endedAt), - preview: () => {}, - sessionId: SESSION_ID, - }); - projection.attachRun(activeRun(startedAt)); - await projection.putItem( - RUN_ID, - "message/started", - { providerEventId: "message-1", type: "provider" }, - activeMessage("message-1", startedAt), - ); - await projection.putItem( - RUN_ID, - "message/started", - { providerEventId: "message-2", type: "provider" }, - activeMessage("message-2", startedAt), - ); - const error = { code: "turn.incomplete", message: "Missing snapshot.", retryable: false }; - - await projection.finishRun({ - cause: { providerEventId: "turn/completed", type: "provider" }, - error, - event: "turn/completed", - runId: RUN_ID, - status: "failed", - terminalItems: [ - itemSchema.parse({ - ...activeMessage("message-1", startedAt), - content: [{ text: "done", type: "text" }], - endedAt, - status: "completed", - updatedAt: endedAt, - }), - ], - }); - - expect(commits.at(-1)).toMatchObject([ - { entity: "item", op: "put", value: { id: "message-1", status: "completed" } }, - { - entity: "item", - op: "put", - value: { error, id: "message-2", status: "failed" }, - }, - { entity: "run", op: "put", value: { error, id: RUN_ID, status: "failed" } }, - ]); - expect(projection.run(RUN_ID)).toBeUndefined(); -}); - -test.each([ - [ - "an older requested end", - "2026-07-16T08:00:01.000Z", - "2026-07-16T08:00:00.500Z", - "2026-07-16T08:00:01.000Z", - ], - [ - "a newer requested end", - "2026-07-16T08:00:01.000Z", - "2026-07-16T08:00:02.000Z", - "2026-07-16T08:00:02.000Z", - ], - [ - "an offset Item update", - "2026-07-16T16:00:01.500+08:00", - "2026-07-16T09:00:01.000+01:00", - "2026-07-16T16:00:01.500+08:00", - ], - [ - "a sub-millisecond Item update", - "2026-07-16T08:00:00.1000009Z", - "2026-07-16T08:00:00.1000001Z", - "2026-07-16T08:00:00.1000009Z", - ], -] as const)( - "Contract projection ends an atomic terminal Item enrichment after %s", - async (_name, updatedAt, requestedEnd, expectedEnd) => { - const startedAt = "2026-07-16T08:00:00.000Z"; - const itemEndedAt = "2026-07-16T08:00:00.100Z"; - let committedAt = itemEndedAt; - let snapshot: SessionSnapshot = validateSessionSnapshot({ - capturedAt: startedAt, - interactions: [], - items: [], - protocolVersion: 2, - revision: 0, - runs: [activeRun(startedAt)], - session: { - capabilities: {}, - config: [], - createdAt: startedAt, - id: SESSION_ID, - status: "open", - updatedAt: startedAt, - }, - }); - const projection = new ContractProjection({ - authority: async (update) => { - const revision = snapshot.revision + 1; - snapshot = applyCommittedMutation(snapshot, { - baseRevision: snapshot.revision, - cause: update.cause, - committedAt, - mutationId: protocolId(1_000 + revision), - operations: [...update.operations], - revision, - sessionId: SESSION_ID, - }); - }, - preview: () => {}, - sessionId: SESSION_ID, - }); - projection.attachRun(activeRun(startedAt)); - const terminal = itemSchema.parse({ - ...activeMessage("message-1", startedAt), - content: [{ text: "before", type: "text" }], - endedAt: itemEndedAt, - status: "completed", - updatedAt: itemEndedAt, - }); - await projection.putItem( - RUN_ID, - "message/completed", - { providerEventId: "message-1", type: "provider" }, - terminal, - ); - committedAt = "2026-07-18T00:00:00.000Z"; - - await projection.finishRun({ - cause: { providerEventId: "run-1", type: "provider" }, - endedAt: requestedEnd, - event: "run/completed", - runId: RUN_ID, - status: "completed", - terminalItems: [ - itemSchema.parse({ - ...terminal, - content: [{ text: "after", type: "text" }], - updatedAt, - }), - ], - }); - - expect(snapshot.items[0]).toMatchObject({ - content: [{ text: "after", type: "text" }], - endedAt: itemEndedAt, - updatedAt, - }); - expect(snapshot.runs[0]).toMatchObject({ endedAt: expectedEnd, status: "completed" }); - }, -); - -test("Contract projection bubbles a descendant Run boundary through every parent", async () => { - const startedAt = "2026-07-16T08:00:00.000Z"; - const childRunId = protocolId(7); - const grandchildRunId = protocolId(8); - const child = childRun(childRunId, RUN_ID, "2026-07-16T08:00:00.100Z"); - const grandchild = childRun(grandchildRunId, childRunId, "2026-07-16T08:00:00.200Z"); - const descendantEnd = "2026-07-16T16:00:03.0000009+08:00"; - const tree = runTreeProjection([activeRun(startedAt), child, grandchild], grandchild.startedAt); - - tree.setCommittedAt(descendantEnd); - await tree.projection.finishRun({ - cause: { providerEventId: "grandchild-1", type: "provider" }, - endedAt: descendantEnd, - event: "grandchild/completed", - runId: grandchildRunId, - status: "completed", - }); - tree.setCommittedAt("2026-07-16T08:00:04.000Z"); - await tree.projection.finishRun({ - cause: { providerEventId: "child-1", type: "provider" }, - endedAt: "2026-07-16T08:00:01.000Z", - event: "child/completed", - runId: childRunId, - status: "completed", - }); - tree.setCommittedAt("2026-07-16T08:00:05.000Z"); - await tree.projection.finishRun({ - cause: { providerEventId: "parent-1", type: "provider" }, - endedAt: "2026-07-16T08:00:02.000Z", - event: "parent/completed", - runId: RUN_ID, - status: "completed", - }); - - expect(tree.snapshot().runs).toEqual( - expect.arrayContaining([ - expect.objectContaining({ endedAt: descendantEnd, id: childRunId }), - expect.objectContaining({ endedAt: descendantEnd, id: RUN_ID }), - ]), - ); -}); - -test("Contract projection retains the latest sibling Run boundary regardless of finish order", async () => { - const startedAt = "2026-07-16T08:00:00.000Z"; - const firstRunId = protocolId(7); - const secondRunId = protocolId(8); - const first = childRun(firstRunId, RUN_ID, "2026-07-16T08:00:00.100Z"); - const second = childRun(secondRunId, RUN_ID, "2026-07-16T08:00:00.200Z"); - const latestEnd = "2026-07-16T16:00:04.0000009+08:00"; - const tree = runTreeProjection([activeRun(startedAt), first, second], second.startedAt); - - tree.setCommittedAt("2026-07-16T08:00:05.000Z"); - await tree.projection.finishRun({ - cause: { providerEventId: "first-1", type: "provider" }, - endedAt: latestEnd, - event: "first/completed", - runId: firstRunId, - status: "completed", - }); - tree.setCommittedAt("2026-07-16T08:00:06.000Z"); - await tree.projection.finishRun({ - cause: { providerEventId: "second-1", type: "provider" }, - endedAt: "2026-07-16T08:00:03.000Z", - event: "second/completed", - runId: secondRunId, - status: "completed", - }); - tree.setCommittedAt("2026-07-16T08:00:07.000Z"); - await tree.projection.finishRun({ - cause: { providerEventId: "parent-1", type: "provider" }, - endedAt: "2026-07-16T08:00:02.000Z", - event: "parent/completed", - runId: RUN_ID, - status: "completed", - }); - - expect(tree.snapshot().runs.find((run) => run.id === RUN_ID)).toMatchObject({ - endedAt: latestEnd, - status: "completed", - }); -}); - -test.each([ - ["an earlier requested end", "2026-07-16T08:00:01.0000001Z", "child"], - ["a later requested end", "2026-07-16T08:00:02.0000001Z", "requested"], -] as const)( - "Contract projection combines a finished child Run with %s", - async (_name, requestedEnd, expected) => { - const startedAt = "2026-07-16T08:00:00.000Z"; - const childStartedAt = "2026-07-16T08:00:00.500Z"; - const childEndedAt = "2026-07-16T16:00:01.0000009+08:00"; - const childRunId = protocolId(7); - const child = childRun(childRunId, RUN_ID, childStartedAt); - const tree = runTreeProjection([activeRun(startedAt), child], childStartedAt); - const { projection } = tree; - - tree.setCommittedAt(childEndedAt); - await projection.finishRun({ - cause: { providerEventId: "child-1", type: "provider" }, - endedAt: childEndedAt, - event: "child/completed", - runId: childRunId, - status: "completed", - }); - tree.setCommittedAt("2026-07-18T00:00:00.000Z"); - await projection.finishRun({ - cause: { providerEventId: "parent-1", type: "provider" }, - endedAt: requestedEnd, - event: "parent/completed", - runId: RUN_ID, - status: "completed", - }); - - expect(tree.snapshot().runs.find((run) => run.id === RUN_ID)).toMatchObject({ - endedAt: expected === "child" ? childEndedAt : requestedEnd, - status: "completed", - }); - }, -); - -test("Contract projection propagates a child boundary once after an unknown Authority retry", async () => { - const startedAt = "2026-07-16T08:00:00.000Z"; - const childStartedAt = "2026-07-16T08:00:00.500Z"; - const childEndedAt = "2026-07-16T08:00:01.0000009Z"; - const childRunId = protocolId(7); - const child = childRun(childRunId, RUN_ID, childStartedAt); - let committedAt = childEndedAt; - let childAttempts = 0; - let snapshot: SessionSnapshot = validateSessionSnapshot({ - capturedAt: childStartedAt, - interactions: [], - items: [], - protocolVersion: 2, - revision: 0, - runs: [activeRun(startedAt), child], - session: { - capabilities: { "run.child": {} }, - config: [], - createdAt: startedAt, - id: SESSION_ID, - status: "open", - updatedAt: startedAt, - }, - }); - const committed = new Set(); - const writes: ContractAuthorityUpdate[] = []; - const projection = new ContractProjection({ - authority: async (update) => { - writes.push(update); - - if (!committed.has(update.mutationId)) { - const revision = snapshot.revision + 1; - snapshot = applyCommittedMutation(snapshot, { - baseRevision: snapshot.revision, - cause: update.cause, - committedAt, - mutationId: update.mutationId, - operations: [...update.operations], - revision, - sessionId: SESSION_ID, - }); - committed.add(update.mutationId); - } - - if (update.event === "child/completed" && ++childAttempts === 1) { - throw new AuthorityOutcomeUnknownError("child result lost after commit"); - } - }, - preview: () => {}, - sessionId: SESSION_ID, - }); - projection.attachRun(activeRun(startedAt)); - projection.attachRun(child); - const finishChild = () => - projection.finishRun({ - cause: { providerEventId: "child-1", type: "provider" }, - endedAt: childEndedAt, - event: "child/completed", - runId: childRunId, - status: "completed", - }); - - await expect(finishChild()).rejects.toThrow("child result lost after commit"); - expect(projection.run(childRunId)).toMatchObject({ status: "active" }); - await finishChild(); - committedAt = "2026-07-16T08:00:02.000Z"; - await projection.finishRun({ - cause: { providerEventId: "parent-1", type: "provider" }, - endedAt: "2026-07-16T08:00:01.0000001Z", - event: "parent/completed", - runId: RUN_ID, - status: "completed", - }); - - expect(writes.slice(0, 2).map(({ mutationId }) => mutationId)).toEqual([ - writes[0]?.mutationId, - writes[0]?.mutationId, - ]); - expect(snapshot.revision).toBe(2); - expect(snapshot.runs.find((run) => run.id === RUN_ID)).toMatchObject({ - endedAt: childEndedAt, - status: "completed", - }); -}); - -test("Contract projection releases child boundaries with their parent and on disposal", async () => { - const startedAt = "2026-07-16T08:00:00.000Z"; - const childRunId = protocolId(7); - const child = childRun(childRunId, RUN_ID, "2026-07-16T08:00:00.500Z"); - const childEndedAt = "2026-07-16T08:00:01.000Z"; - const terminalRuns: Run[] = []; - const options = { - authority: async ({ operations }: ContractAuthorityUpdate) => { - const operation = operations.at(-1); - - if (operation?.op === "put" && operation.entity === "run") { - terminalRuns.push(operation.value); - } - }, - preview: () => {}, - sessionId: SESSION_ID, - }; - const projection = new ContractProjection(options); - projection.attachRun(activeRun(startedAt)); - projection.attachRun(child); - await projection.finishRun({ - cause: { providerEventId: "child-1", type: "provider" }, - endedAt: childEndedAt, - event: "child/completed", - runId: childRunId, - status: "completed", - }); - await projection.finishRun({ - cause: { providerEventId: "parent-1", type: "provider" }, - endedAt: "2026-07-16T08:00:00.750Z", - event: "parent/completed", - runId: RUN_ID, - status: "completed", - }); - - projection.attachRun(activeRun(startedAt)); - await projection.finishRun({ - cause: { providerEventId: "reused-1", type: "provider" }, - endedAt: "2026-07-16T08:00:00.750Z", - event: "reused/completed", - runId: RUN_ID, - status: "completed", - }); - expect(terminalRuns.at(-1)).toMatchObject({ endedAt: "2026-07-16T08:00:00.750Z" }); - - const disposable = new ContractProjection(options); - disposable.attachRun(activeRun(startedAt)); - disposable.attachRun(child); - await disposable.finishRun({ - cause: { providerEventId: "disposable-child-1", type: "provider" }, - endedAt: childEndedAt, - event: "disposable-child/completed", - runId: childRunId, - status: "completed", - }); - disposable.dispose(); - disposable.dispose(); - expect(() => disposable.run(RUN_ID)).toThrow("disposed"); -}); - -test.each([ - ["an active Item", (at: string) => [activeMessage("message-1", at)]], - [ - "an Item from another Run", - (at: string) => [ - itemSchema.parse({ - ...activeMessage("message-1", at), - endedAt: at, - runId: protocolId(99), - status: "completed", - }), - ], - ], - [ - "duplicate Item IDs", - (at: string) => { - const item = itemSchema.parse({ - ...activeMessage("message-1", at), - endedAt: at, - status: "completed", - }); - return [item, item]; - }, - ], -] as const)( - "Contract projection rejects terminal snapshots with %s", - async (_name, terminalItems) => { - const timestamp = "2026-07-16T08:00:00.000Z"; - const projection = new ContractProjection({ - authority: async () => {}, - now: () => new Date(timestamp), - preview: () => {}, - sessionId: SESSION_ID, - }); - projection.attachRun(activeRun(timestamp)); - - await expect( - projection.finishRun({ - cause: { providerEventId: "turn/completed", type: "provider" }, - event: "turn/completed", - runId: RUN_ID, - status: "completed", - terminalItems: terminalItems(timestamp), - }), - ).rejects.toThrow("invalid terminal Item"); - expect(projection.run(RUN_ID)?.status).toBe("active"); - }, -); - -test("Contract projection never ends a Run before a later Interaction", async () => { - let now = new Date("2026-07-16T08:00:00.000Z"); - let snapshot: SessionSnapshot = validateSessionSnapshot({ - capturedAt: now.toISOString(), - interactions: [], - items: [], - protocolVersion: 2, - revision: 0, - runs: [activeRun(now.toISOString())], - session: { - capabilities: { - "example.com/interaction": {}, - "interaction.permission": {}, - }, - config: [], - createdAt: now.toISOString(), - id: SESSION_ID, - status: "open", - updatedAt: now.toISOString(), - }, - }); - const projection = new ContractProjection({ - authority: async (update) => { - const revision = snapshot.revision + 1; - const mutation: CommittedMutation = { - baseRevision: snapshot.revision, - cause: update.cause, - committedAt: now.toISOString(), - mutationId: protocolId(1_000 + revision), - operations: [...update.operations] as AuthorityOperation[], - revision, - sessionId: SESSION_ID, - }; - snapshot = applyCommittedMutation(snapshot, mutation); - }, - now: () => now, - preview: () => {}, - sessionId: SESSION_ID, - }); - projection.attachRun(activeRun(now.toISOString())); - now = new Date("2026-07-16T08:00:01.000Z"); - await projection.putInteraction( - RUN_ID, - "permission/requested", - { providerEventId: "permission-1", type: "provider" }, - interactionSchema.parse({ - audience: "participants", - blocking: true, - createdAt: now.toISOString(), - expiresAt: "2026-07-16T08:05:01.000Z", - id: INTERACTION_ID, - kind: "permission", - request: { - options: [ - { - effect: "deny", - id: "deny", - label: "Deny", - scope: "once", - }, - ], - subject: { - operation: "execute", - targets: ["workspace"], - type: "resource", - }, - title: "Run command?", - }, - runId: RUN_ID, - status: "open", - }), - ); - now = new Date("2026-07-16T08:00:02.000Z"); - await projection.putInteraction( - RUN_ID, - "permission/requested", - { providerEventId: "permission-2-open", type: "provider" }, - interactionSchema.parse({ - audience: "participants", - blocking: true, - createdAt: "2026-07-16T08:00:01.500Z", - expiresAt: "2026-07-16T08:05:01.500Z", - id: RESOLVED_INTERACTION_ID, - kind: "permission", - request: { - options: [ - { - effect: "deny", - id: "deny", - label: "Deny", - scope: "once", - }, - ], - subject: { - operation: "execute", - targets: ["workspace"], - type: "resource", - }, - title: "Run another command?", - }, - runId: RUN_ID, - status: "open", - }), - ); - await projection.putInteraction( - RUN_ID, - "permission/resolved", - { providerEventId: "permission-2-resolved", type: "provider" }, - interactionSchema.parse({ - ...projection.interaction(RESOLVED_INTERACTION_ID), - endedAt: now.toISOString(), - resolution: { type: "cancelled" }, - status: "resolved", - }), - ); - const extensionId = protocolId(6); - await projection.putInteraction( - RUN_ID, - "extension/requested", - { providerEventId: "extension-1", type: "provider" }, - interactionSchema.parse({ - audience: "participants", - blocking: true, - createdAt: now.toISOString(), - expiresAt: "2026-07-16T08:05:02.000Z", - id: extensionId, - kind: "extension", - name: "example.com/interaction", - request: { prompt: "Continue?" }, - runId: RUN_ID, - status: "open", - }), - ); - await projection.finishRun({ - cause: { providerEventId: "result-1", type: "provider" }, - endedAt: "2026-07-16T08:00:00.500Z", - event: "result/completed", - runId: RUN_ID, - status: "completed", - }); - - expect(snapshot.runs[0]).toMatchObject({ - endedAt: "2026-07-16T08:00:02.000Z", - status: "completed", - }); - expect(snapshot.interactions[0]).toMatchObject({ - endedAt: "2026-07-16T08:00:02.000Z", - status: "expired", - }); - expect(snapshot.interactions[0]).not.toHaveProperty("resolution"); - expect(snapshot.interactions.find((entry) => entry.id === extensionId)).toMatchObject({ - endedAt: "2026-07-16T08:00:02.000Z", - status: "expired", - }); -}); - -test("Contract projection checkpoints every text channel before clearing Preview", async () => { - const timestamp = "2026-07-16T08:00:00.000Z"; - const previews: string[] = []; - const projection = new ContractProjection({ - authority: async () => {}, - now: () => new Date(timestamp), - preview: ({ update }) => previews.push(update.text), - previewCheckpointBytes: 4, - sessionId: SESSION_ID, - }); - projection.attachRun(activeRun(timestamp, false)); - await projection.putItem( - RUN_ID, - "terminal/created", - { providerEventId: "terminal-1", type: "provider" }, - itemSchema.parse({ - audience: "participants", - createdAt: timestamp, - id: "terminal-1", - kind: "terminal", - runId: RUN_ID, - status: "active", - stderr: [], - stdout: [], - updatedAt: timestamp, - }), - ); - await projection.replacePreview({ - channel: "terminal.stderr", - itemId: "terminal-1", - runId: RUN_ID, - text: "err", - }); - await projection.replacePreview({ - channel: "terminal.stdout", - itemId: "terminal-1", - runId: RUN_ID, - text: "large", - }); - - expect(projection.item(RUN_ID, "terminal-1")).toMatchObject({ - stderr: [{ text: "err", type: "text" }], - stdout: [{ text: "large", type: "text" }], - }); - expect(projection.materializedText(RUN_ID, "terminal-1", "terminal.stderr")).toBe("err"); - expect(projection.materializedText(RUN_ID, "terminal-1", "terminal.stdout")).toBe("large"); - - await projection.putItem( - RUN_ID, - "message/started", - { providerEventId: "message-1", type: "provider" }, - itemSchema.parse({ - audience: "participants", - content: [], - createdAt: timestamp, - id: "message-1", - kind: "message", - role: "agent", - runId: RUN_ID, - status: "active", - updatedAt: timestamp, - }), - ); - await projection.replacePreview({ - channel: "tool.progress", - itemId: "message-1", - runId: RUN_ID, - text: "a😀b", - }); - - expect(previews.at(-1)).toBe("err"); - await projection.putItem( - RUN_ID, - "tool/started", - { providerEventId: "tool-1", type: "provider" }, - itemSchema.parse({ - audience: "participants", - category: "other", - createdAt: timestamp, - id: "tool-1", - kind: "tool", - name: "Tool", - origin: "provider", - runId: RUN_ID, - status: "active", - updatedAt: timestamp, - }), - ); - await projection.replacePreview({ - channel: "tool.progress", - itemId: "tool-1", - runId: RUN_ID, - text: "a😀b", - }); - - expect(previews.at(-1)).toBe("a"); -}); diff --git a/tests/contract-reducer.test.ts b/tests/contract-reducer.test.ts index 89a727b..1595483 100644 --- a/tests/contract-reducer.test.ts +++ b/tests/contract-reducer.test.ts @@ -41,7 +41,7 @@ function session(id = createDriverId()): Session { function snapshot(sessionValue = session()): SessionSnapshot { return validateSessionSnapshot({ - protocolVersion: 2, + protocolVersion: 3, revision: 0, capturedAt: time, session: sessionValue, @@ -239,11 +239,13 @@ describe("contract authority reducer", () => { }); test.each([ - ["decreases", { input: 9, total: 10 }], - ["drops a field", { total: 10 }], + ["decreases", { cachedWrite: 10, input: 9, total: 10 }], + ["drops a field", { cachedWrite: 10, total: 10 }], + ["decreases cached writes", { cachedWrite: 9, input: 10, total: 10 }], + ["drops cached writes", { input: 10, total: 10 }], ])("rejects cumulative Run usage that %s", (_name, usage) => { const initial = snapshot(); - const run = { ...activeRun(), usage: { input: 10, total: 10 } }; + const run = { ...activeRun(), usage: { cachedWrite: 10, input: 10, total: 10 } }; const running = applyCommittedMutation( initial, mutation(initial, [{ entity: "run", op: "put", value: run }]), @@ -617,7 +619,7 @@ describe("contract authority reducer", () => { }; const nextRun = activeRun(); const value = validateSessionSnapshot({ - protocolVersion: 2, + protocolVersion: 3, revision: 2, capturedAt: time, session: sessionValue, @@ -646,7 +648,7 @@ describe("contract authority reducer", () => { endedAt: time, }; const current = validateSessionSnapshot({ - protocolVersion: 2, + protocolVersion: 3, revision: 1, capturedAt: time, session: sessionValue, diff --git a/tests/contract-wire.test.ts b/tests/contract-wire.test.ts index 4da77a8..61981a7 100644 --- a/tests/contract-wire.test.ts +++ b/tests/contract-wire.test.ts @@ -41,7 +41,7 @@ function session(id = createDriverId()): Session { function snapshot(sessionValue = session()): SessionSnapshot { return validateSessionSnapshot({ - protocolVersion: 2, + protocolVersion: 3, revision: 0, capturedAt: time, session: sessionValue, @@ -150,7 +150,6 @@ describe("contract protocol IDs", () => { ["canonical", "01J00000000000000000000009", "01J00000000000000000000009"], ["lowercase", "01j00000000000000000000009", "01J00000000000000000000009"], ["maximum timestamp", "7ZZZZZZZZZZZZZZZZZZZZZZZZZ", "7ZZZZZZZZZZZZZZZZZZZZZZZZZ"], - ["overflowing timestamp", "80000000000000000000000000", "80000000000000000000000000"], ] as const)("accepts and canonicalizes a %s ULID", (_case, input, expected) => { expect(protocolIdSchema.parse(input)).toBe(expected); expect(isDriverId(expected)).toBe(true); @@ -159,6 +158,7 @@ describe("contract protocol IDs", () => { test.each([ ["UUID", "00000000-0000-4000-8000-000000000001"], ["excluded alphabet character", "01J0000000000000000000000I"], + ["overflowing timestamp", "80000000000000000000000000"], ["wrong length", "01J0000000000000000000000"], ] as const)("rejects a %s", (_case, input) => { expect(protocolIdSchema.safeParse(input).success).toBe(false); diff --git a/tests/driver-artifact-contract.test.ts b/tests/driver-artifact-contract.test.ts index e4e2fc2..93e006a 100644 --- a/tests/driver-artifact-contract.test.ts +++ b/tests/driver-artifact-contract.test.ts @@ -1,41 +1,31 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; -import { OPENAI_APP_SERVER_SCHEMA_VERSION } from "../src/runtimes/openai/generated/app-server-protocol-types"; +import environmentPackageManagers from "../environment-package-managers.json" with { type: "json" }; +import packageJson from "../package.json" with { type: "json" }; import { AGENT_DRIVER_VERSION } from "../src/core/version"; +import viteConfig from "../vite.config"; -type DriverPackageExportTarget = - | string - | { - readonly default?: string; - readonly types?: string; - }; - -interface DriverPackageJson { - readonly bin?: Record; - readonly bugs?: { readonly url?: string }; - readonly dependencies?: Record; - readonly devDependencies?: Record; - readonly description?: string; - readonly engines?: Record; - readonly exports?: Record; - readonly files?: readonly string[]; - readonly homepage?: string; - readonly license?: string; +interface WorkflowStep { + readonly env?: Record; readonly name?: string; - readonly packageManager?: string; - readonly private?: boolean; - readonly publishConfig?: Record; - readonly repository?: { readonly type?: string; readonly url?: string }; - readonly scripts?: Record; - readonly types?: string; - readonly type?: string; - readonly version?: string; + readonly run?: string; + readonly "timeout-minutes"?: number; + readonly uses?: string; } -interface EnvironmentPackageManagerManifest { - readonly managers?: readonly string[]; - readonly schemaVersion?: number; +interface WorkflowJob { + readonly if?: string; + readonly needs?: string | readonly string[]; + readonly steps?: readonly WorkflowStep[]; +} + +interface Workflow { + readonly concurrency?: { + readonly group?: string; + readonly queue?: string; + }; + readonly jobs?: Record; } const PUBLIC_EXPORTS = [ @@ -55,32 +45,49 @@ function readText(path: string): string { return readFileSync(new URL(path, import.meta.url), "utf8"); } -function readDriverPackageJson(): DriverPackageJson { - return JSON.parse(readText("../package.json")) as DriverPackageJson; +function readWorkflow(path: string): Workflow { + return Bun.YAML.parse(readText(path)) as Workflow; +} + +function readContainerArguments(): Record { + return Object.fromEntries( + [...readText("../Containerfile").matchAll(/^ARG ([A-Z_]+)=(.+)$/gmu)].map(([, name, value]) => [ + name, + value, + ]), + ); +} + +function workflowJob(workflow: Workflow, id: string): WorkflowJob { + const job = workflow.jobs?.[id]; + if (job === undefined) { + throw new Error(`Missing workflow job ${id}.`); + } + return job; } -function readEnvironmentPackageManagerManifest(): EnvironmentPackageManagerManifest { - return JSON.parse( - readText("../environment-package-managers.json"), - ) as EnvironmentPackageManagerManifest; +function workflowStep(job: WorkflowJob, name: string): WorkflowStep { + const step = job.steps?.find((candidate) => candidate.name === name); + if (step === undefined) { + throw new Error(`Missing workflow step ${name}.`); + } + return step; } describe("driver artifact contract", () => { test("uses the public mosoo package identity", () => { - const packageJson = readDriverPackageJson(); - expect(packageJson.name).toBe("@mosoo/agent-driver"); expect(packageJson.private).toBe(false); expect(packageJson.version).toBe(AGENT_DRIVER_VERSION); expect(packageJson.description).toContain("Agent Driver"); expect(packageJson.license).toBe("Apache-2.0"); - expect(packageJson.packageManager).toBe("bun@1.3.14"); - expect(packageJson.engines).toEqual({ bun: ">=1.3.14" }); + expect(packageJson.packageManager).toBe("bun@1.4.0"); + expect(packageJson.engines).toEqual({ bun: ">=1.4.0" }); expect(packageJson.repository).toEqual({ type: "git", url: "git+https://github.com/langgenius/mosoo-agent-driver.git", }); - expect(packageJson.bugs?.url).toBe("https://github.com/langgenius/mosoo-agent-driver/issues"); + expect(packageJson.bugs.url).toBe("https://github.com/langgenius/mosoo-agent-driver/issues"); expect(packageJson.homepage).toBe("https://github.com/langgenius/mosoo-agent-driver"); expect(packageJson.publishConfig).toEqual({ access: "public", @@ -90,26 +97,44 @@ describe("driver artifact contract", () => { "agent-driver": "./dist/driver.mjs", }); expect(packageJson.types).toBe("./dist/types/index.d.ts"); - expect(packageJson.files).toEqual(["dist", "src", "assets"]); - expect(packageJson.files).not.toContain("tests/fixtures"); + expect(packageJson.files).toEqual(["dist", "src", "!src/runtimes/openai/generated", "assets"]); }); test("keeps public package entries separate from process internals", () => { - const packageJson = readDriverPackageJson(); - expect(packageJson.type).toBe("module"); - expect(Object.keys(packageJson.exports ?? {}).toSorted()).toEqual( - [...PUBLIC_EXPORTS].toSorted(), - ); - expect(packageJson.exports?.["."]).toEqual({ + expect(Object.keys(packageJson.exports).toSorted()).toEqual([...PUBLIC_EXPORTS].toSorted()); + expect(packageJson.exports["."]).toEqual({ default: "./src/index.ts", types: "./dist/types/index.d.ts", }); expect(packageJson.exports).not.toHaveProperty("./bin/driver"); }); + test("builds declarations for exactly the public package entries", () => { + const targets = Object.values(packageJson.exports); + const sources = [ + ...new Bun.Glob("src/*.ts").scanSync({ + cwd: new URL("../", import.meta.url).pathname, + }), + "src/contract/index.ts", + ].toSorted(); + + expect(viteConfig.pack).toEqual({ + dts: { emitDtsOnly: true }, + entry: ["src/*.ts", "src/contract/index.ts"], + fixedExtension: false, + outDir: "dist/types", + platform: "neutral", + }); + expect(targets.map(({ default: source }) => source.slice(2)).toSorted()).toEqual(sources); + for (const { default: source, types } of targets) { + expect(source).toMatch(/^\.\/src\/.+\.ts$/); + expect(types).toBe(source.replace("./src/", "./dist/types/").replace(/\.ts$/, ".d.ts")); + expect(source).not.toContain("/generated"); + } + }); + test("uses package.json as the runtime version source", () => { - const packageJson = readDriverPackageJson(); const runtimeVersionSources = [ "../src/bin/driver-process.ts", "../src/runtimes/acp/acp-driver-backend.ts", @@ -125,157 +150,156 @@ describe("driver artifact contract", () => { } }); - test("keeps the root library entry free of boot and transport internals", () => { - const publicApi = readText("../src/index.ts"); - - expect(publicApi).toContain("./core/agent-driver-kernel"); - expect(publicApi).toContain("./runtimes/provider-registry"); - expect(publicApi).toContain("./protocol/runtime"); - expect(publicApi).not.toContain("./bin/driver-process"); - expect(publicApi).not.toContain("./protocol/boot"); - expect(publicApi).not.toContain("./protocol/orpc"); - expect(publicApi).not.toContain("./protocol/paths"); - expect(publicApi).not.toContain("DriverProcess"); - expect(publicApi).not.toContain("DriverBootPayload"); - expect(publicApi).not.toContain("DriverRuntimeClient"); - expect(publicApi).not.toContain("createDriverStartInputFromBootPayload"); - }); - - test("builds and packages only the process runner artifact", () => { - const packageJson = readDriverPackageJson(); - const buildScript = packageJson.scripts?.["build"] ?? ""; - const imageBuildScript = packageJson.scripts?.["build:image"] ?? ""; - const containerignore = readText("../.containerignore"); + test("pins container runtimes to package dependency versions", () => { const containerfile = readText("../Containerfile"); - const processEntry = readText("../src/bin/driver.ts"); - - expect(processEntry.startsWith("#!/usr/bin/env bun\n")).toBe(true); - expect(buildScript).toContain("src/bin/driver.ts"); - expect(buildScript).toContain("dist/driver.mjs"); - expect(buildScript).not.toContain("src/index.ts"); - expect(containerfile).toContain("COPY dist/driver.mjs /usr/local/bin/agent-driver"); - expect(containerfile).toContain("RUN chmod +x /usr/local/bin/agent-driver"); - expect(containerfile).toContain("ENV MOSOO_ACP_FALLBACK_COMMAND=opencode"); - expect(containerignore).toContain("!dist/driver.mjs"); - expect(imageBuildScript).toBe("vp run build && buildah build -t agent-driver:local ."); - expect(packageJson.scripts?.["prepack"]).toBe("vp run build"); - }); + const versions = { + BUN_VERSION: packageJson.packageManager.replace("bun@", ""), + CLAUDE_AGENT_SDK_VERSION: packageJson.dependencies["@anthropic-ai/claude-agent-sdk"], + OPENAI_RUNTIME_VERSION: packageJson.devDependencies["@openai/codex"], + OPENCODE_VERSION: packageJson.devDependencies["opencode-ai"], + }; - test("pins the OpenAI runtime, SDK, and app-server schema to one stable version", () => { - const packageJson = readDriverPackageJson(); - const containerfile = readText("../Containerfile"); + expect(readContainerArguments()).toMatchObject(versions); + for (const version of Object.values(versions)) { + expect(version).toMatch(/^\d+\.\d+\.\d+$/); + } + for (const marker of [ + 'test "$(bun --version)" = "$BUN_VERSION"', + "@anthropic-ai/claude-agent-sdk-linux-x64@${CLAUDE_AGENT_SDK_VERSION}", + "opencode-linux-x64-baseline@${OPENCODE_VERSION}", + ]) + expect(containerfile).toContain(marker); - expect(packageJson.devDependencies?.["@openai/codex-sdk"]).toBe( - OPENAI_APP_SERVER_SCHEMA_VERSION, + const openAiVersion = versions.OPENAI_RUNTIME_VERSION; + expect(readText("../src/runtimes/openai/generated/README.md")).toContain( + `version \`${openAiVersion}\``, ); - expect(containerfile).toContain( - `ARG OPENAI_RUNTIME_VERSION=${OPENAI_APP_SERVER_SCHEMA_VERSION}`, + expect(readText("../src/runtimes/openai/generated-json-schema/README.md")).toContain( + `@openai/codex@${openAiVersion}`, ); }); - test("runs every live suite through the packed driver controller", () => { - const packageJson = readDriverPackageJson(); - const controller = readText("./driver-artifact-test-controller.ts"); - const liveTest = readText("./driver-artifact-live.test.ts"); - const artifactScript = packageJson.scripts?.["test:live:artifact"] ?? ""; + test("builds and tests the packed process artifact", () => { + const scripts = packageJson.scripts; + const containerfile = readText("../Containerfile"); + + expect(scripts["build"]).toContain("src/bin/driver.ts"); + expect(scripts["build"]).toContain("dist/driver.mjs"); + expect(scripts["prepack"]).toBe("vp run build"); + expect(containerfile).toContain("COPY dist/driver.mjs /usr/local/bin/agent-driver"); + expect(scripts["test:live"]).toBe("vp run build && vp run test:live:artifact"); + expect(scripts["test:live:artifact"]).toContain("AGENT_DRIVER_LIVE_SUITE=all"); + expect(scripts["test:live:artifact"]).toContain("tests/driver-artifact-live.test.ts"); + expect(scripts["test:live:artifact"]).toContain("tests/driver-artifact-mcp.test.ts"); - expect(artifactScript).toContain("AGENT_DRIVER_LIVE=1"); - expect(artifactScript).toContain("tests/driver-artifact-live.test.ts"); - expect(artifactScript).toContain("tests/driver-artifact-mcp.test.ts"); - expect(packageJson.scripts?.["test:live"]).toBe("vp run build && vp run test:live:artifact"); for (const suite of ["anthropic", "openai", "opencode"] as const) { - const script = packageJson.scripts?.[`test:live:${suite}`] ?? ""; - expect(script).toContain("vp run build"); - expect(script).toContain(`AGENT_DRIVER_LIVE_SUITE=${suite}`); - expect(script).toContain("tests/driver-artifact-live.test.ts"); + expect(scripts[`test:live:${suite}`]).toContain("vp run build"); + expect(scripts[`test:live:${suite}`]).toContain(`AGENT_DRIVER_LIVE_SUITE=${suite}`); + expect(scripts[`test:live:${suite}`]).toContain("tests/driver-artifact-live.test.ts"); } - expect(controller).toContain("export class DriverArtifactTestController"); - expect(controller).toContain("parseDriverEventBatchInput"); - expect(controller).toContain("crashDriver(): void"); - expect(controller).toMatch( - /crashDriver\(\): void \{\s+this\.#signalDriver\("SIGKILL", false\);\s+\}/, - ); - expect(controller).toContain("disconnectDriver(): void"); - expect(controller).toContain("failHeartbeats(): void"); - expect(controller).toContain("signalDriver(signal: NodeJS.Signals): void"); - const scenarioNames = (start: string, end: string) => - Array.from( - liveTest.slice(liveTest.indexOf(start), liveTest.indexOf(end)).matchAll(/\["([^"]+)"/g), - (match) => match[1], - ); - expect(liveTest).toContain("const compatibilityScenarios"); - expect(liveTest).toContain("const lifecycleScenarios"); - expect(liveTest).toContain("const controlScenarios"); - expect(scenarioNames("const compatibilityScenarios", "const lifecycleScenarios")).toEqual([ - "sequential turns", - ]); - expect(scenarioNames("const lifecycleScenarios", "const controlScenarios")).toEqual([ - "workspace Unicode CRUD", - "nonzero command recovery", - "native MCP configuration and tool call", - "native process resume", - "provider crash and native resume", - "stale native resume", - "run.started ACK-boundary, active, replayed, and idle cancellation", - "supervised permission cancellation, rejection, and approval", - "active stop and restart", - ]); - expect(scenarioNames("const controlScenarios", 'describe("packed driver live matrix"')).toEqual( - [ - "process crash and native resume", - "SIGTERM and restart", - "active control disconnect", - "active heartbeat failure", - ], - ); - expect(liveTest).toContain("for (const runtimeCase of runtimeCases)"); - expect(liveTest).toContain("for (const runtimeCase of lifecycleCases)"); - expect(liveTest).not.toMatch(/from ["']\.\.\/src\/(?:core|runtimes)/); }); - test("pins the release OpenCode executable and gates publishing on the packed artifact", () => { - const packageJson = readDriverPackageJson(); - const containerfile = readText("../Containerfile"); - const releaseWorkflow = readText("../.github/workflows/release.yml"); - const openCodeVersion = packageJson.devDependencies?.["opencode-ai"]; - const packIndex = releaseWorkflow.indexOf("- name: Pack package"); - const liveIndex = releaseWorkflow.indexOf("- name: Test packed driver"); - const imageIndex = releaseWorkflow.indexOf("- name: Build image"); + test("keeps release publication ordered, monotonic, and verifiable", () => { + const prWorkflow = readWorkflow("../.github/workflows/pr.yml"); + const releaseWorkflow = readWorkflow("../.github/workflows/release.yml"); + const verifyJob = workflowJob(releaseWorkflow, "verify"); + const buildJob = workflowJob(releaseWorkflow, "build"); + const imageJob = workflowJob(releaseWorkflow, "publish-versioned-image"); + const npmJob = workflowJob(releaseWorkflow, "publish-npm"); + const latestJob = workflowJob(releaseWorkflow, "publish-latest-image"); + const verifyRun = workflowStep(verifyJob, "Verify release tag").run ?? ""; + const packStep = workflowStep(buildJob, "Pack package"); + const mcpStep = workflowStep(buildJob, "Test packed driver"); + const liveStep = workflowStep(buildJob, "Test packed driver live matrix"); + const imageBuildStep = workflowStep(buildJob, "Build image"); + const imageRun = workflowStep(imageJob, "Publish versioned image").run ?? ""; + const npmRun = workflowStep(npmJob, "Publish package").run ?? ""; + const latestRun = + latestJob.steps?.flatMap(({ run }) => (run === undefined ? [] : [run])).join("\n") ?? ""; + const actionUses = [prWorkflow, releaseWorkflow].flatMap((workflow) => + Object.values(workflow.jobs ?? {}).flatMap((job) => + (job.steps ?? []).flatMap((step) => (step.uses === undefined ? [] : [step.uses])), + ), + ); - expect(openCodeVersion).toBe("1.18.4"); - expect(containerfile).toContain(`ARG OPENCODE_VERSION=${openCodeVersion}`); - expect(releaseWorkflow).toContain("AGENT_DRIVER_LIVE_ARTIFACT: packed/dist/driver.mjs"); - expect(releaseWorkflow).toContain("--strip-components=1"); - expect(releaseWorkflow).toContain("secrets.OPENROUTER_API_KEY"); - expect(packIndex).toBeGreaterThan(-1); - expect(liveIndex).toBeGreaterThan(packIndex); - expect(imageIndex).toBeGreaterThan(liveIndex); + expect(releaseWorkflow.concurrency).toEqual({ group: "release", queue: "max" }); + expect({ + build: buildJob.needs, + latest: latestJob.needs, + npm: npmJob.needs, + versionedImage: imageJob.needs, + }).toEqual({ + build: "verify", + latest: ["publish-versioned-image", "publish-npm"], + npm: ["build", "publish-versioned-image"], + versionedImage: "build", + }); + expect(latestJob.if).toBe("needs.publish-npm.outputs.promote_latest == 'true'"); + for (const marker of [ + 'version="$(node -p "require(\'./package.json\').version")"', + 'if [[ "${tag}" != "v${version}" ]]', + 'git merge-base --is-ancestor "${GITHUB_SHA}" origin/main', + ]) + expect(verifyRun).toContain(marker); + expect(packStep.run).toContain("npm pack --ignore-scripts"); + expect(mcpStep.run).toContain("declarations=(packed/dist/types/**/*.d.ts)"); + expect(imageBuildStep.run).toContain("buildah build"); + expect(workflowStep(buildJob, "Test image environment").run).toContain( + "podman run --pull=never --rm", + ); + for (const marker of [ + "skopeo inspect --format '{{.Digest}}' \"docker://$IMAGE:$VERSION\"", + 'if [[ "$remote_digest" != "$EXPECTED_DIGEST" ]]', + "skopeo copy --preserve-digests", + "gh attestation verify", + ]) + expect(imageRun).toContain(marker); + for (const marker of [ + 'npm view "$package_name@$VERSION" dist.integrity', + 'if [[ "$remote_integrity" != "$local_integrity" ]]', + 'npm view "$package_name@>$VERSION"', + 'npm publish "$tarball" --ignore-scripts --provenance', + ]) + expect(npmRun).toContain(marker); + for (const marker of [ + 'npm view "$PACKAGE_NAME" dist-tags.latest', + 'npm view "$PACKAGE_NAME@>$VERSION"', + 'skopeo copy --preserve-digests "docker://$IMAGE@$DIGEST" "docker://$IMAGE:latest"', + ]) + expect(latestRun).toContain(marker); + expect(packStep.env).toBeUndefined(); + expect(mcpStep.env).not.toHaveProperty("OPENROUTER_API_KEY"); + expect(mcpStep.run).toContain("bun test tests/driver-artifact-mcp.test.ts"); + expect(liveStep).toMatchObject({ + env: { + AGENT_DRIVER_LIVE_ARTIFACT: "packed/dist/driver.mjs", + OPENROUTER_API_KEY: "${{ secrets.OPENROUTER_API_KEY }}", + }, + "timeout-minutes": 180, + }); + expect(liveStep.run).toContain('if [[ -z "${OPENROUTER_API_KEY:-}" ]]'); + expect(liveStep.run).toContain("vp run test:live:artifact"); + const buildSteps = buildJob.steps ?? []; + expect(buildSteps.indexOf(packStep)).toBeLessThan(buildSteps.indexOf(mcpStep)); + expect(buildSteps.indexOf(mcpStep)).toBeLessThan(buildSteps.indexOf(liveStep)); + expect(buildSteps.indexOf(liveStep)).toBeLessThan(buildSteps.indexOf(imageBuildStep)); + expect(readText("../.github/workflows/pr.yml")).not.toContain("OPENROUTER_API_KEY"); + expect(actionUses.length).toBeGreaterThan(0); + for (const uses of actionUses) { + expect(uses).toMatch(/^[\w.-]+\/[\w./-]+@[0-9a-f]{40}$/); + } }); test("declares writable Environment package managers", () => { - const manifest = readEnvironmentPackageManagerManifest(); - - expect(manifest).toEqual({ + expect(environmentPackageManagers).toEqual({ managers: ["npm", "pip"], schemaVersion: 1, }); }); - test("keeps the standalone package out of Mosoo workspace dependencies", () => { - const packageJson = readDriverPackageJson(); - const deps = Object.keys(packageJson.dependencies ?? {}); - const tsconfig = readText("../tsconfig.json"); - const typesTsconfig = readText("../tsconfig.types.json"); + test("keeps the standalone package independent of Mosoo workspace packages", () => { + const deps = Object.keys(packageJson.dependencies); expect(deps.filter((dependency) => dependency.startsWith("@mosoo/"))).toEqual([]); - expect(packageJson.dependencies).not.toHaveProperty("@cfworker/json-schema"); - expect(packageJson.dependencies).toHaveProperty("fflate"); - expect(packageJson.dependencies).toHaveProperty("vestig"); - expect(tsconfig).not.toContain("../../dev/"); - expect(tsconfig).not.toContain('"extends"'); - expect(typesTsconfig).not.toContain("../../dev/"); - expect(typesTsconfig).toContain('"declaration": true'); - expect(typesTsconfig).toContain('"emitDeclarationOnly": true'); - expect(typesTsconfig).toContain('"outDir": "dist/types"'); }); }); diff --git a/tests/driver-artifact-live.test.ts b/tests/driver-artifact-live.test.ts index a30b404..87c1843 100644 --- a/tests/driver-artifact-live.test.ts +++ b/tests/driver-artifact-live.test.ts @@ -6,12 +6,14 @@ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises" import { tmpdir } from "node:os"; import { isAbsolute, join, resolve } from "node:path"; +import { DRIVER_PROTOCOL_VERSION } from "../src/protocol/boot"; import { DriverArtifactTestController, expectedDriverCapabilities, type DriverArtifactBootPayload, type DriverArtifactTestEvent, } from "./driver-artifact-test-controller"; +import { messageText } from "./driver-event-test-helpers"; const LIVE_START_TIMEOUT_MS = 120_000; const LIVE_TURN_TIMEOUT_MS = 180_000; @@ -282,7 +284,7 @@ function readLiveConfig(): LiveConfig { assertCommandVersion( openAiCommand, ["--version"], - resolve(process.cwd(), "node_modules", "@openai", "codex-sdk", "package.json"), + resolve(process.cwd(), "node_modules", "@openai", "codex", "package.json"), "OpenAI app-server", ); } @@ -343,16 +345,14 @@ const runtimeCases: LiveRuntimeCase[] = [ suite: "anthropic", transport: "claude-agent-sdk", } satisfies LiveRuntimeCase, - ...config.openCodeModels.map( - (model): LiveRuntimeCase => ({ - model, - nativeResumeKind: "acp_session_id", - provider: "openrouter", - runtime: "acp-fallback", - suite: "opencode", - transport: "acp-fallback", - }), - ), + ...config.openCodeModels.map((model): LiveRuntimeCase => ({ + model, + nativeResumeKind: "acp_session_id", + provider: "openrouter", + runtime: "acp-fallback", + suite: "opencode", + transport: "acp-fallback", + })), ].filter((runtimeCase) => config.suite === "all" || config.suite === runtimeCase.suite); const representativeOpenCodeModel = config.openCodeModels.find((model) => model.includes("/deepseek/")) ?? config.openCodeModels[0]!; @@ -384,26 +384,25 @@ function payloadRecord(event: DriverArtifactTestEvent): Record } function eventText(events: readonly DriverArtifactTestEvent[]): string { - const finalText = events + const finalMessageId = events .filter((event) => event.kind === "run.completed") - .map((event) => payloadRecord(event)["finalMessageText"]) + .map((event) => payloadRecord(event)["finalMessageId"]) .findLast((value): value is string => typeof value === "string" && value.length > 0); - if (finalText !== undefined) { - return finalText; + if (finalMessageId !== undefined) { + return messageText(events, finalMessageId); } - return events - .flatMap((event) => { - const payload = payloadRecord(event); - - if (event.kind === "message.delta" && typeof payload["contentDelta"] === "string") { - return [payload["contentDelta"]]; + const messageIds = new Set( + events.flatMap((event) => { + if (event.kind !== "message.added" && event.kind !== "message.delta") { + return []; } - - return []; - }) - .join(""); + const messageId = payloadRecord(event)["messageId"]; + return typeof messageId === "string" ? [messageId] : []; + }), + ); + return [...messageIds].map((messageId) => messageText(events, messageId)).join(""); } function eventOutputText(events: readonly DriverArtifactTestEvent[]): string { @@ -693,9 +692,8 @@ function expectSingleRunLifecycle( if (expectations.requireFinalMessage) { const terminal = terminalEvents[0]!; const finalMessageId = payloadString(terminal, "finalMessageId"); - const finalMessageText = payloadString(terminal, "finalMessageText"); expect(finalMessageId).not.toBeNull(); - expect(finalMessageText).not.toBeNull(); + expect(payloadRecord(terminal)).not.toHaveProperty("finalMessageText"); expect( runEvents.some( (event) => @@ -703,47 +701,8 @@ function expectSingleRunLifecycle( payloadString(event, "messageId") === finalMessageId, ), ).toBe(true); - const completedAgentMessageIds = runEvents - .filter( - (event) => event.kind === "message.completed" && payloadRecord(event)["role"] === "agent", - ) - .map((event) => payloadString(event, "messageId")) - .filter((messageId): messageId is string => messageId !== null); - expect(completedAgentMessageIds.length).toBeGreaterThan(0); - expect(finalMessageId).toBe(completedAgentMessageIds.at(-1)); - - let reconstructedFinalMessageText = ""; - for (const event of runEvents.filter( - (candidate) => - payloadString(candidate, "messageId") === finalMessageId && - (candidate.kind === "message.added" || candidate.kind === "message.delta"), - )) { - const payload = payloadRecord(event); - if (event.kind === "message.delta") { - const contentDelta = payloadString(event, "contentDelta"); - expect(contentDelta).not.toBeNull(); - reconstructedFinalMessageText += contentDelta ?? ""; - continue; - } - - const content = payload["content"]; - reconstructedFinalMessageText = - typeof content === "string" - ? content - : Array.isArray(content) - ? content - .flatMap((block) => { - const text = - typeof block === "object" && block !== null && !Array.isArray(block) - ? (block as Record)["text"] - : null; - return typeof text === "string" ? [text] : []; - }) - .join("") - : reconstructedFinalMessageText; - } + const reconstructedFinalMessageText = messageText(runEvents, finalMessageId!); expect(reconstructedFinalMessageText.length).toBeGreaterThan(0); - expect(finalMessageText).toBe(reconstructedFinalMessageText); } } @@ -838,6 +797,7 @@ function createBootPayload(input: { readonly sessionId: string; }): DriverArtifactBootPayload { const runtimeCase = input.runtimeCase; + const sandboxId = createTestId(); return { bootToken: `artifact-test-${input.driverInstanceId}`, @@ -881,7 +841,7 @@ function createBootPayload(input: { executionOwnerUserId: createTestId(), type: "agent", }, - sandboxId: createTestId(), + sandboxId, sandboxKind: "cattle", sandboxSessionId: createTestId(), sandboxSubjectId: input.sessionId, @@ -897,10 +857,10 @@ function createBootPayload(input: { skills: [], }, heartbeatIntervalMs: 60_000, - protocolVersion: 2, + protocolVersion: DRIVER_PROTOCOL_VERSION, runtime: runtimeCase.runtime, runtimeTransport: runtimeCase.transport, - sandboxId: createTestId(), + sandboxId, traceparent: "00-00000000000000000000000000000001-0000000000000001-01", }; } @@ -2181,6 +2141,7 @@ async function testCancellation(runtimeCase: LiveRuntimeCase): Promise { commandId: startBoundaryCancelId, kind: "turn.cancel", reason: "live.cancel.start_boundary", + runId: startBoundary.runId, }); await controller.waitForCommandUpdate( (update) => update.commandId === startBoundaryCancelId && update.status === "accepted", @@ -2260,7 +2221,12 @@ async function testCancellation(runtimeCase: LiveRuntimeCase): Promise { } }); const cancelId = `cancel-${createTestId()}`; - controller.enqueue({ commandId: cancelId, kind: "turn.cancel", reason: "live.cancel" }); + controller.enqueue({ + commandId: cancelId, + kind: "turn.cancel", + reason: "live.cancel", + runId: active.runId, + }); const [inputUpdate, cancelUpdate, cancelledEvent] = await Promise.all([ controller.waitForCommandTerminal(active.commandId, LIVE_TURN_TIMEOUT_MS), controller.waitForCommandTerminal(cancelId, LIVE_TURN_TIMEOUT_MS), @@ -2293,7 +2259,12 @@ async function testCancellation(runtimeCase: LiveRuntimeCase): Promise { "run.cancelled", ); const replayIndex = controller.commandUpdates.length; - controller.enqueue({ commandId: cancelId, kind: "turn.cancel", reason: "live.cancel" }); + controller.enqueue({ + commandId: cancelId, + kind: "turn.cancel", + reason: "live.cancel", + runId: active.runId, + }); expect( (await controller.waitForCommandTerminal(cancelId, LIVE_STOP_TIMEOUT_MS, replayIndex)) .status, @@ -2313,10 +2284,11 @@ async function testCancellation(runtimeCase: LiveRuntimeCase): Promise { commandId: idleCancelId, kind: "turn.cancel", reason: "live.idle.cancel", + runId: active.runId, }); expect( (await controller.waitForCommandTerminal(idleCancelId, LIVE_STOP_TIMEOUT_MS)).status, - ).toBe("completed"); + ).toBe("failed"); const events = await runTurn( controller, @@ -2430,6 +2402,7 @@ async function testSupervisedPermission(runtimeCase: LiveRuntimeCase): Promise @@ -2511,6 +2485,7 @@ async function testSupervisedPermission(runtimeCase: LiveRuntimeCase): Promise process.stdout.write(JSON.stringify(message) + "\n"); const mcpRequest = async (server, message) => { const response = await fetch(server.url, { @@ -43,14 +46,22 @@ const mcpRequest = async (server, message) => { return message.id === undefined ? undefined : response.json(); }; const handle = async (message) => { - if (!("method" in message) || !("id" in message)) return; + if (!("method" in message)) return; + if (message.method === "session/cancel") { + if (pendingPromptId !== undefined) { + send({ id: pendingPromptId, jsonrpc: "2.0", result: { stopReason: "cancelled" } }); + pendingPromptId = undefined; + } + return; + } + if (!("id" in message)) return; let result; switch (message.method) { case "initialize": result = { agentCapabilities: { mcpCapabilities: { http: true }, - sessionCapabilities: { close: {} }, + sessionCapabilities: { close: {}, resume: {} }, }, authMethods: [], protocolVersion: 1, @@ -85,8 +96,12 @@ const handle = async (message) => { break; } case "session/close": + case "session/resume": result = {}; break; + case "session/prompt": + pendingPromptId = message.id; + return; default: result = {}; } @@ -114,15 +129,18 @@ function mcpCommand( commandId: string, toolName: string, argumentsJson = "{}", + runId: string = driverBootPayload.execution.configRevision.runId, ): DriverArtifactTestCommand { return { argumentsJson, commandId, kind: "mcp.execute", requestId: `request-${commandId}`, + runId, serverId: MCP_SERVER_ID, + toolCallId: `tool-call-${commandId}`, toolName, - }; + } satisfies McpExecuteCommand; } function jsonResponse(id: unknown, result: unknown, headers?: HeadersInit): Response { @@ -158,11 +176,11 @@ artifactTest( const sessions = new Map(); const toolCalls = new Map(); let deleteRequests = 0; - let hangingRequestId: unknown; + let cancellationNotifications = 0; let invalidSessionHeaders = 0; let unauthorizedRequests = 0; const hangStarted = Promise.withResolvers(); - const hangCancelled = Promise.withResolvers(); + const hangReleased = Promise.withResolvers(); const server = Bun.serve({ hostname: "127.0.0.1", port: 0, @@ -206,14 +224,19 @@ artifactTest( }; methods.push(message.method); + if (message.method === "server/discover") { + return Response.json({ + error: { code: -32601, message: "Method not found" }, + id: message.id, + jsonrpc: "2.0", + }); + } if (message.method !== "initialize" && !hasValidSessionHeaders()) { invalidSessionHeaders += 1; return new Response("Invalid MCP session headers.", { status: 400 }); } if (message.method === "notifications/cancelled") { - if (hangingRequestId !== undefined && message.params?.requestId === hangingRequestId) { - hangCancelled.resolve(); - } + cancellationNotifications += 1; return new Response(null, { status: 202 }); } if (message.method === "notifications/initialized" || message.id === undefined) { @@ -303,12 +326,10 @@ artifactTest( }); } if (toolName === "hang") { - hangingRequestId = message.id; hangStarted.resolve(); - await hangCancelled.promise; + await hangReleased.promise; return jsonResponse(message.id, { - content: [{ text: "cancelled", type: "text" }], - isError: true, + content: [{ text: "committed after cancellation", type: "text" }], }); } if (toolName === "tool-error") { @@ -393,6 +414,23 @@ artifactTest( ]); expect(unauthorizedRequests).toBe(0); + const initialRunEventIndex = controller.events.length; + controller.enqueue({ + commandId: "input-mcp-run", + input: { text: "hold MCP run open" }, + kind: "input.start", + requestId: "request-input-mcp-run", + runId: driverBootPayload.execution.configRevision.runId, + }); + await controller.waitForEvent( + (event) => + event.kind === "run.started" && + event.runId === driverBootPayload.execution.configRevision.runId, + initialRunEventIndex, + 10_000, + "initial MCP run start", + ); + const counterCommand = mcpCommand("mcp-counter", "counter"); controller.enqueue(counterCommand); const counter = await controller.waitForCommandTerminal("mcp-counter", 10_000); @@ -444,7 +482,14 @@ artifactTest( controller.enqueue(mcpCommand("mcp-structured", "structured")); expect(await controller.waitForCommandTerminal("mcp-structured", 10_000)).toMatchObject({ result: { - outputText: '{\n "count": 1,\n "status": "ok"\n}', + outputText: JSON.stringify( + { + content: [], + structuredContent: { count: 1, status: "ok" }, + }, + null, + 2, + ), }, status: "completed", }); @@ -455,16 +500,41 @@ artifactTest( commandId: "cancel-mcp-hang", kind: "turn.cancel", reason: "artifact.mcp.test.cancel", + runId: driverBootPayload.execution.configRevision.runId, }); - const [hangUpdate, cancelUpdate] = await Promise.all([ + hangReleased.resolve(); + const [hangUpdate, cancelUpdate, initialRunUpdate] = await Promise.all([ controller.waitForCommandTerminal("mcp-hang", 10_000), controller.waitForCommandTerminal("cancel-mcp-hang", 10_000), + controller.waitForCommandTerminal("input-mcp-run", 10_000), ]); - expect(hangUpdate.status).toBe("cancelled"); + controller.assertHealthy("committed MCP cancellation"); + expect(hangUpdate).toMatchObject({ + result: { outputText: "committed after cancellation" }, + status: "completed", + }); expect(cancelUpdate.status).toBe("completed"); - await withTimeout(hangCancelled.promise, "MCP cancellation notification"); + expect(initialRunUpdate.status).toBe("cancelled"); + expect(cancellationNotifications).toBe(0); - controller.enqueue(mcpCommand("mcp-after-cancel", "echo", '{"value":"recovered"}')); + const recoveryRunEventIndex = controller.events.length; + controller.enqueue({ + commandId: "input-mcp-recovery-run", + input: { text: "hold recovery MCP run open" }, + kind: "input.start", + requestId: "request-input-mcp-recovery-run", + runId: RECOVERY_RUN_ID, + }); + await controller.waitForEvent( + (event) => event.kind === "run.started" && event.runId === RECOVERY_RUN_ID, + recoveryRunEventIndex, + 10_000, + "recovery MCP run start", + ); + + controller.enqueue( + mcpCommand("mcp-after-cancel", "echo", '{"value":"recovered"}', RECOVERY_RUN_ID), + ); expect(await controller.waitForCommandTerminal("mcp-after-cancel", 10_000)).toMatchObject({ result: { outputText: "echo:recovered" }, status: "completed", @@ -474,6 +544,7 @@ artifactTest( commandId: "changed-replay", kind: "turn.cancel", reason: "first reason", + runId: RECOVERY_RUN_ID, }); expect((await controller.waitForCommandTerminal("changed-replay", 10_000)).status).toBe( "completed", @@ -482,6 +553,7 @@ artifactTest( commandId: "changed-replay", kind: "turn.cancel", reason: "changed reason", + runId: RECOVERY_RUN_ID, }); const replayFailure = await controller.waitForRunTerminal(10_000); expect(replayFailure.status).toBe("failed"); @@ -494,6 +566,7 @@ artifactTest( expect(sessions.size).toBeGreaterThan(1); expect(unauthorizedRequests).toBe(0); } finally { + hangReleased.resolve(); await controller?.dispose(); await server.stop(true); await rm(rootPath, { force: true, recursive: true }); diff --git a/tests/driver-artifact-test-controller.test.ts b/tests/driver-artifact-test-controller.test.ts index 6da1147..d12cd9b 100644 --- a/tests/driver-artifact-test-controller.test.ts +++ b/tests/driver-artifact-test-controller.test.ts @@ -3,15 +3,15 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { DRIVER_PROTOCOL_VERSION } from "../src/protocol/boot"; import { ForbiddenSecretScanner, DriverArtifactTestController, type DriverArtifactBootPayload, } from "./driver-artifact-test-controller"; import { - parseDriverCommandUpdateInput, + driverRuntimeRpcSchemas, parseDriverFailureInput, - parseDriverHelloInput, parseDriverLogBatchInput, } from "../src/protocol/orpc"; @@ -39,7 +39,7 @@ await rpc("/driver/hello", { capabilities: [], driverVersion: "test", pid: process.pid, - protocolVersion: 2, + protocolVersion: ${String(DRIVER_PROTOCOL_VERSION)}, runtime: payload.runtime, startedAt: new Date().toISOString(), }); @@ -69,7 +69,6 @@ if (process.env.TEST_MODE === "terminal") { await rpc("/driver/commandUpdate", { commandId: "command-1", driverInstanceId: payload.driverInstanceId, - result: null, status: "completed", }); } @@ -159,7 +158,6 @@ describe("driver artifact test controller", () => { const diagnostics = controller.diagnostics(); expect(diagnostics).toContain('"message": "transport exploded: [redacted]"'); expect(diagnostics).toContain('"stage": "session/new"'); - expect(diagnostics).not.toContain(secret); expect(diagnostics).not.toContain(JSON.stringify(secret).slice(1, -1)); await controller.dispose(); }); @@ -254,38 +252,7 @@ describe("driver artifact test controller", () => { expect(disposeError.message).not.toContain(secret); }); - test("validates structured RPC inputs with the production parsers", () => { - expect(() => - parseDriverHelloInput({ - capabilities: [], - driverVersion: "test", - pid: 0, - protocolVersion: 2, - runtime: "acp-fallback", - startedAt: "now", - }), - ).toThrow("pid must be a positive safe integer"); - expect(() => - parseDriverCommandUpdateInput({ - commandId: "command-1", - driverInstanceId: "driver-1", - status: "invented", - }), - ).toThrow("status is not a supported runtime command status"); - expect( - parseDriverCommandUpdateInput({ - commandId: "command-1", - driverInstanceId: "driver-1", - result: { - isError: true, - outputText: "failed", - requestId: "request-1", - serverId: "server-1", - toolName: "tool-1", - }, - status: "completed", - }).result, - ).toMatchObject({ isError: true }); + test("validates structured RPC input edge cases", () => { expect(() => parseDriverFailureInput({ driverInstanceId: "driver-1", @@ -295,8 +262,16 @@ describe("driver artifact test controller", () => { message: "failed", retryable: false, }, + runId: "run-1", }), ).toThrow("driver failure details.nested must be a primitive value"); + const details = JSON.parse('{"__proto__":"preserved"}') as Record; + const failure = parseDriverFailureInput({ + driverInstanceId: "driver-1", + error: { code: "failed", details, message: "failed", retryable: false }, + runId: "run-1", + }); + expect(Object.hasOwn(failure.error.details, "__proto__")).toBeTrue(); expect(() => parseDriverLogBatchInput({ driverInstanceId: "driver-1", @@ -305,6 +280,32 @@ describe("driver artifact test controller", () => { ).toThrow("driver log level is unsupported"); }); + test("reports nested schema failures without throwing from safeParse", () => { + const commandUpdate = driverRuntimeRpcSchemas.driver.commandUpdate.input.safeParse({ + commandId: "command-1", + driverInstanceId: "driver-1", + result: { outputText: "partial", requestId: "request-1" }, + status: "completed", + }); + expect(commandUpdate.success).toBeFalse(); + expect(commandUpdate.error?.issues).toContainEqual( + expect.objectContaining({ path: ["result"] }), + ); + + const events = driverRuntimeRpcSchemas.driver.pushEvents.input.safeParse({ + driverInstanceId: "driver-1", + events: [{}], + }); + expect(events.success).toBeFalse(); + expect(events.error?.issues[0]?.path).toEqual(["events", 0]); + + const command = driverRuntimeRpcSchemas.driverInstance.nextCommand.output.safeParse({ + command: { commandId: "command-1", kind: "unknown" }, + }); + expect(command.success).toBeFalse(); + expect(command.error?.issues[0]?.path).toEqual(["command"]); + }); + test("scanner carries only the suffix needed for cross-chunk detection", () => { const scanner = new ForbiddenSecretScanner(["sentinel-credential"]); expect(scanner.scan("noise-sentinel-")).toBe(false); diff --git a/tests/driver-artifact-test-controller.ts b/tests/driver-artifact-test-controller.ts index 982a224..cae5e65 100644 --- a/tests/driver-artifact-test-controller.ts +++ b/tests/driver-artifact-test-controller.ts @@ -9,15 +9,25 @@ import { parseDriverCommandUpdateInput, parseDriverCompletionInput, parseDriverEventBatchInput, + parseDriverExternalToolEffectClaimInput, + parseDriverExternalToolEffectObserveInput, + parseDriverExternalToolEffectSettleInput, parseDriverFailureInput, parseDriverHeartbeatInput, parseDriverHelloInput, parseDriverLogBatchInput, parseDriverNextCommandInput, parseDriverReadyInput, + type DriverEventReceipt, type DriverLogEntry, } from "../src/protocol/orpc"; -import type { DriverCapability } from "../src/runtime-command"; +import type { + DriverCapability, + DriverCommandUpdate, + McpExecuteCommandResult, + McpExternalToolEffectState, + RuntimeCommand, +} from "../src/runtime-command"; import { PROCESS_TREE_OWNER_ENV } from "../src/runtimes/child-process"; import { AGENT_DRIVER_PROVIDER_REGISTRY, @@ -39,18 +49,7 @@ export function expectedDriverCapabilities(runtime: string): readonly DriverCapa }); } -export interface DriverArtifactTestCommandUpdate { - readonly commandId: string; - readonly error?: unknown; - readonly result?: unknown; - readonly status: string; -} - -export interface DriverArtifactTestCommand { - readonly commandId: string; - readonly kind: string; - readonly [key: string]: unknown; -} +export type DriverArtifactTestCommand = RuntimeCommand; export interface DriverArtifactEventIngressGate { readonly entered: Promise; @@ -92,6 +91,7 @@ interface DriverExit { interface DriverRunTerminal { readonly error?: unknown; + readonly runId: string; readonly status: "completed" | "failed"; } @@ -103,6 +103,30 @@ interface EventIngressGateState { readonly released: Promise; } +type ArtifactExternalToolEffect = + | { readonly effectId: string; readonly kind: "intent" } + | { + readonly attempt: number; + readonly claimToken: string; + readonly effectId: string; + readonly idempotencyKey: string; + readonly kind: "claimed"; + } + | { + readonly effectId: string; + readonly kind: "succeeded"; + readonly result: McpExecuteCommandResult; + } + | { readonly effectId: string; readonly kind: "unknown" }; + +function toExternalToolEffectState(effect: ArtifactExternalToolEffect): McpExternalToolEffectState { + if (effect.kind === "claimed") { + const { claimToken: _, ...state } = effect; + return state; + } + return structuredClone(effect); +} + interface RpcRequest { readonly id: number | string; readonly input: Record; @@ -294,10 +318,12 @@ function readSameUserLinuxIdentity(pid: number): LinuxProcessIdentity | null { export class DriverArtifactTestController { readonly #bootPayload: DriverArtifactBootPayload; readonly #commands: DriverArtifactTestCommand[] = []; - readonly #commandUpdates: DriverArtifactTestCommandUpdate[] = []; + readonly #commandUpdates: DriverCommandUpdate[] = []; readonly #eventIngressGates = new Set(); readonly #eventIngressObservers = new Set<(event: DriverArtifactTestEvent) => void>(); + readonly #eventReceipts = new Map(); readonly #events: DriverArtifactTestEvent[] = []; + readonly #externalToolEffects = new Map(); readonly #expectedCapabilities: readonly DriverCapability[] | undefined; readonly #forbiddenSecrets: readonly string[]; readonly #heartbeatIntervalMs: number; @@ -401,7 +427,7 @@ export class DriverArtifactTestController { return this.#events; } - get commandUpdates(): readonly DriverArtifactTestCommandUpdate[] { + get commandUpdates(): readonly DriverCommandUpdate[] { return this.#commandUpdates; } @@ -626,7 +652,7 @@ export class DriverArtifactTestController { commandId: string, timeoutMs: number, fromIndex = 0, - ): Promise { + ): Promise { return this.#waitFor( () => this.#commandUpdates @@ -641,11 +667,11 @@ export class DriverArtifactTestController { } async waitForCommandUpdate( - predicate: (update: DriverArtifactTestCommandUpdate) => boolean, + predicate: (update: DriverCommandUpdate) => boolean, fromIndex: number, timeoutMs: number, label: string, - ): Promise { + ): Promise { return this.#waitFor( () => this.#commandUpdates.slice(fromIndex).find(predicate), label, @@ -896,7 +922,7 @@ export class DriverArtifactTestController { runConfig: { commandLeaseMs: 300_000, envPolicy: "strict", - eventBatchMaxSize: 256, + eventBatchMaxSize: 64, organizationPath: this.#organizationPath, }, runId: null, @@ -926,6 +952,15 @@ export class DriverArtifactTestController { const ingressWaits = new Set>(); const accepted = batch.events.map((envelope) => { + const existing = this.#eventReceipts.get(envelope.eventId); + + if (existing !== undefined) { + if (existing.type !== envelope.event.kind) { + throw new Error(`Driver reused event ID ${envelope.eventId} with a changed type.`); + } + return existing; + } + const { event } = envelope; if (event.driverInstanceId !== this.#bootPayload.driverInstanceId) { throw new Error( @@ -970,11 +1005,13 @@ export class DriverArtifactTestController { this.#terminalRunIds.add(event.runId); } this.#nextEventSeq += 1; - return { + const receipt = { eventId: envelope.eventId, seq: this.#nextEventSeq, type: envelope.event.kind, }; + this.#eventReceipts.set(envelope.eventId, receipt); + return receipt; }); await Promise.all(ingressWaits); return { accepted }; @@ -986,23 +1023,90 @@ export class DriverArtifactTestController { return { ok: true }; } case "/driver/commandUpdate": { - const update = parseDriverCommandUpdateInput(input); - this.#assertDriverInstanceId(update.driverInstanceId); - this.#commandUpdates.push({ - commandId: update.commandId, - ...(update.error === undefined ? {} : { error: update.error }), - ...(update.result === undefined ? {} : { result: update.result }), - status: update.status, - }); + const { driverInstanceId, ...update } = parseDriverCommandUpdateInput(input); + this.#assertDriverInstanceId(driverInstanceId); + this.#commandUpdates.push(update); return { ok: true }; } + case "/driver/observeExternalToolEffect": { + const { commandId, driverInstanceId } = parseDriverExternalToolEffectObserveInput(input); + this.#assertDriverInstanceId(driverInstanceId); + const effect = + this.#externalToolEffects.get(commandId) ?? + ({ effectId: `artifact-test-effect-${commandId}`, kind: "intent" } as const); + this.#externalToolEffects.set(commandId, effect); + return toExternalToolEffectState(effect); + } + case "/driver/claimExternalToolEffect": { + const { claimToken, commandId, driverInstanceId } = + parseDriverExternalToolEffectClaimInput(input); + this.#assertDriverInstanceId(driverInstanceId); + const effectId = `artifact-test-effect-${commandId}`; + const effect = + this.#externalToolEffects.get(commandId) ?? ({ effectId, kind: "intent" } as const); + + if (effect.kind === "succeeded" || effect.kind === "unknown") { + return toExternalToolEffectState(effect); + } + if (effect.kind === "claimed") { + if (effect.claimToken === claimToken) { + return toExternalToolEffectState(effect); + } + const unknown = { effectId, kind: "unknown" } as const; + this.#externalToolEffects.set(commandId, unknown); + return unknown; + } + + const claimed = { + attempt: 1, + claimToken, + effectId, + idempotencyKey: effectId, + kind: "claimed", + } as const; + this.#externalToolEffects.set(commandId, claimed); + return toExternalToolEffectState(claimed); + } + case "/driver/settleExternalToolEffect": { + const { claimToken, commandId, driverInstanceId, effectId, settlement } = + parseDriverExternalToolEffectSettleInput(input); + this.#assertDriverInstanceId(driverInstanceId); + const current = + this.#externalToolEffects.get(commandId) ?? ({ effectId, kind: "intent" } as const); + + if (current.effectId !== effectId) { + throw new Error(`External tool effect ${commandId} used a mismatched effect ID.`); + } + if (current.kind === "succeeded" || current.kind === "unknown") { + return toExternalToolEffectState(current); + } + if (current.kind === "intent") { + this.#externalToolEffects.set(commandId, current); + return current; + } + if (current.claimToken !== claimToken) { + const unknown = { effectId, kind: "unknown" } as const; + this.#externalToolEffects.set(commandId, unknown); + return unknown; + } + + const terminal: ArtifactExternalToolEffect = + settlement.kind === "succeeded" + ? { effectId, kind: "succeeded", result: structuredClone(settlement.result) } + : { effectId, kind: "unknown" }; + this.#externalToolEffects.set(commandId, terminal); + return toExternalToolEffectState(terminal); + } case "/driver/completeRun": { const completion = parseDriverCompletionInput(input); this.#assertDriverInstanceId(completion.driverInstanceId); + if (!this.#knownRunIds.has(completion.runId)) { + throw new Error(`Driver completed unknown run ${completion.runId}.`); + } if (this.#runTerminals.length > 0) { throw new Error("Driver emitted more than one control-plane run terminal."); } - const terminal = { status: "completed" } as const; + const terminal = { runId: completion.runId, status: "completed" } as const; this.#runTerminals.push(terminal); for (const observer of this.#runTerminalIngressObservers) { observer(terminal); @@ -1012,10 +1116,13 @@ export class DriverArtifactTestController { case "/driver/failRun": { const failure = parseDriverFailureInput(input); this.#assertDriverInstanceId(failure.driverInstanceId); + if (!this.#knownRunIds.has(failure.runId)) { + throw new Error(`Driver failed unknown run ${failure.runId}.`); + } if (this.#runTerminals.length > 0) { throw new Error("Driver emitted more than one control-plane run terminal."); } - const terminal = { error: failure.error, status: "failed" } as const; + const terminal = { error: failure.error, runId: failure.runId, status: "failed" } as const; this.#runTerminals.push(terminal); for (const observer of this.#runTerminalIngressObservers) { observer(terminal); @@ -1088,7 +1195,9 @@ export class DriverArtifactTestController { #throwProtocolError(label: string): void { if (this.#protocolError !== null) { - throw new Error(`Driver protocol failed while waiting for ${label}: ${this.#protocolError}.`); + throw new Error( + `Driver protocol failed while waiting for ${label}: ${this.#protocolError}.\n${this.diagnostics()}`, + ); } } @@ -1120,7 +1229,7 @@ export class DriverArtifactTestController { waitForRunTerminal( timeoutMs: number, fromIndex = 0, - ): Promise<{ error?: unknown; status: "completed" | "failed" }> { + ): Promise<{ error?: unknown; runId: string; status: "completed" | "failed" }> { return this.#waitFor( () => this.#runTerminals.slice(fromIndex).at(-1), "control-plane run terminal", diff --git a/tests/driver-boot-payload-fixture.ts b/tests/driver-boot-payload-fixture.ts index df9c67b..99b805b 100644 --- a/tests/driver-boot-payload-fixture.ts +++ b/tests/driver-boot-payload-fixture.ts @@ -7,7 +7,7 @@ import type { SandboxId, SandboxSessionId, } from "../src/protocol/boot"; -import { DRIVER_CONTROL_PORT_MIN } from "../src/protocol/boot"; +import { DRIVER_CONTROL_PORT_MIN, DRIVER_PROTOCOL_VERSION } from "../src/protocol/boot"; import type { DriverInstanceId, RunId, SessionId } from "../src/protocol/id"; import { createDriverStartInputFromBootPayload } from "../src/protocol/start"; @@ -85,7 +85,7 @@ export const driverBootPayload = { skills: [], }, heartbeatIntervalMs: 1_000, - protocolVersion: 2, + protocolVersion: DRIVER_PROTOCOL_VERSION, runtime: "openai-runtime", runtimeTransport: "openai-app-server", sandboxId: DRIVER_TEST_IDS.sandboxId, diff --git a/tests/driver-boot-schema.test.ts b/tests/driver-boot-schema.test.ts new file mode 100644 index 0000000..9a35f40 --- /dev/null +++ b/tests/driver-boot-schema.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, test } from "bun:test"; + +import { parseDriverBootPayload } from "../src/protocol/boot"; +import { parseDriverNativeRuntimeRef } from "../src/protocol/runtime"; +import { mergeProviderOptions } from "../src/runtimes/provider-options"; +import { DRIVER_TEST_IDS, driverBootPayload } from "./driver-boot-payload-fixture"; + +describe("Driver boot schema", () => { + test("uses the runtime parser for native refs and ignores inherited fields", () => { + const parsed = parseDriverNativeRuntimeRef({ + kind: "openai_thread_id", + runtimeId: "openai-runtime", + unknown: true, + value: "thread-1", + }); + + expect(parsed).toEqual({ + kind: "openai_thread_id", + runtimeId: "openai-runtime", + value: "thread-1", + }); + expect(() => + parseDriverNativeRuntimeRef( + Object.assign(Object.create({ kind: "openai_thread_id" }), { + runtimeId: "openai-runtime", + value: "thread-1", + }), + ), + ).toThrow(TypeError); + + expect(() => + parseDriverNativeRuntimeRef({ + get kind() { + throw new Error("getter must stay inside the parser boundary"); + }, + runtimeId: "openai-runtime", + value: "thread-1", + }), + ).toThrow(TypeError); + }); + + test("strips unknown fields and canonicalizes IDs", () => { + const ignoredCycle: { self?: unknown } = {}; + ignoredCycle.self = ignoredCycle; + const input = { + ...driverBootPayload, + driverInstanceId: driverBootPayload.driverInstanceId.toLowerCase(), + execution: { ...driverBootPayload.execution, unknownExecutionField: true }, + ignoredCycle, + unknownRootField: true, + }; + Object.defineProperty(input, "ignoredGetter", { + enumerable: true, + get: () => { + throw new Error("unknown fields must not be read"); + }, + }); + const parsed = parseDriverBootPayload(input); + + expect(parsed.driverInstanceId).toBe(driverBootPayload.driverInstanceId); + expect(parsed).not.toHaveProperty("unknownRootField"); + expect(parsed.execution).not.toHaveProperty("unknownExecutionField"); + }); + + test("requires the native resume kind to match its runtime", () => { + expect(() => + parseDriverBootPayload({ + ...driverBootPayload, + execution: { + ...driverBootPayload.execution, + session: { + ...driverBootPayload.execution.session, + nativeResumeRef: { + kind: "claude_session_id", + runtimeId: "openai-runtime", + value: "thread-1", + }, + }, + }, + }), + ).toThrow("does not match runtime openai-runtime"); + + expect(() => + parseDriverBootPayload({ + ...driverBootPayload, + execution: { + ...driverBootPayload.execution, + session: { + ...driverBootPayload.execution.session, + nativeResumeRef: { + kind: "claude_session_id", + runtimeId: "claude-agent-sdk", + value: "session-1", + }, + }, + }, + }), + ).toThrow("native resume runtime claude-agent-sdk does not match runtime openai-runtime"); + + expect(() => + parseDriverBootPayload({ + ...driverBootPayload, + runtimeTransport: "claude-agent-sdk", + }), + ).toThrow("runtime openai-runtime does not match transport claude-agent-sdk"); + }); + + test("preserves arbitrary JSON option keys and rejects non-JSON values", () => { + const providerOptions = JSON.parse('{"__proto__":{"enabled":true}}') as unknown; + const sparseOptions: unknown[] = []; + sparseOptions.length = 1; + const parsed = parseDriverBootPayload({ + ...driverBootPayload, + execution: { ...driverBootPayload.execution, providerOptions }, + }); + + expect(Object.hasOwn(parsed.execution.providerOptions, "__proto__")).toBe(true); + expect(parsed.execution.providerOptions["__proto__"]).toEqual({ enabled: true }); + + const merged = mergeProviderOptions({}, parsed.execution.providerOptions); + expect(Object.getPrototypeOf(merged)).toBe(Object.prototype); + expect(Object.hasOwn(merged, "__proto__")).toBe(true); + expect(merged).not.toHaveProperty("enabled"); + + expect(() => + parseDriverBootPayload({ + ...driverBootPayload, + execution: { ...driverBootPayload.execution, providerOptions: { invalid: Infinity } }, + }), + ).toThrow("must be JSON-serializable"); + + for (const invalid of [new Date(), new Map(), new Set(), sparseOptions]) { + expect(() => + parseDriverBootPayload({ + ...driverBootPayload, + execution: { ...driverBootPayload.execution, providerOptions: invalid }, + }), + ).toThrow(); + } + }); + + test("ignores inherited fields at every object boundary", () => { + const { bootToken: _bootToken, ...withoutBootToken } = driverBootPayload; + expect(() => + parseDriverBootPayload( + Object.assign(Object.create({ bootToken: "inherited" }), withoutBootToken), + ), + ).toThrow("bootToken"); + + const { model: _model, ...executionWithoutModel } = driverBootPayload.execution; + expect(() => + parseDriverBootPayload({ + ...driverBootPayload, + execution: Object.assign( + Object.create({ model: driverBootPayload.execution.model }), + executionWithoutModel, + ), + }), + ).toThrow("model"); + }); + + test("applies defaults while keeping absent optional fields absent", () => { + const { + providerOptions: _providerOptions, + session: originalSession, + ...execution + } = driverBootPayload.execution; + const { recoveryMessages: _recoveryMessages, ...session } = originalSession; + const parsed = parseDriverBootPayload({ + ...driverBootPayload, + execution: { + ...execution, + environment: { ...execution.environment, paths: undefined }, + permissionPolicy: null, + session: { + ...session, + mcpServers: [ + { + authType: "token", + authorizationState: "disabled", + credentialScope: "sandbox", + credentialStatus: "disabled", + name: "disabled-server", + serverId: DRIVER_TEST_IDS.agentId, + subjectLabel: undefined, + }, + ], + recoveryMessages: undefined, + }, + skillCatalog: [ + { + frontmatter: {}, + mountPath: "/skills/example", + resolutionMode: "explicit", + skillId: DRIVER_TEST_IDS.agentId, + skillName: "example", + }, + ], + skills: [ + { + archiveFormat: "zip", + blobSha256: "sha256", + compression: "deflate", + downloadUrl: "artifact://skill", + materializationStatus: "ready", + mountPath: "/skills/example", + resolutionMode: "explicit", + skillId: DRIVER_TEST_IDS.agentId, + skillName: "example", + snapshotId: undefined, + warningCode: undefined, + }, + ], + }, + }); + + expect(parsed.execution.permissionPolicy).toBe("full_access"); + expect(parsed.execution.providerOptions).toEqual({}); + expect(parsed.execution.session.recoveryMessages).toEqual([]); + expect(parsed.execution.environment).not.toHaveProperty("paths"); + expect(parsed.execution.session.mcpServers[0]).not.toHaveProperty("subjectLabel"); + expect(parsed.execution.skills[0]).not.toHaveProperty("snapshotId"); + expect(parsed.execution.skills[0]).not.toHaveProperty("warningCode"); + expect(parsed.execution.skillCatalog[0]?.frontmatter).toEqual({ + author: null, + description: null, + version: null, + }); + }); + + test("rejects array-shaped environment variables", () => { + expect(() => + parseDriverBootPayload({ + ...driverBootPayload, + execution: { + ...driverBootPayload.execution, + environment: { variables: [["NAME", "value"]] }, + }, + }), + ).toThrow("must be an object"); + }); + + test("rejects inherited sparse array entries", () => { + const directories: string[] = []; + directories.length = 1; + Array.prototype[0] = "/inherited"; + try { + expect(() => + parseDriverBootPayload({ + ...driverBootPayload, + execution: { + ...driverBootPayload.execution, + session: { ...driverBootPayload.execution.session, additionalDirectories: directories }, + }, + }), + ).toThrow(); + } finally { + delete Array.prototype[0]; + } + }); + + test("rejects unsupported control URL protocols", () => { + expect(() => + parseDriverBootPayload({ ...driverBootPayload, controlUrl: "file:///tmp/socket" }), + ).toThrow("must use http, https, ws, or wss"); + expect(() => parseDriverBootPayload({ ...driverBootPayload, controlUrl: "not-url" })).toThrow( + TypeError, + ); + }); + + test.each([ + "", + "00-00000000000000000000000000000000-0000000000000001-01", + "00-00000000000000000000000000000001-0000000000000000-01", + "00-0000000000000000000000000000000g-0000000000000001-01", + "00-0000000000000000000000000000000A-0000000000000001-01", + "00-00000000000000000000000000000001-0000000000000001", + ])("rejects invalid W3C traceparent %p", (traceparent) => { + expect(() => parseDriverBootPayload({ ...driverBootPayload, traceparent })).toThrow( + "traceparent", + ); + }); +}); diff --git a/tests/driver-event-publisher-admission.test.ts b/tests/driver-event-publisher-admission.test.ts index 4fc7c95..e0cec52 100644 --- a/tests/driver-event-publisher-admission.test.ts +++ b/tests/driver-event-publisher-admission.test.ts @@ -1,46 +1,21 @@ import { describe, expect, test } from "bun:test"; -import { pushLosslessEvents, withSourceEventIds } from "../src/core/driver-runtime-io"; +import { + DriverEventRejectedError, + pushLosslessEvents, + withSourceEventIds, +} from "../src/core/driver-runtime-io"; import { toDriverEventEnvelopes } from "../src/infrastructure/runtime/driver-instance-socket"; -import { createBufferedSinkLogger } from "../src/observability"; import type { DriverEventInput } from "../src/protocol/events"; import { isDriverId } from "../src/protocol/id"; import type { RunId } from "../src/protocol/id"; import type { DriverEventBatchOutput } from "../src/protocol/orpc"; -import { createAgentDriverContext } from "../src/core/agent-driver-backend"; -import { DriverEventPublisher } from "../src/runtimes/driver-event-publisher"; +import { + DriverCompletedTerminalSupersededError, + DriverEventPublisher, +} from "../src/runtimes/driver-event-publisher"; import { DRIVER_TEST_IDS, driverBootPayload } from "./driver-boot-payload-fixture"; -import { bootPayload } from "./driver-runtime-boundary-fixtures"; - -function createTestLogger() { - return createBufferedSinkLogger({ - level: "debug", - service: "driver-event-publisher-test", - sink: async () => {}, - }); -} - -function createEvent(kind: "message.started" | "message.completed"): DriverEventInput { - return { - kind, - payload: { - messageId: "message-1", - ...(kind === "message.started" ? { role: "agent" } : { stopReason: "end_turn" }), - }, - }; -} - -function createDelta(contentDelta: string): DriverEventInput { - return { - delivery: "best_effort", - kind: "message.delta", - payload: { - contentDelta, - messageId: "message-1", - role: "agent", - }, - }; -} +import { createContext, createDelta, createEvent, kinds } from "./driver-event-publisher-fixture"; function createRunTerminal( kind: "run.cancelled" | "run.completed" | "run.failed", @@ -57,29 +32,204 @@ function createRunTerminal( }; } -function kinds(batches: readonly (readonly DriverEventInput[])[]): string[][] { - return batches.map((batch) => batch.map((event) => event.kind)); +function acceptEvents( + events: readonly DriverEventInput[], + sequence: (index: number) => number = (index) => index + 1, +): DriverEventBatchOutput { + return { + accepted: events.map((event, index) => ({ + eventId: event.sourceEventId!, + seq: sequence(index), + type: event.kind, + })), + }; } -function createContext(input: { - currentRunId?: () => RunId | null; - pushEvents: (events: DriverEventInput[], signal?: AbortSignal) => Promise; -}) { - return createAgentDriverContext({ - eventSink: { - commandUpdate: async () => {}, - ...(input.currentRunId === undefined ? {} : { currentRunId: input.currentRunId }), - pushEvents: async ({ events, signal }) => input.pushEvents(events, signal), - }, - logger: createTestLogger(), - payload: bootPayload, - permission: { - request: async () => "reject_once", - }, +describe("DriverEventPublisher", () => { + test("joins an unresolved lossless draft by explicit identity", async () => { + const attempts: DriverEventInput[][] = []; + const context = createContext({ + pushEvents: async (events) => { + attempts.push(events); + + if (attempts.length === 1) { + throw new Error("response lost"); + } + + return acceptEvents(events); + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + const draft = { + ...createEvent("message.completed"), + sourceEventId: "stable-source-event-id", + }; + + await expect(publisher.push(context, "first", [draft])).rejects.toThrow("response lost"); + await expect( + publisher.push(context, "retry", [structuredClone(draft)]), + ).resolves.toBeUndefined(); + + expect(kinds(attempts)).toEqual([["message.completed"], ["message.completed"]]); + expect(attempts[1]?.[0]?.sourceEventId).toBe(attempts[0]?.[0]?.sourceEventId); + }); + + test("keeps distinct implicit drafts separate when their content matches", async () => { + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const attempts: DriverEventInput[][] = []; + const context = createContext({ + pushEvents: async (events) => { + attempts.push(events); + if (attempts.length === 1) { + entered.resolve(); + await release.promise; + } + return acceptEvents(events); + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + const first = publisher.push(context, "first", [createEvent("message.completed")]); + + await entered.promise; + const second = publisher.push(context, "second", [createEvent("message.completed")]); + release.resolve(); + await Promise.all([first, second]); + + expect(kinds(attempts)).toEqual([["message.completed"], ["message.completed"]]); + expect(attempts[1]?.[0]?.sourceEventId).not.toBe(attempts[0]?.[0]?.sourceEventId); + }); + + test("joins a retained identity while admitting a fresh event", async () => { + const attempts: DriverEventInput[][] = []; + const context = createContext({ + pushEvents: async (events) => { + attempts.push(events); + if (attempts.length === 1) { + throw new Error("response lost"); + } + return acceptEvents(events); + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + const retained = { + ...createEvent("message.started"), + sourceEventId: "retained-source-event-id", + }; + + await expect(publisher.push(context, "first", [retained])).rejects.toThrow("response lost"); + await publisher.push(context, "retry", [retained, createEvent("message.completed")]); + + expect(kinds(attempts)).toEqual([ + ["message.started"], + ["message.started", "message.completed"], + ]); + expect(attempts[1]?.[0]?.sourceEventId).toBe(attempts[0]?.[0]?.sourceEventId); + }); + + test("settles an identity join when its event is acked before a blocked suffix", async () => { + const prefixEntered = Promise.withResolvers(); + const releasePrefix = Promise.withResolvers(); + const suffixEntered = Promise.withResolvers(); + const releaseSuffix = Promise.withResolvers(); + const attempts: DriverEventInput[][] = []; + const context = createContext({ + pushEvents: async (events) => { + attempts.push(events); + if (attempts.length === 1) { + prefixEntered.resolve(); + await releasePrefix.promise; + return { accepted: [acceptEvents(events).accepted[0]!] }; + } + suffixEntered.resolve(); + await releaseSuffix.promise; + return acceptEvents(events); + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + const draft = { + ...createEvent("message.started"), + sourceEventId: "joined-source-event-id", + }; + const first = publisher.push(context, "first", [draft, createEvent("message.completed")]); + + await prefixEntered.promise; + let joinedSettled = false; + const joined = publisher.push(context, "join", [draft]).then(() => { + joinedSettled = true; + }); + releasePrefix.resolve(); + await suffixEntered.promise; + await Bun.sleep(0); + expect(joinedSettled).toBe(true); + releaseSuffix.resolve(); + await Promise.all([first, joined]); + + expect(kinds(attempts)).toEqual([ + ["message.started", "message.completed"], + ["message.completed"], + ]); + }); + + test("rejects changed content that reuses an unresolved source ID", async () => { + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const attempts: DriverEventInput[][] = []; + const context = createContext({ + pushEvents: async (events) => { + attempts.push(events); + entered.resolve(); + await release.promise; + return acceptEvents(events); + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + const first = publisher.push(context, "first", [ + { ...createEvent("message.started"), sourceEventId: "stable-source-event-id" }, + ]); + + await entered.promise; + await expect( + publisher.push(context, "conflict", [ + { ...createEvent("message.completed"), sourceEventId: "stable-source-event-id" }, + ]), + ).rejects.toThrow("source event ID conflicts"); + release.resolve(); + await first; + + expect(kinds(attempts)).toEqual([["message.started"]]); + }); + + test("propagates an in-flight rejection to an identity join", async () => { + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const attempts: DriverEventInput[][] = []; + const context = createContext({ + pushEvents: async (events) => { + attempts.push(events); + entered.resolve(); + await release.promise; + throw new DriverEventRejectedError(events[0]!.sourceEventId!, new Error("rejected")); + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + const draft = { + ...createEvent("message.completed"), + sourceEventId: "stable-source-event-id", + }; + const first = publisher.push(context, "first", [draft]); + + await entered.promise; + const joined = publisher.push(context, "join", [structuredClone(draft)]); + void first.catch(() => {}); + void joined.catch(() => {}); + release.resolve(); + + await expect(first).rejects.toThrow("rejected"); + await expect(joined).rejects.toThrow("rejected"); + expect(kinds(attempts)).toEqual([["message.completed"]]); }); -} -describe("DriverEventPublisher", () => { test("keeps an explicit source ID across direct lossless retries", async () => { const attempts: DriverEventInput[][] = []; const events = withSourceEventIds([createEvent("message.completed")]); @@ -91,13 +241,7 @@ describe("DriverEventPublisher", () => { throw new Error("response lost"); } - return { - accepted: sent.map((event, index) => ({ - eventId: event.sourceEventId, - seq: index + 1, - type: event.kind, - })), - }; + return acceptEvents(sent); }, }; @@ -108,6 +252,46 @@ describe("DriverEventPublisher", () => { expect(attempts[1]?.[0]?.sourceEventId).toBe(attempts[0]?.[0]?.sourceEventId); }); + test("retries one transport failure after a valid receipt prefix", async () => { + const attempts: DriverEventInput[][] = []; + let sequence = 0; + const port = { + pushEvents: async ({ events }: { events: DriverEventInput[] }) => { + attempts.push(events); + + if (attempts.length === 1) { + return { + accepted: [ + { + eventId: events[0]!.sourceEventId!, + seq: ++sequence, + type: events[0]!.kind, + }, + ], + }; + } + + if (attempts.length === 2) { + throw new Error("temporary transport failure"); + } + + return acceptEvents(events, () => ++sequence); + }, + }; + + await expect( + pushLosslessEvents(port, [createEvent("message.started"), createEvent("message.completed")]), + ).resolves.toHaveLength(2); + + expect(kinds(attempts)).toEqual([ + ["message.started", "message.completed"], + ["message.completed"], + ["message.completed"], + ]); + expect(attempts[1]?.[0]?.sourceEventId).toBe(attempts[0]?.[1]?.sourceEventId); + expect(attempts[2]?.[0]?.sourceEventId).toBe(attempts[0]?.[1]?.sourceEventId); + }); + test.each([ ["without", undefined], ["with", "explicit-source-event-id"], @@ -137,7 +321,7 @@ describe("DriverEventPublisher", () => { return { accepted: [ { - eventId: events[0]?.sourceEventId, + eventId: events[0]!.sourceEventId!, seq: 1, type: events[0]!.kind, }, @@ -145,13 +329,7 @@ describe("DriverEventPublisher", () => { }; } - return { - accepted: events.map((event, index) => ({ - eventId: event.sourceEventId, - seq: index + 2, - type: event.kind, - })), - }; + return acceptEvents(events, (index) => index + 2); }, }; @@ -188,6 +366,7 @@ describe("DriverEventPublisher", () => { return { accepted: events.map((event, index) => ({ + eventId: event.sourceEventId!, seq: 40 + index, type: event.kind, })), @@ -209,7 +388,6 @@ describe("DriverEventPublisher", () => { expect(isDriverId(attempts[0]?.[0]?.sourceEventId)).toBe(true); expect(isDriverId(attempts[1]?.[1]?.sourceEventId)).toBe(true); expect(publisher.lastAcceptedSeq()).toBe(41); - await context.logger.destroy(); }); test("rejects new events when failed delivery fills the bounded queue", async () => { @@ -225,151 +403,6 @@ describe("DriverEventPublisher", () => { await expect( publisher.push(context, "overflow", [createEvent("message.completed")]), ).rejects.toThrow("Driver event queue exceeds 1024 events."); - await context.logger.destroy(); - }); - - test.each(["run.cancelled", "run.completed", "run.failed"] as const)( - "admits one %s event after the lossless queue is full", - async (kind) => { - const attempts: DriverEventInput[][] = []; - const context = createContext({ - pushEvents: async (events) => { - attempts.push(events); - - if (attempts.length === 1) { - throw new Error("socket unavailable"); - } - - return { - accepted: events.map((event, index) => ({ - eventId: event.sourceEventId, - seq: index + 1, - type: event.kind, - })), - }; - }, - }); - const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); - const retained = Array.from({ length: 1_024 }, () => createEvent("message.started")); - - await expect(publisher.push(context, "fill", retained)).rejects.toThrow("socket unavailable"); - await expect( - publisher.push(context, "terminal", [createRunTerminal(kind)]), - ).resolves.toBeUndefined(); - - expect(attempts).toHaveLength(2); - expect(attempts[1]).toHaveLength(1_025); - expect(attempts[1]?.at(-1)?.kind).toBe(kind); - await context.logger.destroy(); - }, - ); - - test("reserves exactly one run terminal slot", async () => { - let sends = 0; - const context = createContext({ - pushEvents: async () => { - sends += 1; - return { accepted: [] }; - }, - }); - const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); - - await expect( - publisher.push(context, "terminal", [ - createRunTerminal("run.completed"), - createRunTerminal("run.failed"), - ]), - ).rejects.toThrow("run terminal slot"); - expect(sends).toBe(0); - await context.logger.destroy(); - }); - - test("bounds the independent run terminal slot by bytes", async () => { - let sends = 0; - const context = createContext({ - pushEvents: async () => { - sends += 1; - return { accepted: [] }; - }, - }); - const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); - const terminal = { - ...createRunTerminal("run.completed"), - payload: { detail: "x".repeat(1_024 * 1_024) }, - }; - - await expect(publisher.push(context, "terminal", [terminal])).rejects.toThrow( - "run terminal batch exceeds 1048576 UTF-8 bytes", - ); - expect(sends).toBe(0); - await context.logger.destroy(); - }); - - test("admits a bounded closing batch through the full regular lossless lane", async () => { - const attempts: DriverEventInput[][] = []; - const context = createContext({ - pushEvents: async (events) => { - attempts.push(events); - - if (attempts.length === 1) { - throw new Error("socket unavailable"); - } - - return { - accepted: events.map((event, index) => ({ - eventId: event.sourceEventId, - seq: index + 1, - type: event.kind, - })), - }; - }, - }); - const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); - const retained = Array.from({ length: 1_024 }, () => createEvent("message.started")); - const closing = [ - createEvent("message.completed"), - { - kind: "thought.completed", - payload: { channel: "summary", thoughtId: "thought-1" }, - } satisfies DriverEventInput, - createRunTerminal("run.completed"), - ]; - - await expect(publisher.push(context, "fill", retained)).rejects.toThrow("socket unavailable"); - await expect(publisher.push(context, "terminal", closing)).resolves.toBeUndefined(); - - expect(attempts[1]).toHaveLength(1_027); - expect(attempts[1]?.slice(-3).map((event) => event.kind)).toEqual([ - "message.completed", - "thought.completed", - "run.completed", - ]); - await context.logger.destroy(); - }); - - test("drops an oversized best-effort prefix without blocking its run terminal", async () => { - const attempts: DriverEventInput[][] = []; - const context = createContext({ - pushEvents: async (events) => { - attempts.push(events); - return { - accepted: events.map((event, index) => ({ - eventId: event.sourceEventId, - seq: index + 1, - type: event.kind, - })), - }; - }, - }); - const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); - - await publisher.push(context, "terminal", [ - ...Array.from({ length: 2_048 }, (_, index) => createDelta(String(index))), - createRunTerminal("run.completed"), - ]); - - expect(kinds(attempts)).toEqual([["run.completed"]]); - await context.logger.destroy(); }); test("does not let a timed-out best-effort lane poison later lossless delivery", async () => { @@ -391,13 +424,7 @@ describe("DriverEventPublisher", () => { signal?.throwIfAborted(); } - return { - accepted: events.map((event, index) => ({ - eventId: event.sourceEventId, - seq: index + 1, - type: event.kind, - })), - }; + return acceptEvents(events); }, }); const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); @@ -410,7 +437,6 @@ describe("DriverEventPublisher", () => { ]); } finally { AbortSignal.timeout = nativeTimeout; - await context.logger.destroy(); } expect(signals).toHaveLength(3); @@ -424,39 +450,31 @@ describe("DriverEventPublisher", () => { const releaseFirstSend = Promise.withResolvers(); const attempts: DriverEventInput[][] = []; let activeRunId = DRIVER_TEST_IDS.runId as RunId; - const logger = createTestLogger(); - const context = createAgentDriverContext({ - eventSink: { - currentRunId: () => activeRunId, - pushEvents: async ({ events }) => { - const canonical = events.flatMap((event) => - toDriverEventEnvelopes(driverBootPayload, event, activeRunId), - ); - attempts.push(canonical.map(({ event }) => event)); + const context = createContext({ + currentRunId: () => activeRunId, + pushEvents: async (events) => { + const canonical = events.flatMap((event) => + toDriverEventEnvelopes(driverBootPayload, event, activeRunId), + ); + attempts.push(canonical.map(({ event }) => event)); - if (attempts.length === 1) { - firstSendEntered.resolve(); - await releaseFirstSend.promise; - } + if (attempts.length === 1) { + firstSendEntered.resolve(); + await releaseFirstSend.promise; + } - if (attempts.length === 2) { - activeRunId = DRIVER_TEST_IDS.thirdRunId; - } + if (attempts.length === 2) { + activeRunId = DRIVER_TEST_IDS.thirdRunId; + } - const accepted = attempts.length === 2 ? canonical.slice(0, 1) : canonical; - return { - accepted: accepted.map((envelope, index) => ({ - eventId: envelope.eventId, - seq: attempts.length * 10 + index, - type: envelope.event.kind, - })), - }; - }, - }, - logger, - payload: bootPayload, - permission: { - request: async () => "reject_once", + const accepted = attempts.length === 2 ? canonical.slice(0, 1) : canonical; + return { + accepted: accepted.map((envelope, index) => ({ + eventId: envelope.eventId, + seq: attempts.length * 10 + index, + type: envelope.event.kind, + })), + }; }, }); const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); @@ -476,83 +494,1341 @@ describe("DriverEventPublisher", () => { [DRIVER_TEST_IDS.runId], ]); expect(attempts[2]?.[0]?.sourceEventId).toBe(attempts[1]?.[1]?.sourceEventId); - await logger.destroy(); }); - test("coalesces concurrent singleton pushes without starving the final terminal", async () => { + test("does not block best-effort producers on transport acknowledgements", async () => { + const firstSendEntered = Promise.withResolvers(); + const releaseFirstSend = Promise.withResolvers(); + const context = createContext({ + pushEvents: async (events) => { + firstSendEntered.resolve(); + await releaseFirstSend.promise; + + return acceptEvents(events); + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + const first = publisher.push(context, "first", [createDelta("a")]); + + await firstSendEntered.promise; + await first; + releaseFirstSend.resolve(); + await publisher.push(context, "flush", [createEvent("message.completed")]); + }); + + test("delivers terminal closures one at a time before the run terminal", async () => { const attempts: DriverEventInput[][] = []; + let seq = 0; const context = createContext({ pushEvents: async (events) => { attempts.push(events); - return { - accepted: events.map((event, index) => ({ - eventId: event.sourceEventId, - seq: index + 1, - type: event.kind, - })), - }; + return acceptEvents(events, () => (seq += 1)); }, }); const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); - const pushes = Array.from({ length: 1_024 }, () => - publisher.push(context, "singleton", [createEvent("message.started")]), + const closures = Array.from({ length: 66 }, (_, index) => ({ + kind: "message.completed", + payload: { messageId: `message-${index}`, stopReason: "end_turn" }, + })) satisfies DriverEventInput[]; + + await publisher.pushTerminal(context, "terminal", closures, createRunTerminal("run.completed")); + + expect(attempts).toHaveLength(67); + expect(attempts.every((events) => events.length === 1)).toBe(true); + expect(attempts.slice(0, -1).every(([event]) => event?.kind === "message.completed")).toBe( + true, ); - pushes.push(publisher.push(context, "terminal", [createRunTerminal("run.completed")])); + expect(attempts.at(-1)?.[0]?.kind).toBe("run.completed"); + }); + + test("bounds the whole terminal settlement by one delivery deadline", async () => { + const acceptedKinds: string[] = []; + const attempts: DriverEventInput[][] = []; + const nativeTimeout = AbortSignal.timeout; + AbortSignal.timeout = () => nativeTimeout(100); + const context = createContext({ + pushEvents: async (events, signal) => { + attempts.push(events); + await new Promise((resolve, reject) => { + const timeout = setTimeout(resolve, 40); + signal?.addEventListener( + "abort", + () => { + clearTimeout(timeout); + reject(signal.reason); + }, + { once: true }, + ); + }); + acceptedKinds.push(...events.map(({ kind }) => kind)); + return acceptEvents(events, (index) => attempts.length + index); + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + let terminal: Promise; + + try { + terminal = publisher.pushTerminal( + context, + "terminal", + [ + { ...createEvent("message.completed"), payload: { messageId: "one" } }, + { ...createEvent("message.completed"), payload: { messageId: "two" } }, + ], + createRunTerminal("run.completed"), + ); + } finally { + AbortSignal.timeout = nativeTimeout; + } - await Promise.all(pushes); + try { + await expect(terminal).rejects.toThrow(); + expect(kinds(attempts)).toEqual([ + ["message.completed"], + ["message.completed"], + ["run.completed"], + ]); + expect(acceptedKinds).toEqual(["message.completed", "message.completed"]); - expect(attempts).toHaveLength(1); - expect(attempts[0]).toHaveLength(1_025); - expect(attempts[0]?.at(-1)?.kind).toBe("run.completed"); - await context.logger.destroy(); + AbortSignal.timeout = () => nativeTimeout(100); + try { + terminal = publisher.pushTerminal( + context, + "terminal.retry", + [ + { ...createEvent("message.completed"), payload: { messageId: "one" } }, + { ...createEvent("message.completed"), payload: { messageId: "two" } }, + ], + createRunTerminal("run.completed"), + ); + } finally { + AbortSignal.timeout = nativeTimeout; + } + await expect(terminal).resolves.toBeUndefined(); + expect(acceptedKinds).toEqual(["message.completed", "message.completed", "run.completed"]); + } finally { + } }); - test("does not block best-effort producers on transport acknowledgements", async () => { - const firstSendEntered = Promise.withResolvers(); - const releaseFirstSend = Promise.withResolvers(); + test("bounds waiting for an earlier drain by the terminal settlement deadline", async () => { + const attempts: DriverEventInput[][] = []; + const drainEntered = Promise.withResolvers(); + const releaseDrain = Promise.withResolvers(); + const nativeTimeout = AbortSignal.timeout; const context = createContext({ pushEvents: async (events) => { - firstSendEntered.resolve(); - await releaseFirstSend.promise; + attempts.push(events); + if (attempts.length === 1) { + drainEntered.resolve(); + await releaseDrain.promise; + } + return acceptEvents(events, (index) => attempts.length + index); + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + const ordinary = publisher.push(context, "ordinary", [createEvent("message.completed")]); + await drainEntered.promise; + AbortSignal.timeout = () => nativeTimeout(50); + let terminal: Promise; - return { - accepted: events.map((event, index) => ({ - eventId: event.sourceEventId, - seq: index + 1, - type: event.kind, - })), - }; + try { + terminal = publisher.pushTerminal( + context, + "terminal", + [], + createRunTerminal("run.completed"), + ); + } finally { + AbortSignal.timeout = nativeTimeout; + } + + try { + await expect(terminal).rejects.toThrow(); + expect(kinds(attempts)).toEqual([["message.completed"]]); + } finally { + releaseDrain.resolve(); + await ordinary; + } + }); + + test("retries the exact pending closure before delivering the run terminal", async () => { + const attempts: DriverEventInput[][] = []; + let seq = 0; + const context = createContext({ + pushEvents: async (events) => { + attempts.push(events); + + if (attempts.length === 1) { + throw new Error("response lost"); + } + + return acceptEvents(events, () => (seq += 1)); }, }); const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); - const first = publisher.push(context, "first", [createDelta("a")]); - await firstSendEntered.promise; - const blocked = await Promise.race([first.then(() => false), Bun.sleep(10).then(() => true)]); - releaseFirstSend.resolve(); - await publisher.push(context, "flush", [createEvent("message.completed")]); + await publisher.pushTerminal( + context, + "terminal", + [createEvent("message.completed")], + createRunTerminal("run.completed"), + ); - expect(blocked).toBe(false); - await context.logger.destroy(); + expect(kinds(attempts)).toEqual([ + ["message.completed"], + ["message.completed"], + ["run.completed"], + ]); + expect(attempts[1]?.[0]?.sourceEventId).toBe(attempts[0]?.[0]?.sourceEventId); }); - test("bounds the number of closing events in a run terminal batch", async () => { - let sends = 0; + test("reuses a retained closure identity when the terminal call is retried", async () => { + const attempts: DriverEventInput[][] = []; + const accepted: DriverEventInput[] = []; + let seq = 0; const context = createContext({ - pushEvents: async () => { - sends += 1; - return { accepted: [] }; + pushEvents: async (events) => { + attempts.push(events); + + if (attempts.length <= 2) { + throw new Error(`socket unavailable ${attempts.length}`); + } + + accepted.push(...events); + return acceptEvents(events, () => (seq += 1)); + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + const closures = [createEvent("message.completed")]; + const terminal = createRunTerminal("run.completed"); + + await expect(publisher.pushTerminal(context, "terminal", closures, terminal)).rejects.toThrow( + "socket unavailable 2", + ); + await expect( + publisher.pushTerminal(context, "terminal.retry", closures, terminal), + ).resolves.toBeUndefined(); + + expect(kinds(attempts)).toEqual([ + ["message.completed"], + ["message.completed"], + ["message.completed"], + ["run.completed"], + ]); + expect(new Set(attempts.slice(0, 3).map(([event]) => event?.sourceEventId)).size).toBe(1); + expect(kinds([accepted])).toEqual([["message.completed", "run.completed"]]); + }); + + test("lets cancellation replace only the unselected completed terminal", async () => { + const closureEntered = Promise.withResolvers(); + const releaseClosure = Promise.withResolvers(); + const attempts: DriverEventInput[][] = []; + let closureAttempts = 0; + let seq = 0; + const context = createContext({ + pushEvents: async (events) => { + attempts.push(events); + + if (events[0]?.kind === "message.completed" && ++closureAttempts <= 2) { + if (closureAttempts === 1) { + closureEntered.resolve(); + await releaseClosure.promise; + } + throw new Error(`closure outcome unknown ${closureAttempts}`); + } + + return acceptEvents(events, () => (seq += 1)); + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + const cancellation = new Error("cancellation won"); + const controller = new AbortController(); + const closure = createEvent("message.completed"); + const completed = createRunTerminal("run.completed"); + const first = publisher.pushTerminal( + context, + "terminal", + [closure], + completed, + controller.signal, + ); + + await closureEntered.promise; + controller.abort(cancellation); + releaseClosure.resolve(); + await expect(first).rejects.toThrow("closure outcome unknown 2"); + await expect( + publisher.pushTerminal(context, "terminal.retry", [closure], completed, controller.signal), + ).rejects.toBeInstanceOf(DriverCompletedTerminalSupersededError); + await publisher.pushTerminal( + context, + "terminal.cancelled", + [], + createRunTerminal("run.cancelled"), + ); + + expect(kinds(attempts)).toEqual([ + ["message.completed"], + ["message.completed"], + ["message.completed"], + ["run.cancelled"], + ]); + expect(new Set(attempts.slice(0, 3).map(([event]) => event?.sourceEventId)).size).toBe(1); + }); + + test("does not start a completed settlement after cancellation was claimed", async () => { + const attempts: DriverEventInput[][] = []; + const context = createContext({ + pushEvents: async (events) => { + attempts.push(events); + return acceptEvents(events); + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + const cancellation = new Error("cancellation already won"); + const controller = new AbortController(); + controller.abort(cancellation); + + expect(() => + publisher.pushTerminal( + context, + "completed", + [createEvent("message.completed")], + createRunTerminal("run.completed"), + controller.signal, + ), + ).toThrow(cancellation); + await publisher.pushTerminal(context, "cancelled", [], createRunTerminal("run.cancelled")); + + expect(kinds(attempts)).toEqual([["run.cancelled"]]); + }); + + test("aborts an in-flight unselected completed terminal", async () => { + const terminalEntered = Promise.withResolvers(); + const attempts: DriverEventInput[][] = []; + const context = createContext({ + pushEvents: async (events, signal) => { + attempts.push(events); + if (events[0]?.kind === "run.completed") { + terminalEntered.resolve(); + await new Promise((_resolve, reject) => + signal?.addEventListener("abort", () => reject(signal.reason), { once: true }), + ); + } + return acceptEvents(events); + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + const cancellation = new Error("cancellation won during delivery"); + const controller = new AbortController(); + const completed = publisher.pushTerminal( + context, + "completed", + [createEvent("message.completed")], + createRunTerminal("run.completed"), + controller.signal, + ); + + await terminalEntered.promise; + controller.abort(cancellation); + const error = await completed.catch((reason: unknown) => reason); + expect(error).toBeInstanceOf(DriverCompletedTerminalSupersededError); + expect((error as Error).cause).toBe(cancellation); + await publisher.pushTerminal(context, "cancelled", [], createRunTerminal("run.cancelled")); + + expect(kinds(attempts)).toEqual([["message.completed"], ["run.completed"], ["run.cancelled"]]); + }); + + test("releases a failed unselected completion when cancellation arrives later", async () => { + const attempts: DriverEventInput[][] = []; + const context = createContext({ + pushEvents: async (events) => { + attempts.push(events); + if (events[0]?.kind === "run.completed") { + throw new Error("completion outcome unknown"); + } + return acceptEvents(events); }, }); const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + const controller = new AbortController(); await expect( - publisher.push(context, "terminal", [ - ...Array.from({ length: 64 }, () => createEvent("message.completed")), + publisher.pushTerminal( + context, + "completed", + [], + createRunTerminal("run.completed"), + controller.signal, + ), + ).rejects.toThrow("completion outcome unknown"); + controller.abort(new Error("cancellation won")); + await expect( + publisher.pushTerminal( + context, + "completed.retry", + [], createRunTerminal("run.completed"), + controller.signal, + ), + ).rejects.toBeInstanceOf(DriverCompletedTerminalSupersededError); + await publisher.pushTerminal(context, "cancelled", [], createRunTerminal("run.cancelled")); + + expect(kinds(attempts)).toEqual([["run.completed"], ["run.completed"], ["run.cancelled"]]); + }); + + test("joins one in-flight terminal operation and rejects a different occurrence", async () => { + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const attempts: DriverEventInput[][] = []; + let seq = 0; + const context = createContext({ + pushEvents: async (events) => { + attempts.push(events); + + if (attempts.length === 1) { + entered.resolve(); + await release.promise; + } + + return acceptEvents(events, () => (seq += 1)); + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + const closure = createEvent("message.completed"); + const terminal = createRunTerminal("run.completed"); + const first = publisher.pushTerminal(context, "terminal", [closure], terminal); + const joined = publisher.pushTerminal(context, "terminal.join", [closure], terminal); + + await entered.promise; + expect(() => + publisher.pushTerminal( + context, + "terminal.other", + [{ ...closure, sourceEventId: "explicit-other-closure" }], + { ...terminal, sourceEventId: "explicit-other-terminal" }, + ), + ).toThrow("settlement slot is full"); + release.resolve(); + await Promise.all([first, joined]); + + expect(kinds(attempts)).toEqual([["message.completed"], ["run.completed"]]); + }); + + test("continues a partially delivered terminal operation without duplicating closures", async () => { + const attempts: DriverEventInput[][] = []; + const accepted: DriverEventInput[] = []; + let seq = 0; + const context = createContext({ + pushEvents: async (events) => { + attempts.push(events); + + if (attempts.length === 2 || attempts.length === 3) { + throw new Error(`closure unavailable ${attempts.length}`); + } + + accepted.push(...events); + return acceptEvents(events, () => (seq += 1)); + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + const closures = [ + { ...createEvent("message.completed"), payload: { messageId: "one" } }, + { ...createEvent("message.completed"), payload: { messageId: "two" } }, + ] satisfies DriverEventInput[]; + const terminal = createRunTerminal("run.completed"); + + await expect(publisher.pushTerminal(context, "terminal", closures, terminal)).rejects.toThrow( + "closure unavailable 3", + ); + await publisher.pushTerminal(context, "terminal.retry", closures, terminal); + + expect(accepted.map((event) => event.kind)).toEqual([ + "message.completed", + "message.completed", + "run.completed", + ]); + expect( + accepted.filter( + (event) => + event.kind === "message.completed" && + (event.payload as { messageId?: string }).messageId === "one", + ), + ).toHaveLength(1); + expect(new Set(attempts.slice(1, 4).map(([event]) => event?.sourceEventId)).size).toBe(1); + }); + + test("keeps a failed terminal settlement reserved until the same run retries it", async () => { + const attempts: DriverEventInput[][] = []; + let activeRunId: RunId | null = DRIVER_TEST_IDS.runId; + let available = false; + let seq = 0; + const context = createContext({ + currentRunId: () => activeRunId, + pushEvents: async (events) => { + attempts.push(events); + + if (!available) { + throw new Error("terminal transport unavailable"); + } + + return acceptEvents(events, () => (seq += 1)); + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + const closure = createEvent("message.completed"); + const terminal = createRunTerminal("run.completed"); + + await expect(publisher.pushTerminal(context, "terminal", [closure], terminal)).rejects.toThrow( + "terminal transport unavailable", + ); + activeRunId = DRIVER_TEST_IDS.secondRunId; + await expect( + publisher.pushSession(context, "session", [ + { + kind: "agent.task.updated", + payload: { active: false, status: "completed", taskId: "agent-1" }, + }, ]), - ).rejects.toThrow("run terminal batch exceeds 64 events"); + ).rejects.toThrow("terminal settlement slot is full"); + expect(() => + publisher.pushTerminal(context, "next-run", [], { + ...terminal, + runId: DRIVER_TEST_IDS.secondRunId, + }), + ).toThrow("terminal settlement slot is full"); + + activeRunId = DRIVER_TEST_IDS.runId; + available = true; + await publisher.pushTerminal(context, "terminal.retry", [closure], terminal); + + expect(kinds(attempts)).toEqual([ + ["message.completed"], + ["message.completed"], + ["message.completed"], + ["run.completed"], + ]); + expect(attempts.flat().some((event) => event.kind === "agent.task.updated")).toBe(false); + }); + + test("does not let a session push cross a terminal installed at its await boundary", async () => { + const attempts: DriverEventInput[][] = []; + const context = createContext({ + pushEvents: async (events) => { + attempts.push(events); + return acceptEvents(events); + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + const session = publisher.pushSession(context, "session", [ + { + kind: "agent.task.updated", + payload: { active: false, status: "completed", taskId: "agent-1" }, + }, + ]); + const terminal = publisher.pushTerminal( + context, + "terminal", + [], + createRunTerminal("run.completed"), + ); + + await expect(session).rejects.toThrow("terminal settlement slot is full"); + await terminal; + expect(kinds(attempts)).toEqual([["run.completed"]]); + }); + + test("adopts the identity of a matching generic pending closure", async () => { + const attempts: DriverEventInput[][] = []; + let seq = 0; + const context = createContext({ + pushEvents: async (events) => { + attempts.push(events); + + if (attempts.length === 1) { + throw new Error("generic closure unavailable"); + } + + return acceptEvents(events, () => (seq += 1)); + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + const closure = createEvent("message.completed"); + + await expect(publisher.push(context, "closure", [closure])).rejects.toThrow( + "generic closure unavailable", + ); + await publisher.pushTerminal( + context, + "terminal", + [closure], + createRunTerminal("run.completed"), + ); + + expect(kinds(attempts)).toEqual([ + ["message.completed"], + ["message.completed"], + ["run.completed"], + ]); + expect(attempts[1]?.[0]?.sourceEventId).toBe(attempts[0]?.[0]?.sourceEventId); + }); + + test("does not duplicate a matching closure already in flight", async () => { + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const attempts: DriverEventInput[][] = []; + let seq = 0; + const context = createContext({ + pushEvents: async (events) => { + attempts.push(events); + + if (attempts.length === 1) { + entered.resolve(); + await release.promise; + } + + return acceptEvents(events, () => (seq += 1)); + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + const closure = createEvent("message.completed"); + const ordinary = publisher.push(context, "closure", [closure]); + + await entered.promise; + const terminal = publisher.pushTerminal( + context, + "terminal", + [closure], + createRunTerminal("run.completed"), + ); + void ordinary.catch(() => {}); + void terminal.catch(() => {}); + release.resolve(); + await Promise.all([ordinary, terminal]); + + expect(kinds(attempts)).toEqual([["message.completed"], ["run.completed"]]); + }); + + test("does not resend an accepted in-flight closure while its suffix is blocked", async () => { + const suffixEntered = Promise.withResolvers(); + const releaseSuffix = Promise.withResolvers(); + const attempts: DriverEventInput[][] = []; + const accepted: DriverEventInput[] = []; + let seq = 0; + const context = createContext({ + pushEvents: async (events) => { + attempts.push(events); + + if (attempts.length === 1) { + accepted.push(events[0]!); + return { + accepted: [ + { + eventId: events[0]!.sourceEventId!, + seq: (seq += 1), + type: events[0]!.kind, + }, + ], + }; + } + + if (attempts.length === 2) { + suffixEntered.resolve(); + await releaseSuffix.promise; + throw new Error("suffix transport unavailable"); + } + + if (attempts.length === 3) { + throw new Error("suffix transport still unavailable"); + } + + accepted.push(...events); + return acceptEvents(events, () => (seq += 1)); + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + const closure = createEvent("message.completed"); + const ordinary = publisher.push(context, "ordinary", [closure, createEvent("message.started")]); + + await suffixEntered.promise; + const terminal = publisher.pushTerminal( + context, + "terminal", + [closure], + createRunTerminal("run.completed"), + ); + void ordinary.catch(() => {}); + void terminal.catch(() => {}); + releaseSuffix.resolve(); + + await expect(ordinary).rejects.toThrow("suffix transport still unavailable"); + await expect(terminal).resolves.toBeUndefined(); + expect(kinds(attempts)).toEqual([ + ["message.completed", "message.started"], + ["message.started"], + ["message.started"], + ["message.started", "run.completed"], + ]); + expect( + accepted.filter((event) => event.sourceEventId === attempts[0]![0]!.sourceEventId), + ).toHaveLength(1); + }); + + test("does not revive a matching in-flight closure rejected by the sink", async () => { + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const attempts: DriverEventInput[][] = []; + const context = createContext({ + pushEvents: async (events) => { + attempts.push(events); + entered.resolve(); + await release.promise; + throw new DriverEventRejectedError( + events[0]!.sourceEventId!, + new Error("closure rejected"), + ); + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + const closure = createEvent("message.completed"); + const ordinary = publisher.push(context, "closure", [closure]); + + await entered.promise; + const terminal = publisher.pushTerminal( + context, + "terminal", + [closure], + createRunTerminal("run.completed"), + ); + void ordinary.catch(() => {}); + void terminal.catch(() => {}); + release.resolve(); + + await expect(ordinary).rejects.toThrow("closure rejected"); + await expect(terminal).rejects.toThrow("closure rejected"); + expect(kinds(attempts)).toEqual([["message.completed"]]); + }); + + test("does not revive a rejected closure while its in-flight suffix is blocked", async () => { + const suffixEntered = Promise.withResolvers(); + const releaseSuffix = Promise.withResolvers(); + const attempts: DriverEventInput[][] = []; + let seq = 0; + const context = createContext({ + pushEvents: async (events) => { + attempts.push(events); + + if (attempts.length === 1) { + throw new DriverEventRejectedError( + events[0]!.sourceEventId!, + new Error("closure rejected"), + ); + } + + suffixEntered.resolve(); + await releaseSuffix.promise; + return acceptEvents(events, () => (seq += 1)); + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + const closure = createEvent("message.completed"); + const ordinary = publisher.push(context, "ordinary", [closure, createEvent("message.started")]); + + await suffixEntered.promise; + const terminal = publisher.pushTerminal( + context, + "terminal", + [closure], + createRunTerminal("run.completed"), + ); + void ordinary.catch(() => {}); + void terminal.catch(() => {}); + releaseSuffix.resolve(); + + await expect(ordinary).rejects.toThrow("closure rejected"); + await expect(terminal).rejects.toThrow("closure rejected"); + expect(kinds(attempts)).toEqual([ + ["message.completed", "message.started"], + ["message.started"], + ]); + }); + + test("does not treat a rejected pending terminal retry as accepted", async () => { + const attempts: DriverEventInput[][] = []; + const context = createContext({ + pushEvents: async (events) => { + attempts.push(events); + + if (attempts.length === 1) { + throw new Error("response lost"); + } + + throw new DriverEventRejectedError( + events[0]!.sourceEventId!, + new Error("terminal rejected"), + ); + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + + await expect( + publisher.pushTerminal(context, "terminal", [], createRunTerminal("run.completed")), + ).rejects.toThrow("terminal rejected"); + expect(kinds(attempts)).toEqual([["run.completed"], ["run.completed"]]); + expect(attempts[1]?.[0]?.sourceEventId).toBe(attempts[0]?.[0]?.sourceEventId); + }); + + test.each([ + ["target", true], + ["unrelated", false], + ] as const)( + "attributes a coalesced pending rejection to the %s event", + async (_name, rejectTarget) => { + const attempts: DriverEventInput[][] = []; + const context = createContext({ + pushEvents: async (events) => { + attempts.push(events); + + if (attempts.length === 1) { + throw new Error("coalesced transport failure"); + } + + if (attempts.length === 2) { + const rejected = rejectTarget + ? events.find((event) => event.kind === "message.completed") + : events.find((event) => event.kind === "message.started"); + throw new DriverEventRejectedError( + rejected!.sourceEventId!, + new Error(rejectTarget ? "target rejected" : "unrelated rejected"), + ); + } + + if (rejectTarget && attempts.length === 3) { + throw new Error("unrelated remains pending"); + } + + return acceptEvents(events); + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + const unrelated = publisher.push(context, "unrelated", [createEvent("message.started")]); + const terminal = publisher.pushTerminal( + context, + "terminal", + [createEvent("message.completed")], + createRunTerminal("run.completed"), + ); + void unrelated.catch(() => {}); + void terminal.catch(() => {}); + + await expect(unrelated).rejects.toThrow("coalesced transport failure"); + + if (rejectTarget) { + await expect(terminal).rejects.toThrow("target rejected"); + expect(attempts.flat().some((event) => event.kind === "run.completed")).toBe(false); + } else { + await expect(terminal).resolves.toBeUndefined(); + expect(attempts.at(-1)?.[0]?.kind).toBe("run.completed"); + } + }, + ); + + test("does not mistake an old full pending queue for an admitted closure", async () => { + const retryEntered = Promise.withResolvers(); + const releaseRetry = Promise.withResolvers(); + const attempts: DriverEventInput[][] = []; + const context = createContext({ + pushEvents: async (events) => { + attempts.push(events); + + if (attempts.length === 1) { + throw new Error("socket unavailable"); + } + + if (attempts.length === 2) { + retryEntered.resolve(); + await releaseRetry.promise; + } + + return acceptEvents(events); + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + const retained = Array.from({ length: 1_024 }, (_, index) => ({ + kind: "message.started", + payload: { messageId: `pending-${index}`, role: "agent" }, + })) satisfies DriverEventInput[]; + + try { + await expect(publisher.push(context, "fill", retained)).rejects.toThrow("socket unavailable"); + await expect( + publisher.pushTerminal( + context, + "terminal", + [createEvent("message.completed")], + createRunTerminal("run.completed"), + ), + ).rejects.toThrow("Driver event queue exceeds 1024 events"); + await retryEntered.promise; + } finally { + releaseRetry.resolve(); + } + + expect(attempts.flat().some((event) => event.kind === "run.completed")).toBe(false); + }); + + test("does not retry a rejected closure through unrelated pending events", async () => { + const firstSendEntered = Promise.withResolvers(); + const releaseFirstSend = Promise.withResolvers(); + const attempts: DriverEventInput[][] = []; + const context = createContext({ + pushEvents: async (events) => { + attempts.push(events); + + if (attempts.length === 1) { + firstSendEntered.resolve(); + await releaseFirstSend.promise; + throw new Error("initial transport failure"); + } + + if (attempts.length === 3) { + throw new Error("old pending still unavailable"); + } + + const envelopes = events.flatMap((event) => + toDriverEventEnvelopes(driverBootPayload, event, DRIVER_TEST_IDS.runId), + ); + return { + accepted: envelopes.map((envelope, index) => ({ + eventId: envelope.eventId, + seq: index + 1, + type: envelope.event.kind, + })), + }; + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + const pending = publisher.push(context, "pending", [createEvent("message.started")]); + void pending.catch(() => {}); + + await firstSendEntered.promise; + const terminal = publisher.pushTerminal( + context, + "terminal", + [{ kind: "invalid.kind", payload: {} } as unknown as DriverEventInput], + createRunTerminal("run.completed"), + ); + void terminal.catch(() => {}); + releaseFirstSend.resolve(); + + await expect(pending).rejects.toThrow("initial transport failure"); + await expect(terminal).rejects.toThrow("unsupported"); + expect(attempts.flat().some((event) => event.kind === "run.completed")).toBe(false); + }); + + test("rejects aggregate terminal bytes before delivering any event", async () => { + let sends = 0; + const context = createContext({ + pushEvents: async () => { + sends += 1; + return { accepted: [] }; + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + const closures = ["one", "two"].map((messageId): DriverEventInput => ({ + kind: "message.completed", + payload: { + messageId, + metadata: { detail: "x".repeat(600 * 1_024) }, + stopReason: "end_turn", + }, + })); + + expect(() => + publisher.pushTerminal(context, "terminal", closures, createRunTerminal("run.completed")), + ).toThrow("run terminal batch exceeds 1048576 UTF-8 bytes"); + expect(sends).toBe(0); + }); + + test("rejects aggregate terminal count before reading payloads", async () => { + let payloadReads = 0; + let sends = 0; + const closure = { + kind: "message.completed", + get payload() { + payloadReads += 1; + return { messageId: "message-1", stopReason: "end_turn" }; + }, + } as DriverEventInput; + const context = createContext({ + pushEvents: async () => { + sends += 1; + return { accepted: [] }; + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + + expect(() => + publisher.pushTerminal( + context, + "terminal", + Array.from({ length: 1_024 }, () => closure), + createRunTerminal("run.completed"), + ), + ).toThrow("exceeds 1024 events"); + expect(payloadReads).toBe(0); + expect(sends).toBe(0); + }); + + test("rejects an oversized run terminal before delivering closures", async () => { + let sends = 0; + const context = createContext({ + pushEvents: async () => { + sends += 1; + return { accepted: [] }; + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + const terminal = { + ...createRunTerminal("run.completed"), + payload: { structuredOutput: "x".repeat(1_024 * 1_024) }, + } satisfies DriverEventInput; + + expect(() => + publisher.pushTerminal(context, "terminal", [createEvent("message.completed")], terminal), + ).toThrow("run terminal batch exceeds 1048576 UTF-8 bytes"); + expect(sends).toBe(0); + }); + + test("rejects a non-run terminal before delivering closures", async () => { + let sends = 0; + const context = createContext({ + pushEvents: async () => { + sends += 1; + return { accepted: [] }; + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + + expect(() => + publisher.pushTerminal( + context, + "terminal", + [createEvent("message.completed")], + createEvent("message.completed"), + ), + ).toThrow("requires a run terminal event"); + expect(sends).toBe(0); + }); + + test.each([ + [ + "closures", + [ + { ...createEvent("message.started"), sourceEventId: "duplicate" }, + { ...createEvent("message.completed"), sourceEventId: "duplicate" }, + ], + createRunTerminal("run.completed"), + ], + [ + "closure and terminal", + [{ ...createEvent("message.completed"), sourceEventId: "duplicate" }], + { ...createRunTerminal("run.completed"), sourceEventId: "duplicate" }, + ], + ] as const)("rejects duplicate source IDs across %s", async (_name, closures, terminal) => { + let sends = 0; + const context = createContext({ + pushEvents: async () => { + sends += 1; + return { accepted: [] }; + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + + expect(() => publisher.pushTerminal(context, "terminal", closures, terminal)).toThrow( + "requires unique source event IDs", + ); + expect(sends).toBe(0); + }); + + test("rejects duplicate source IDs in an ordinary push before delivery", async () => { + let sends = 0; + const context = createContext({ + pushEvents: async () => { + sends += 1; + return { accepted: [] }; + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + + await expect( + publisher.push(context, "duplicate", [ + { ...createEvent("message.started"), sourceEventId: "duplicate" }, + { ...createEvent("message.completed"), sourceEventId: "duplicate" }, + ]), + ).rejects.toThrow("requires unique source event IDs"); + expect(sends).toBe(0); + }); + + test("does not confuse a pending event with a different terminal closure sharing its ID", async () => { + const attempts: DriverEventInput[][] = []; + const context = createContext({ + pushEvents: async (events) => { + attempts.push(events); + + if (attempts.length === 1) { + throw new Error("transport unavailable"); + } + + return acceptEvents(events); + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + const pending = publisher.push(context, "pending", [ + { ...createEvent("message.started"), sourceEventId: "collision" }, + ]); + + await expect(pending).rejects.toThrow("transport unavailable"); + expect(() => + publisher.pushTerminal( + context, + "terminal", + [{ ...createEvent("message.completed"), sourceEventId: "collision" }], + createRunTerminal("run.completed"), + ), + ).toThrow("source event ID conflicts"); + + expect(attempts.flat().some((event) => event.kind === "message.completed")).toBe(false); + expect(attempts.flat().some((event) => event.kind === "run.completed")).toBe(false); + }); + + test.each([ + ["best-effort closure", [createDelta("draft")], createRunTerminal("run.completed")], + ["run terminal closure", [createRunTerminal("run.failed")], createRunTerminal("run.completed")], + [ + "best-effort terminal", + [createEvent("message.completed")], + { ...createRunTerminal("run.completed"), delivery: "best_effort" }, + ], + ] as const)("rejects a %s before delivering any event", async (_name, closures, terminal) => { + let sends = 0; + const context = createContext({ + pushEvents: async () => { + sends += 1; + return { accepted: [] }; + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + + expect(() => publisher.pushTerminal(context, "terminal", closures, terminal)).toThrow(); + expect(sends).toBe(0); + }); + + test("rejects or drops events queued after a run terminal", async () => { + const attempts: DriverEventInput[][] = []; + const context = createContext({ + pushEvents: async (events) => { + attempts.push(events); + return acceptEvents(events); + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + const terminal = publisher.pushTerminal( + context, + "terminal", + [], + createRunTerminal("run.completed"), + ); + const lossless = publisher.push(context, "late-lossless", [createEvent("message.completed")]); + const bestEffort = publisher.push(context, "late-best-effort", [createDelta("late")]); + + await expect(lossless).rejects.toThrow("run terminal settlement slot is full"); + await expect(Promise.all([terminal, bestEffort])).resolves.toEqual([undefined, undefined]); + expect(kinds(attempts)).toEqual([["run.completed"]]); + }); + + test("requires the unique terminal entry point and an attributable run", async () => { + let sends = 0; + const context = createContext({ + currentRunId: () => null, + pushEvents: async () => { + sends += 1; + return { accepted: [] }; + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + const { runId: _, ...unscoped } = createRunTerminal("run.completed"); + + await expect( + publisher.push(context, "terminal", [createRunTerminal("run.completed")]), + ).rejects.toThrow("must use pushTerminal"); + expect(() => publisher.pushTerminal(context, "terminal", [], unscoped)).toThrow( + "requires an active run", + ); + expect(sends).toBe(0); + }); + + test("settles sequential runs after the active run changes", async () => { + const attempts: DriverEventInput[][] = []; + let activeRunId: RunId | null = DRIVER_TEST_IDS.runId; + let seq = 0; + const context = createContext({ + currentRunId: () => activeRunId, + pushEvents: async (events) => { + attempts.push(events); + return acceptEvents(events, () => (seq += 1)); + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + + await publisher.pushTerminal(context, "terminal.one", [], createRunTerminal("run.completed")); + activeRunId = null; + await publisher.push(context, "session.event", [createEvent("message.started")]); + activeRunId = DRIVER_TEST_IDS.secondRunId; + await publisher.pushTerminal(context, "terminal.two", [], { + ...createRunTerminal("run.completed"), + runId: DRIVER_TEST_IDS.secondRunId, + }); + + expect(attempts.map(([event]) => [event?.kind, event?.runId ?? null])).toEqual([ + ["run.completed", DRIVER_TEST_IDS.runId], + ["message.started", null], + ["run.completed", DRIVER_TEST_IDS.secondRunId], + ]); + }); + + test("rejects explicit late events after their run terminal is acknowledged", async () => { + const attempts: DriverEventInput[][] = []; + let activeRunId: RunId | null = DRIVER_TEST_IDS.runId; + const context = createContext({ + currentRunId: () => activeRunId, + pushEvents: async (events) => { + attempts.push(events); + return acceptEvents(events); + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + + await publisher.pushTerminal(context, "terminal", [], createRunTerminal("run.completed")); + activeRunId = null; + + const lateEvents = [ + { ...createEvent("message.completed"), runId: DRIVER_TEST_IDS.runId }, + { + kind: "tool.call.updated" as const, + payload: { status: "completed", toolCallId: "tool-late" }, + runId: DRIVER_TEST_IDS.runId, + }, + { + kind: "file.changed" as const, + payload: { change: "upsert", path: "late.txt" }, + runId: DRIVER_TEST_IDS.runId, + }, + ]; + + for (const event of lateEvents) { + await expect(publisher.push(context, "late.lossless", [event])).rejects.toThrow( + "must target the active run", + ); + await expect( + publisher.push(context, "late.best-effort", [{ ...event, delivery: "best_effort" }]), + ).resolves.toBeUndefined(); + } + + expect(kinds(attempts)).toEqual([["run.completed"]]); + }); + + test("keeps session events unscoped without reopening a settled run", async () => { + const attempts: DriverEventInput[][] = []; + let activeRunId: RunId | null = DRIVER_TEST_IDS.runId; + let seq = 0; + const context = createContext({ + currentRunId: () => activeRunId, + pushEvents: async (events) => { + const canonical = events.flatMap((event) => + toDriverEventEnvelopes(driverBootPayload, event, activeRunId), + ); + attempts.push(canonical.map(({ event }) => event)); + return { + accepted: canonical.map((envelope) => ({ + eventId: envelope.eventId, + seq: (seq += 1), + type: envelope.event.kind, + })), + }; + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + const task = (taskId: string): DriverEventInput => ({ + kind: "agent.task.updated", + payload: { active: false, status: "completed", taskId }, + }); + + await publisher.pushTerminal(context, "terminal", [], createRunTerminal("run.completed")); + await publisher.pushSession(context, "session.same-run", [task("agent-1")]); + await expect( + publisher.push(context, "late-run", [createEvent("message.completed")]), + ).rejects.toThrow("run terminal settlement slot is full"); + activeRunId = DRIVER_TEST_IDS.secondRunId; + await publisher.pushSession(context, "session.next-run", [task("agent-2")]); + + expect(attempts.flat().map((event) => [event.kind, event.runId ?? null])).toEqual([ + ["run.completed", DRIVER_TEST_IDS.runId], + ["agent.task.updated", null], + ["agent.task.updated", null], + ]); + }); + + test("does not let another run displace an in-flight settlement", async () => { + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const attempts: DriverEventInput[][] = []; + const context = createContext({ + pushEvents: async (events) => { + attempts.push(events); + entered.resolve(); + await release.promise; + return acceptEvents(events); + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + const first = publisher.pushTerminal( + context, + "terminal.one", + [], + createRunTerminal("run.completed"), + ); + + await entered.promise; + expect(() => + publisher.pushTerminal(context, "terminal.two", [], { + ...createRunTerminal("run.completed"), + runId: DRIVER_TEST_IDS.secondRunId, + }), + ).toThrow("must target the active run"); + release.resolve(); + await first; + + expect(attempts).toHaveLength(1); + }); + + test("rejects an operation that targets a run other than the active run", async () => { + let sends = 0; + const context = createContext({ + currentRunId: () => DRIVER_TEST_IDS.runId, + pushEvents: async () => { + sends += 1; + return { accepted: [] }; + }, + }); + const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); + + expect(() => + publisher.pushTerminal(context, "terminal", [], { + ...createRunTerminal("run.completed"), + runId: DRIVER_TEST_IDS.secondRunId, + }), + ).toThrow("must target the active run"); expect(sends).toBe(0); - await context.logger.destroy(); }); }); diff --git a/tests/driver-event-publisher-backpressure.test.ts b/tests/driver-event-publisher-backpressure.test.ts index 838dd8e..b48d8c5 100644 --- a/tests/driver-event-publisher-backpressure.test.ts +++ b/tests/driver-event-publisher-backpressure.test.ts @@ -1,125 +1,10 @@ import { describe, expect, test } from "bun:test"; -import { toDriverEventEnvelopes } from "../src/infrastructure/runtime/driver-instance-socket"; -import { createBufferedSinkLogger } from "../src/observability"; import type { DriverEventInput } from "../src/protocol/events"; -import type { RunId } from "../src/protocol/id"; -import type { DriverEventBatchOutput } from "../src/protocol/orpc"; -import { createAgentDriverContext } from "../src/core/agent-driver-backend"; import { DriverEventPublisher } from "../src/runtimes/driver-event-publisher"; -import { DRIVER_TEST_IDS, driverBootPayload } from "./driver-boot-payload-fixture"; -import { bootPayload } from "./driver-runtime-boundary-fixtures"; - -function createTestLogger() { - return createBufferedSinkLogger({ - level: "debug", - service: "driver-event-publisher-test", - sink: async () => {}, - }); -} - -function createEvent(kind: "message.started" | "message.completed"): DriverEventInput { - return { - kind, - payload: { - messageId: "message-1", - ...(kind === "message.started" ? { role: "agent" } : { stopReason: "end_turn" }), - }, - }; -} - -function createDelta(contentDelta: string): DriverEventInput { - return { - delivery: "best_effort", - kind: "message.delta", - payload: { - contentDelta, - messageId: "message-1", - role: "agent", - }, - }; -} - -function createRunTerminal( - kind: "run.cancelled" | "run.completed" | "run.failed", -): DriverEventInput { - return { - kind, - payload: - kind === "run.failed" - ? { error: { code: "runtime_failed", message: "failed", retryable: false } } - : kind === "run.cancelled" - ? { requestedBy: "user", stopReason: "cancelled" } - : {}, - runId: DRIVER_TEST_IDS.runId, - }; -} - -function createUnscopedRunTerminal( - kind: "run.cancelled" | "run.completed" | "run.failed", -): DriverEventInput { - const { runId: _, ...event } = createRunTerminal(kind); - return event; -} - -function kinds(batches: readonly (readonly DriverEventInput[])[]): string[][] { - return batches.map((batch) => batch.map((event) => event.kind)); -} - -function createContext(input: { - currentRunId?: () => RunId | null; - pushEvents: (events: DriverEventInput[], signal?: AbortSignal) => Promise; -}) { - return createAgentDriverContext({ - eventSink: { - commandUpdate: async () => {}, - ...(input.currentRunId === undefined ? {} : { currentRunId: input.currentRunId }), - pushEvents: async ({ events, signal }) => input.pushEvents(events, signal), - }, - logger: createTestLogger(), - payload: bootPayload, - permission: { - request: async () => "reject_once", - }, - }); -} +import { createContext, createDelta, createEvent, kinds } from "./driver-event-publisher-fixture"; describe("DriverEventPublisher", () => { - test("attributes a coalesced poison event only to its caller", async () => { - const delivered: DriverEventInput[] = []; - let nextSeq = 1; - const context = createContext({ - pushEvents: async (events) => { - const envelopes = events.flatMap((event) => - toDriverEventEnvelopes(driverBootPayload, event, DRIVER_TEST_IDS.runId), - ); - delivered.push(...envelopes.map(({ event }) => event)); - return { - accepted: envelopes.map((envelope) => ({ - eventId: envelope.eventId, - seq: nextSeq++, - type: envelope.event.kind, - })), - }; - }, - }); - const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); - const poison = publisher.push(context, "poison", [ - { kind: "invalid.kind", payload: {} } as unknown as DriverEventInput, - ]); - const terminal = publisher.push(context, "terminal", [createRunTerminal("run.completed")]); - - const outcomes = await Promise.allSettled([poison, terminal]); - - expect(outcomes[0]).toMatchObject({ - reason: expect.objectContaining({ message: expect.stringContaining("unsupported") }), - status: "rejected", - }); - expect(outcomes[1]).toEqual({ status: "fulfilled", value: undefined }); - expect(kinds([delivered])).toEqual([["run.completed"]]); - await context.logger.destroy(); - }); - test("resolves only fully accepted callers after a coalesced partial failure", async () => { const attempts: DriverEventInput[][] = []; const context = createContext({ @@ -128,7 +13,7 @@ describe("DriverEventPublisher", () => { const accepted = attempts.length === 1 ? events.slice(0, 1) : []; return { accepted: accepted.map((event, index) => ({ - eventId: event.sourceEventId, + eventId: event.sourceEventId!, seq: index + 1, type: event.kind, })), @@ -150,308 +35,8 @@ describe("DriverEventPublisher", () => { ["message.started", "message.completed"], ["message.completed"], ]); - await context.logger.destroy(); - }); - - test("treats an identical pending run terminal retry as one idempotent delivery", async () => { - const attempts: DriverEventInput[][] = []; - const context = createContext({ - pushEvents: async (events) => { - attempts.push(events); - - if (attempts.length === 1) { - throw new Error("socket unavailable"); - } - - return { - accepted: events.map((event, index) => ({ - eventId: event.sourceEventId, - seq: index + 1, - type: event.kind, - })), - }; - }, - }); - const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); - const terminal = createRunTerminal("run.completed"); - - await expect(publisher.push(context, "terminal", [terminal])).rejects.toThrow( - "socket unavailable", - ); - await expect(publisher.push(context, "terminal.retry", [terminal])).resolves.toBeUndefined(); - - expect(attempts).toHaveLength(2); - expect(attempts[1]?.[0]?.sourceEventId).toBe(attempts[0]?.[0]?.sourceEventId); - await context.logger.destroy(); - }); - - test("does not join the same raw terminal after the active run changes", async () => { - const attempts: DriverEventInput[][] = []; - const oldRunRecovered = Promise.withResolvers(); - let activeRunId = DRIVER_TEST_IDS.runId as RunId; - const context = createContext({ - currentRunId: () => activeRunId, - pushEvents: async (events) => { - attempts.push(events); - - if (attempts.length === 1) { - throw new Error("socket unavailable"); - } - - if (attempts.length === 2) { - oldRunRecovered.resolve(); - } - - return { - accepted: events.map((event, index) => ({ - eventId: event.sourceEventId, - seq: index + 1, - type: event.kind, - })), - }; - }, - }); - const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); - const terminal = createUnscopedRunTerminal("run.completed"); - - await expect(publisher.push(context, "run-1", [terminal])).rejects.toThrow( - "socket unavailable", - ); - activeRunId = DRIVER_TEST_IDS.secondRunId; - await expect(publisher.push(context, "run-2", [terminal])).rejects.toThrow("run terminal slot"); - await oldRunRecovered.promise; - await Bun.sleep(0); - await publisher.push(context, "run-2.retry", [terminal]); - - expect(attempts.map(([event]) => event?.runId)).toEqual([ - DRIVER_TEST_IDS.runId, - DRIVER_TEST_IDS.runId, - DRIVER_TEST_IDS.secondRunId, - ]); - expect(attempts[1]?.[0]?.sourceEventId).toBe(attempts[0]?.[0]?.sourceEventId); - expect(attempts[2]?.[0]?.sourceEventId).not.toBe(attempts[0]?.[0]?.sourceEventId); - await context.logger.destroy(); - }); - - test.each(["run.cancelled", "run.completed", "run.failed"] as const)( - "joins an old %s retry with an explicit frozen run after the active run changes", - async (kind) => { - const attempts: DriverEventInput[][] = []; - let activeRunId = DRIVER_TEST_IDS.runId as RunId; - const context = createContext({ - currentRunId: () => activeRunId, - pushEvents: async (events) => { - attempts.push(events); - - if (attempts.length === 1) { - throw new Error("socket unavailable"); - } - - return { - accepted: events.map((event, index) => ({ - eventId: event.sourceEventId, - seq: index + 1, - type: event.kind, - })), - }; - }, - }); - const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); - const terminal = createUnscopedRunTerminal(kind); - - await expect(publisher.push(context, "terminal", [terminal])).rejects.toThrow( - "socket unavailable", - ); - activeRunId = DRIVER_TEST_IDS.secondRunId; - await publisher.push(context, "terminal.retry", [ - { ...terminal, runId: DRIVER_TEST_IDS.runId }, - ]); - - expect(attempts.map(([event]) => event?.runId)).toEqual([ - DRIVER_TEST_IDS.runId, - DRIVER_TEST_IDS.runId, - ]); - expect(attempts[1]?.[0]?.sourceEventId).toBe(attempts[0]?.[0]?.sourceEventId); - await context.logger.destroy(); - }, - ); - - test.each([ - [ - "the same active run", - DRIVER_TEST_IDS.runId, - undefined, - DRIVER_TEST_IDS.runId, - DRIVER_TEST_IDS.runId, - ], - [ - "an explicit run override", - DRIVER_TEST_IDS.runId, - DRIVER_TEST_IDS.thirdRunId, - DRIVER_TEST_IDS.secondRunId, - DRIVER_TEST_IDS.thirdRunId, - ], - ["no active run", null, undefined, null, null], - ] as const)( - "keeps terminal retry identity with %s", - async (_name, initialActiveRunId, eventRunId, nextActiveRunId, expectedRunId) => { - const attempts: DriverEventInput[][] = []; - let activeRunId: RunId | null = initialActiveRunId; - const context = createContext({ - currentRunId: () => activeRunId, - pushEvents: async (events) => { - attempts.push(events); - - if (attempts.length === 1) { - throw new Error("socket unavailable"); - } - - return { - accepted: events.map((event, index) => ({ - eventId: event.sourceEventId, - seq: index + 1, - type: event.kind, - })), - }; - }, - }); - const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); - const raw = createUnscopedRunTerminal("run.completed"); - const terminal = eventRunId === undefined ? raw : { ...raw, runId: eventRunId }; - - await expect(publisher.push(context, "terminal", [terminal])).rejects.toThrow( - "socket unavailable", - ); - activeRunId = nextActiveRunId; - await publisher.push(context, "terminal.retry", [terminal]); - - expect(attempts.map(([event]) => event?.runId ?? null)).toEqual([ - expectedRunId, - expectedRunId, - ]); - expect(attempts[1]?.[0]?.sourceEventId).toBe(attempts[0]?.[0]?.sourceEventId); - await context.logger.destroy(); - }, - ); - - test.each([ - ["joins the pending terminal for the same", "source-terminal-1", true], - ["keeps the pending occurrence for a different", "source-terminal-2", false], - ] as const)("%s explicit source ID", async (_name, retrySourceEventId, joinsPending) => { - const attempts: DriverEventInput[][] = []; - const delivered = Promise.withResolvers(); - const context = createContext({ - currentRunId: () => DRIVER_TEST_IDS.runId, - pushEvents: async (events) => { - attempts.push(events); - - if (attempts.length === 1) { - throw new Error("socket unavailable"); - } - - delivered.resolve(); - return { - accepted: events.map((event, index) => ({ - eventId: event.sourceEventId, - seq: index + 1, - type: event.kind, - })), - }; - }, - }); - const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); - const terminal = { - ...createUnscopedRunTerminal("run.completed"), - sourceEventId: "source-terminal-1", - }; - - await expect(publisher.push(context, "terminal", [terminal])).rejects.toThrow( - "socket unavailable", - ); - const retry = publisher.push(context, "terminal.retry", [ - { ...terminal, sourceEventId: retrySourceEventId }, - ]); - - if (joinsPending) { - await expect(retry).resolves.toBeUndefined(); - } else { - await expect(retry).rejects.toThrow("run terminal slot"); - await delivered.promise; - await Bun.sleep(0); - } - - expect(attempts).toHaveLength(2); - expect(attempts.map(([event]) => event?.sourceEventId)).toEqual([ - "source-terminal-1", - "source-terminal-1", - ]); - await context.logger.destroy(); }); - test.each([ - ["ordinary lossless", "ordinary"], - ["different terminal", "terminal"], - ["best effort", "best_effort"], - ] as const)( - "a rejected or dropped %s push wakes a full retained terminal batch without joining it", - async (_name, triggerKind) => { - const attempts: DriverEventInput[][] = []; - const recovered = Promise.withResolvers(); - const context = createContext({ - pushEvents: async (events) => { - attempts.push(events); - - if (attempts.length <= 2) { - throw new Error(`socket unavailable ${attempts.length}`); - } - - recovered.resolve(); - return { - accepted: events.map((event, index) => ({ - eventId: event.sourceEventId, - seq: index + 1, - type: event.kind, - })), - }; - }, - }); - const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); - const retained = Array.from({ length: 1_024 }, () => createEvent("message.started")); - - await expect(publisher.push(context, "fill", retained)).rejects.toThrow( - "socket unavailable 1", - ); - await expect( - publisher.push(context, "terminal", [createRunTerminal("run.completed")]), - ).rejects.toThrow("socket unavailable 2"); - - const trigger = - triggerKind === "ordinary" - ? publisher.push(context, "ordinary.retry", [createEvent("message.completed")]) - : triggerKind === "terminal" - ? publisher.push(context, "terminal.retry", [createRunTerminal("run.failed")]) - : publisher.push(context, "stream.retry", [createDelta("wake")]); - - if (triggerKind === "best_effort") { - await expect(trigger).resolves.toBeUndefined(); - } else { - await expect(trigger).rejects.toThrow( - triggerKind === "terminal" ? "run terminal slot" : "exceeds 1024 events", - ); - } - - expect( - await Promise.race([recovered.promise.then(() => true), Bun.sleep(50).then(() => false)]), - ).toBe(true); - expect(attempts).toHaveLength(3); - expect(attempts[2]?.map((event) => event.sourceEventId)).toEqual( - attempts[1]?.map((event) => event.sourceEventId), - ); - expect(attempts[2]?.at(-1)?.kind).toBe("run.completed"); - await context.logger.destroy(); - }, - ); - test.each([ ["rejected lossless", "lossless"], ["dropped best effort", "best_effort"], @@ -475,7 +60,7 @@ describe("DriverEventPublisher", () => { recovered.resolve(); return { accepted: events.map((event, index) => ({ - eventId: event.sourceEventId, + eventId: event.sourceEventId!, seq: index + 1, type: event.kind, })), @@ -501,38 +86,11 @@ describe("DriverEventPublisher", () => { releaseFirstSend.resolve(); await expect(first).rejects.toThrow("socket unavailable"); - expect( - await Promise.race([recovered.promise.then(() => true), Bun.sleep(50).then(() => false)]), - ).toBe(true); + await recovered.promise; expect(attempts).toHaveLength(2); expect(attempts[1]?.map((event) => event.sourceEventId)).toEqual( attempts[0]?.map((event) => event.sourceEventId), ); - await context.logger.destroy(); }, ); - - test("does not spin while an explicitly retried pending terminal keeps failing", async () => { - let attempts = 0; - const context = createContext({ - currentRunId: () => DRIVER_TEST_IDS.runId, - pushEvents: async () => { - attempts += 1; - throw new Error("socket unavailable"); - }, - }); - const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); - const terminal = createUnscopedRunTerminal("run.completed"); - - await expect(publisher.push(context, "terminal", [terminal])).rejects.toThrow( - "socket unavailable", - ); - await expect(publisher.push(context, "terminal.retry", [terminal])).rejects.toThrow( - "socket unavailable", - ); - await Bun.sleep(20); - - expect(attempts).toBe(2); - await context.logger.destroy(); - }); }); diff --git a/tests/driver-event-publisher-drain.test.ts b/tests/driver-event-publisher-drain.test.ts index 05888ba..63a3877 100644 --- a/tests/driver-event-publisher-drain.test.ts +++ b/tests/driver-event-publisher-drain.test.ts @@ -1,67 +1,10 @@ import { describe, expect, test } from "bun:test"; import { toDriverEventEnvelopes } from "../src/infrastructure/runtime/driver-instance-socket"; -import { createBufferedSinkLogger } from "../src/observability"; import type { DriverEventInput } from "../src/protocol/events"; -import { isDriverId } from "../src/protocol/id"; -import type { RunId } from "../src/protocol/id"; -import type { DriverEventBatchOutput } from "../src/protocol/orpc"; -import { createAgentDriverContext } from "../src/core/agent-driver-backend"; import { DriverEventPublisher } from "../src/runtimes/driver-event-publisher"; import { DRIVER_TEST_IDS, driverBootPayload } from "./driver-boot-payload-fixture"; -import { bootPayload } from "./driver-runtime-boundary-fixtures"; - -function createTestLogger() { - return createBufferedSinkLogger({ - level: "debug", - service: "driver-event-publisher-test", - sink: async () => {}, - }); -} - -function createEvent(kind: "message.started" | "message.completed"): DriverEventInput { - return { - kind, - payload: { - messageId: "message-1", - ...(kind === "message.started" ? { role: "agent" } : { stopReason: "end_turn" }), - }, - }; -} - -function createDelta(contentDelta: string): DriverEventInput { - return { - delivery: "best_effort", - kind: "message.delta", - payload: { - contentDelta, - messageId: "message-1", - role: "agent", - }, - }; -} - -function kinds(batches: readonly (readonly DriverEventInput[])[]): string[][] { - return batches.map((batch) => batch.map((event) => event.kind)); -} - -function createContext(input: { - currentRunId?: () => RunId | null; - pushEvents: (events: DriverEventInput[], signal?: AbortSignal) => Promise; -}) { - return createAgentDriverContext({ - eventSink: { - commandUpdate: async () => {}, - ...(input.currentRunId === undefined ? {} : { currentRunId: input.currentRunId }), - pushEvents: async ({ events, signal }) => input.pushEvents(events, signal), - }, - logger: createTestLogger(), - payload: bootPayload, - permission: { - request: async () => "reject_once", - }, - }); -} +import { createContext, createDelta, createEvent, kinds } from "./driver-event-publisher-fixture"; describe("DriverEventPublisher", () => { test("drains a partial receipt before resolving the same reliable push", async () => { @@ -81,6 +24,7 @@ describe("DriverEventPublisher", () => { const acceptedEvents = attempts.length === 1 ? events.slice(0, 1) : events; return { accepted: acceptedEvents.map((event) => ({ + eventId: event.sourceEventId!, seq: nextSeq++, type: event.kind, })), @@ -106,7 +50,6 @@ describe("DriverEventPublisher", () => { ]); expect(attempts[1]?.[0]?.sourceEventId).toBe(attempts[0]?.[1]?.sourceEventId); expect(publisher.lastAcceptedSeq()).toBe(42); - await context.logger.destroy(); }); test("fails a reliable push that makes no receipt progress and retries it later", async () => { @@ -121,6 +64,7 @@ describe("DriverEventPublisher", () => { return { accepted: events.map((event, index) => ({ + eventId: event.sourceEventId!, seq: 50 + index, type: event.kind, })), @@ -141,7 +85,6 @@ describe("DriverEventPublisher", () => { expect(attempts[1]?.[0]?.sourceEventId).toBe(attempts[0]?.[0]?.sourceEventId); expect(attempts[1]?.[1]?.sourceEventId).toBe(attempts[0]?.[1]?.sourceEventId); expect(publisher.lastAcceptedSeq()).toBe(52); - await context.logger.destroy(); }); test("retains only the unaccepted suffix after progress stops", async () => { @@ -152,7 +95,7 @@ describe("DriverEventPublisher", () => { if (attempts.length === 1) { return { - accepted: [{ seq: 40, type: events[0]!.kind }], + accepted: [{ eventId: events[0]!.sourceEventId!, seq: 40, type: events[0]!.kind }], }; } @@ -162,6 +105,7 @@ describe("DriverEventPublisher", () => { return { accepted: events.map((event, index) => ({ + eventId: event.sourceEventId!, seq: 50 + index, type: event.kind, })), @@ -186,7 +130,6 @@ describe("DriverEventPublisher", () => { ]); expect(attempts[2]?.[0]?.sourceEventId).toBe(attempts[0]?.[1]?.sourceEventId); expect(publisher.lastAcceptedSeq()).toBe(51); - await context.logger.destroy(); }); test("reserves a full lossless lane beside queued best-effort deltas", async () => { @@ -204,6 +147,7 @@ describe("DriverEventPublisher", () => { return { accepted: events.map((event, index) => ({ + eventId: event.sourceEventId!, seq: attempts.length * 2_000 + index, type: event.kind, })), @@ -237,7 +181,6 @@ describe("DriverEventPublisher", () => { "message.started", "message.completed", ]); - await context.logger.destroy(); }); test("takes ownership of a queued lossless array", async () => { @@ -254,7 +197,11 @@ describe("DriverEventPublisher", () => { } return { - accepted: events.map((event, index) => ({ seq: index + 1, type: event.kind })), + accepted: events.map((event, index) => ({ + eventId: event.sourceEventId!, + seq: index + 1, + type: event.kind, + })), }; }, }); @@ -269,7 +216,6 @@ describe("DriverEventPublisher", () => { await Promise.all([first, second]); expect(attempts.map((batch) => batch.length)).toEqual([1, 1]); - await context.logger.destroy(); }); test("takes deep ownership of queued lossless payloads", async () => { @@ -294,7 +240,7 @@ describe("DriverEventPublisher", () => { return { accepted: events.map((event, index) => ({ - eventId: event.sourceEventId, + eventId: event.sourceEventId!, seq: index + 1, type: event.kind, })), @@ -324,7 +270,6 @@ describe("DriverEventPublisher", () => { ["original", "message-1"], ]); expect(observed[2]?.sourceEventIds[0]).toBe(observed[1]?.sourceEventIds[0]); - await context.logger.destroy(); }); test("drops a rejected lossless draft without blocking a terminal event", async () => { @@ -356,7 +301,6 @@ describe("DriverEventPublisher", () => { "unsupported", ); expect(kinds([delivered])).toEqual([["message.completed"]]); - await context.logger.destroy(); }); test("rejects an oversized lossless batch before reading its payloads", async () => { @@ -383,7 +327,6 @@ describe("DriverEventPublisher", () => { ), ).rejects.toThrow("exceeds 1024 events"); expect(payloadReads).toBe(0); - await context.logger.destroy(); }); test("rejects a receipt for a later same-kind event", async () => { @@ -396,7 +339,7 @@ describe("DriverEventPublisher", () => { return { accepted: [ { - eventId: events[1]?.sourceEventId, + eventId: events[1]!.sourceEventId!, seq: 1, type: events[0]!.kind, }, @@ -406,7 +349,7 @@ describe("DriverEventPublisher", () => { return { accepted: events.map((event, index) => ({ - eventId: event.sourceEventId, + eventId: event.sourceEventId!, seq: index + 2, type: event.kind, })), @@ -419,29 +362,6 @@ describe("DriverEventPublisher", () => { await expect(publisher.push(context, "mismatched", firstBatch)).rejects.toThrow("event ID"); await publisher.push(context, "retry", [createEvent("message.started")]); expect(attempt).toBe(2); - await context.logger.destroy(); - }); - - test("gives a reused draft object a new identity after a successful push", async () => { - const attempts: DriverEventInput[][] = []; - const context = createContext({ - pushEvents: async (events) => { - attempts.push(events); - return { - accepted: events.map((event, index) => ({ seq: index + 1, type: event.kind })), - }; - }, - }); - const publisher = new DriverEventPublisher("openai-runtime", () => "session-ref"); - const event = createEvent("message.completed"); - - await publisher.push(context, "first", [event]); - await publisher.push(context, "second", [event]); - - expect(attempts[1]?.[0]?.sourceEventId).not.toBe(attempts[0]?.[0]?.sourceEventId); - expect(isDriverId(attempts[0]?.[0]?.sourceEventId)).toBe(true); - expect(isDriverId(attempts[1]?.[0]?.sourceEventId)).toBe(true); - await context.logger.destroy(); }); test.each([ @@ -477,11 +397,19 @@ describe("DriverEventPublisher", () => { attempts.push(events); if (attempts.length === 1) { - return { accepted: malformedReceipts }; + return { + accepted: malformedReceipts.map( + (receipt: { readonly seq: number; readonly type: string }, index: number) => ({ + ...receipt, + eventId: events[index]?.sourceEventId ?? "extra-event-id", + }), + ), + }; } return { accepted: events.map((event, index) => ({ + eventId: event.sourceEventId!, seq: 50 + index, type: event.kind, })), @@ -503,7 +431,6 @@ describe("DriverEventPublisher", () => { expect(attempts[1]?.[0]?.sourceEventId).toBe(attempts[0]?.[0]?.sourceEventId); expect(attempts[1]?.[1]?.sourceEventId).toBe(attempts[0]?.[1]?.sourceEventId); expect(publisher.lastAcceptedSeq()).toBe(52); - await context.logger.destroy(); }, ); }); diff --git a/tests/driver-event-publisher-fixture.ts b/tests/driver-event-publisher-fixture.ts new file mode 100644 index 0000000..cd7aa26 --- /dev/null +++ b/tests/driver-event-publisher-fixture.ts @@ -0,0 +1,51 @@ +import { createAgentDriverContext } from "../src/core/agent-driver-backend"; +import { createDisabledLogger } from "../src/observability"; +import type { DriverEventInput } from "../src/protocol/events"; +import type { RunId } from "../src/protocol/id"; +import type { DriverEventBatchOutput } from "../src/protocol/orpc"; +import { DRIVER_TEST_IDS } from "./driver-boot-payload-fixture"; +import { bootPayload } from "./driver-runtime-boundary-fixtures"; + +export function createEvent(kind: "message.started" | "message.completed"): DriverEventInput { + return { + kind, + payload: { + messageId: "message-1", + ...(kind === "message.started" ? { role: "agent" } : { stopReason: "end_turn" }), + }, + }; +} + +export function createDelta(contentDelta: string): DriverEventInput { + return { + delivery: "best_effort", + kind: "message.delta", + payload: { + contentDelta, + messageId: "message-1", + role: "agent", + }, + }; +} + +export function kinds(batches: readonly (readonly DriverEventInput[])[]): string[][] { + return batches.map((batch) => batch.map((event) => event.kind)); +} + +export function createContext(input: { + currentRunId?: () => RunId | null; + pushEvents: (events: DriverEventInput[], signal?: AbortSignal) => Promise; +}) { + return createAgentDriverContext({ + eventSink: { + commandUpdate: async () => {}, + currentRunId: input.currentRunId ?? (() => DRIVER_TEST_IDS.runId), + pushEvents: async ({ events, signal }) => input.pushEvents(events, signal), + }, + logger: createDisabledLogger(), + payload: bootPayload, + permission: { + request: async () => "reject_once", + }, + }); +} diff --git a/tests/driver-event-test-helpers.ts b/tests/driver-event-test-helpers.ts new file mode 100644 index 0000000..e294cc5 --- /dev/null +++ b/tests/driver-event-test-helpers.ts @@ -0,0 +1,44 @@ +interface DriverEventLike { + readonly kind: string; + readonly payload: unknown; +} + +function record(value: unknown): Record | null { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + +export function messageText(events: readonly DriverEventLike[], messageId: unknown): string { + let text = ""; + + for (const event of events) { + const payload = record(event.payload); + if (payload === null || payload["messageId"] !== messageId) { + continue; + } + + if (event.kind === "message.delta" && typeof payload["contentDelta"] === "string") { + text += payload["contentDelta"]; + continue; + } + + if (event.kind !== "message.added") { + continue; + } + + const content = payload["content"]; + if (typeof content === "string") { + text = content; + } else if (Array.isArray(content)) { + text = content + .flatMap((block) => { + const value = record(block)?.["text"]; + return typeof value === "string" ? [value] : []; + }) + .join(""); + } + } + + return text; +} diff --git a/tests/driver-external-tool-effect-v3.test.ts b/tests/driver-external-tool-effect-v3.test.ts new file mode 100644 index 0000000..b4e6a69 --- /dev/null +++ b/tests/driver-external-tool-effect-v3.test.ts @@ -0,0 +1,1245 @@ +import { describe, expect, test } from "bun:test"; + +import { DriverRuntimeStateMachine } from "../src/core/driver-runtime-state"; +import { createMcpExecuteFailedEventIdentity } from "../src/events"; +import type { DriverEventInput } from "../src/protocol/events"; +import { driverRuntimeRpcSchemas } from "../src/protocol/orpc"; +import type { + McpExecuteCommand, + McpExternalToolEffectState, + McpExternalToolExecutionResult, +} from "../src/runtime-command"; +import { RUNTIME_COMMAND_TERMINAL_PAYLOAD_MAX_UTF8_BYTES } from "../src/runtime-command"; +import { + DRIVER_TEST_IDS, + FakeDriverRuntimeIo, + createBackend, + createDispatcher, + waitForUpdate, +} from "./driver-runtime-boundary-fixtures"; + +function command(commandId: string): McpExecuteCommand { + return { + argumentsJson: '{"title":"once"}', + commandId, + kind: "mcp.execute", + requestId: `request-${commandId}`, + runId: DRIVER_TEST_IDS.runId, + serverId: "mcp-linear", + toolCallId: `tool-${commandId}`, + toolName: "createIssue", + }; +} + +async function waitFor(predicate: () => boolean): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + if (predicate()) { + return; + } + await Bun.sleep(0); + } + throw new Error("Timed out waiting for Driver MCP state."); +} + +describe("durable external MCP effect protocol v3", () => { + test("exports the canonical failed MCP event identity", () => { + expect( + createMcpExecuteFailedEventIdentity({ + toolCallId: "tool-canonical", + title: "createIssue", + rawOutput: "provider rejected the request", + rawInput: '{"issue":"A-1"}', + commandId: "command-canonical", + }), + ).toEqual({ + payload: { + kind: "mcp", + rawInput: '{"issue":"A-1"}', + rawOutput: "provider rejected the request", + status: "failed", + title: "createIssue", + toolCallId: "tool-canonical", + }, + sourceEventId: + "mcp.execute.failed:807e9e02d3b2c37d2d77db8d1ad473b0db1e1e37f98c85556227baa8873ea62a", + }); + }); + + test("exposes only the schema-first v3 wire", () => { + const claimToken = "00000000-0000-4000-8000-000000000001"; + const result = { + outputText: "stored", + requestId: "request-schema", + serverId: "server-schema", + toolName: "lookup", + }; + + expect( + driverRuntimeRpcSchemas.driver.observeExternalToolEffect.output.parse({ + effectId: "effect-schema", + kind: "intent", + }), + ).toEqual({ effectId: "effect-schema", kind: "intent" }); + expect( + driverRuntimeRpcSchemas.driver.claimExternalToolEffect.output.parse({ + attempt: 1, + effectId: "effect-schema", + idempotencyKey: "idempotency-schema", + kind: "claimed", + }), + ).toMatchObject({ kind: "claimed" }); + expect( + driverRuntimeRpcSchemas.driver.settleExternalToolEffect.input.parse({ + claimToken, + commandId: "command-schema", + driverInstanceId: "driver-schema", + effectId: "effect-schema", + settlement: { kind: "succeeded", providerReceiptJson: null, result }, + }), + ).toMatchObject({ settlement: { kind: "succeeded", result } }); + for (const invalidClaimToken of [ + "", + "not-a-uuid", + "00000000-0000-4000-8000-ABCDEFABCDEF", + "00000000-0000-f000-8000-000000000001", + "00000000-0000-4000-c000-000000000001", + ]) { + expect( + driverRuntimeRpcSchemas.driver.claimExternalToolEffect.input.safeParse({ + claimToken: invalidClaimToken, + commandId: "command-schema", + driverInstanceId: "driver-schema", + }).success, + ).toBeFalse(); + expect( + driverRuntimeRpcSchemas.driver.settleExternalToolEffect.input.safeParse({ + claimToken: invalidClaimToken, + commandId: "command-schema", + driverInstanceId: "driver-schema", + effectId: "effect-schema", + settlement: { kind: "unknown" }, + }).success, + ).toBeFalse(); + } + expect( + driverRuntimeRpcSchemas.driver.claimExternalToolEffect.output.safeParse({ + attempt: 1, + effectId: "effect-schema", + idempotencyKey: "idempotency-schema", + kind: "execute", + }).success, + ).toBeFalse(); + expect("completeExternalToolEffect" in driverRuntimeRpcSchemas.driver).toBeFalse(); + expect("markExternalToolEffectUnknown" in driverRuntimeRpcSchemas.driver).toBeFalse(); + }); + + test.each([ + ["stale", DRIVER_TEST_IDS.secondRunId, DRIVER_TEST_IDS.runId], + ["future", DRIVER_TEST_IDS.runId, DRIVER_TEST_IDS.secondRunId], + ] as const)( + "rejects a %s-run command before observing an external effect", + async (_case, activeRunId, commandRunId) => { + const input = { ...command(`wrong-run-${_case}`), runId: commandRunId }; + const socket = new FakeDriverRuntimeIo([input], activeRunId); + let claims = 0; + let observations = 0; + let preparations = 0; + socket.claimExternalToolEffect = async () => { + claims += 1; + throw new Error("unexpected claim"); + }; + socket.observeExternalToolEffect = async () => { + observations += 1; + throw new Error("unexpected observation"); + }; + const runtime = createDispatcher({ + backend: createBackend(), + isShuttingDown: () => socket.isDrained(), + mcpPrepare: async () => { + preparations += 1; + throw new Error("unexpected preparation"); + }, + runtimeState: new DriverRuntimeStateMachine("ready"), + }); + + await runtime.dispatcher.run(socket, runtime.logger); + + expect({ claims, observations, preparations }).toEqual({ + claims: 0, + observations: 0, + preparations: 0, + }); + expect(socket.updates.at(-1)).toMatchObject({ + commandId: input.commandId, + error: { code: "driver.command_failed.mcp.execute" }, + status: "failed", + }); + }, + ); + + test("normalizes an oversized MCP failure before emitting its tool event", async () => { + const input = command("oversized-failure"); + const socket = new FakeDriverRuntimeIo([input], DRIVER_TEST_IDS.runId); + const runtime = createDispatcher({ + backend: createBackend(), + isShuttingDown: () => socket.isDrained(), + mcpPrepare: async () => { + throw new Error("界".repeat(500_000)); + }, + runtimeState: new DriverRuntimeStateMachine("ready"), + }); + + await runtime.dispatcher.run(socket, runtime.logger); + await waitForUpdate(socket, (update) => update.status === "failed"); + + const terminal = socket.updates.at(-1); + const failedToolEvent = socket.pushedEvents + .flatMap(({ events }) => events) + .find( + (event) => + event.kind === "tool.call.updated" && + typeof event.payload === "object" && + event.payload !== null && + Reflect.get(event.payload, "status") === "failed", + ); + expect(terminal).toMatchObject({ + error: { code: "driver.error_oversized" }, + status: "failed", + }); + expect(failedToolEvent).toMatchObject({ + payload: { rawOutput: terminal?.status === "failed" ? terminal.error.message : undefined }, + }); + }); + + test("runs the protocol in durable order and completes from canonical settlement", async () => { + const input = command("ordered"); + const trace: string[] = []; + const claims: Parameters[0][] = []; + const settlements: Parameters[0][] = []; + + class OrderedSocket extends FakeDriverRuntimeIo { + override async observeExternalToolEffect() { + trace.push("observe"); + return { effectId: "effect-ordered", kind: "intent" as const }; + } + + override async claimExternalToolEffect( + claim: Parameters[0], + ) { + trace.push("claim"); + claims.push(structuredClone(claim)); + return { + attempt: 1, + effectId: "effect-ordered", + idempotencyKey: "idempotency-ordered", + kind: "claimed" as const, + }; + } + + override async settleExternalToolEffect( + settlement: Parameters[0], + ) { + trace.push("settle"); + settlements.push(structuredClone(settlement)); + return settlement.settlement.kind === "succeeded" + ? { + effectId: settlement.effectId, + kind: "succeeded" as const, + result: settlement.settlement.result, + } + : { effectId: settlement.effectId, kind: "unknown" as const }; + } + + override async pushEvents( + batch: Parameters[0], + ): ReturnType { + for (const event of batch.events) { + if ( + event.kind === "tool.call.updated" && + typeof event.payload === "object" && + event.payload !== null && + "status" in event.payload && + typeof event.payload.status === "string" + ) { + trace.push(event.payload.status); + } + } + return super.pushEvents(batch); + } + } + + const socket = new OrderedSocket([input], DRIVER_TEST_IDS.runId); + const runtime = createDispatcher({ + backend: createBackend(), + isShuttingDown: () => socket.isDrained(), + mcpPrepare: async () => { + trace.push("prepare"); + return { + async execute() { + trace.push("execute"); + return { + outputText: "created A-1", + providerReceiptJson: '{"receipt":"A-1"}', + requestId: input.requestId, + serverId: input.serverId, + toolName: input.toolName, + }; + }, + async [Symbol.asyncDispose]() { + trace.push("cleanup"); + }, + }; + }, + runtimeState: new DriverRuntimeStateMachine("ready"), + }); + + await runtime.dispatcher.run(socket, runtime.logger); + await waitForUpdate(socket, (update) => update.status === "completed"); + + expect(trace).toEqual([ + "running", + "observe", + "prepare", + "claim", + "execute", + "settle", + "cleanup", + "completed", + ]); + expect(claims[0]?.claimToken).toMatch(/^[0-9a-f-]{36}$/u); + expect(settlements[0]).toMatchObject({ + claimToken: claims[0]?.claimToken, + commandId: input.commandId, + effectId: "effect-ordered", + settlement: { + kind: "succeeded", + providerReceiptJson: '{"receipt":"A-1"}', + }, + }); + expect( + socket.pushedEvents + .flatMap(({ events }) => events) + .filter((event) => event.kind === "tool.call.updated") + .map(({ correlationId }) => correlationId), + ).toEqual([input.commandId, input.commandId]); + }); + + test("settles a known oversized provider result as bounded succeeded data", async () => { + const input = command("bounded-success"); + const socket = new FakeDriverRuntimeIo([input], DRIVER_TEST_IDS.runId); + const settlements: Parameters[0][] = []; + socket.settleExternalToolEffect = async (next) => { + settlements.push(structuredClone(next)); + return next.settlement.kind === "succeeded" + ? { + effectId: next.effectId, + kind: "succeeded", + result: next.settlement.result, + } + : { effectId: next.effectId, kind: "unknown" }; + }; + const runtime = createDispatcher({ + backend: createBackend(), + isShuttingDown: () => socket.isDrained(), + mcpExecute: async () => ({ + outputText: "界".repeat(RUNTIME_COMMAND_TERMINAL_PAYLOAD_MAX_UTF8_BYTES), + providerReceiptJson: JSON.stringify({ diagnostic: "unused" }), + requestId: input.requestId, + serverId: input.serverId, + toolName: input.toolName, + }), + runtimeState: new DriverRuntimeStateMachine("ready"), + }); + + await runtime.dispatcher.run(socket, runtime.logger); + await waitForUpdate(socket, (update) => update.status === "completed"); + + expect(settlements[0]?.settlement).toEqual({ + kind: "succeeded", + result: { + isError: true, + outputText: + "MCP tool output was omitted because its durable settlement exceeded the 1044480-byte limit.", + requestId: input.requestId, + serverId: input.serverId, + toolName: input.toolName, + }, + }); + expect(socket.updates.at(-1)).toMatchObject({ + result: { isError: true, outputText: expect.stringContaining("output was omitted") }, + status: "completed", + }); + }); + + test("rejects an oversized command before acknowledgement, claim, or provider preparation", async () => { + const input = { + ...command("oversized-identity"), + requestId: "r".repeat(RUNTIME_COMMAND_TERMINAL_PAYLOAD_MAX_UTF8_BYTES), + }; + const socket = new FakeDriverRuntimeIo([input], DRIVER_TEST_IDS.runId); + let claims = 0; + let preparations = 0; + socket.claimExternalToolEffect = async (...arguments_) => { + claims += 1; + return FakeDriverRuntimeIo.prototype.claimExternalToolEffect.apply(socket, arguments_); + }; + const runtime = createDispatcher({ + backend: createBackend(), + mcpPrepare: async () => { + preparations += 1; + throw new Error("oversized identity must not prepare the provider"); + }, + runtimeState: new DriverRuntimeStateMachine("ready"), + }); + + await expect(runtime.dispatcher.run(socket, runtime.logger)).rejects.toThrow( + "Runtime command exceeds", + ); + + expect(claims).toBe(0); + expect(preparations).toBe(0); + expect(socket.updates).toEqual([]); + }); + + test("replays a durable succeeded effect after completed event delivery fails", async () => { + const input = command("completed-event-replay"); + const result = { + outputText: "created once", + requestId: input.requestId, + serverId: input.serverId, + toolName: input.toolName, + }; + let state: McpExternalToolEffectState = { + effectId: `test-effect-${input.commandId}`, + kind: "intent", + }; + let executions = 0; + const firstEventIds: string[] = []; + const firstSocket = new FakeDriverRuntimeIo([input], DRIVER_TEST_IDS.runId); + firstSocket.observeExternalToolEffect = async () => state; + firstSocket.settleExternalToolEffect = async (settlement) => { + state = { effectId: settlement.effectId, kind: "succeeded", result }; + return state; + }; + const firstPush = firstSocket.pushEvents.bind(firstSocket); + firstSocket.pushEvents = async (batch) => { + const completed = batch.events.find( + (event) => + event.kind === "tool.call.updated" && + typeof event.payload === "object" && + event.payload !== null && + Reflect.get(event.payload, "status") === "completed", + ); + if (completed !== undefined) { + firstEventIds.push(completed.sourceEventId!); + throw new Error("completed event acknowledgement lost"); + } + return firstPush(batch); + }; + let firstRuntime!: ReturnType; + firstRuntime = createDispatcher({ + backend: createBackend(), + isShuttingDown: () => firstRuntime.shutdownCalls.length > 0, + mcpExecute: async () => { + executions += 1; + return result; + }, + runtimeState: new DriverRuntimeStateMachine("ready"), + }); + + const firstRun = firstRuntime.dispatcher.run(firstSocket, firstRuntime.logger); + await waitFor(() => firstRuntime.shutdownCalls.includes("driver.mcp_task_failed")); + await firstRun.catch(() => {}); + + expect(executions).toBe(1); + expect(firstSocket.updates).toEqual([{ commandId: input.commandId, status: "accepted" }]); + expect(firstEventIds).toEqual([`mcp.execute.completed:${input.commandId}`]); + + const replacementEventIds: string[] = []; + const replacementSocket = new FakeDriverRuntimeIo([input], DRIVER_TEST_IDS.runId); + replacementSocket.observeExternalToolEffect = async () => state; + const replacementPush = replacementSocket.pushEvents.bind(replacementSocket); + replacementSocket.pushEvents = async (batch) => { + for (const event of batch.events) { + if ( + event.kind === "tool.call.updated" && + typeof event.payload === "object" && + event.payload !== null && + Reflect.get(event.payload, "status") === "completed" + ) { + replacementEventIds.push(event.sourceEventId!); + } + } + return replacementPush(batch); + }; + const replacement = createDispatcher({ + backend: createBackend(), + isShuttingDown: () => replacementSocket.isDrained(), + mcpPrepare: async () => { + throw new Error("durable succeeded effect must not re-execute"); + }, + runtimeState: new DriverRuntimeStateMachine("ready"), + }); + + await replacement.dispatcher.run(replacementSocket, replacement.logger); + await waitForUpdate(replacementSocket, (update) => update.status === "completed"); + + expect(executions).toBe(1); + expect(replacementEventIds).toEqual(firstEventIds); + expect(replacementSocket.updates.at(-1)).toMatchObject({ + result, + status: "completed", + }); + }); + + test("keeps an MCP command accepted when its failed tool event is not durable", async () => { + const input = command("failed-event-unavailable"); + const eventIds: string[] = []; + const socket = new FakeDriverRuntimeIo([input], DRIVER_TEST_IDS.runId); + const push = socket.pushEvents.bind(socket); + socket.pushEvents = async (batch) => { + const failed = batch.events.find( + (event) => + event.kind === "tool.call.updated" && + typeof event.payload === "object" && + event.payload !== null && + Reflect.get(event.payload, "status") === "failed", + ); + if (failed !== undefined) { + eventIds.push(failed.sourceEventId!); + throw new Error("failed event unavailable"); + } + return push(batch); + }; + let runtime!: ReturnType; + runtime = createDispatcher({ + backend: createBackend(), + isShuttingDown: () => runtime.shutdownCalls.length > 0, + mcpPrepare: async () => { + throw new Error("provider preparation failed"); + }, + runtimeState: new DriverRuntimeStateMachine("ready"), + }); + + const run = runtime.dispatcher.run(socket, runtime.logger); + await waitFor(() => runtime.shutdownCalls.includes("driver.mcp_task_failed")); + await run.catch(() => {}); + + expect(eventIds).toHaveLength(1); + expect(eventIds[0]).toMatch(/^mcp\.execute\.failed:[0-9a-f]{64}$/u); + expect(socket.updates).toEqual([{ commandId: input.commandId, status: "accepted" }]); + }); + + test("content-addresses changed MCP failures across an ACK-lost instance replay", async () => { + const input = command("failed-event-cross-instance"); + const persisted = new Map(); + + const observeFailure = async (message: string, loseAck: boolean) => { + const observed = Promise.withResolvers(); + const socket = new FakeDriverRuntimeIo([input], DRIVER_TEST_IDS.runId); + const push = socket.pushEvents.bind(socket); + socket.pushEvents = async (batch) => { + const failed = batch.events.find( + (event) => + event.kind === "tool.call.updated" && + typeof event.payload === "object" && + event.payload !== null && + Reflect.get(event.payload, "status") === "failed", + ); + if (failed === undefined) { + return push(batch); + } + + const sourceEventId = failed.sourceEventId!; + const content = JSON.stringify(failed); + const previous = persisted.get(sourceEventId); + expect(previous === undefined || previous === content).toBeTrue(); + persisted.set(sourceEventId, content); + observed.resolve(structuredClone(failed)); + const result = await push(batch); + if (loseAck) { + throw new Error("failed event ACK lost after persistence"); + } + return result; + }; + let runtime!: ReturnType; + runtime = createDispatcher({ + backend: createBackend(), + isShuttingDown: () => (loseAck ? runtime.shutdownCalls.length > 0 : socket.isDrained()), + mcpPrepare: async () => { + throw new Error(message); + }, + runtimeState: new DriverRuntimeStateMachine("ready"), + }); + + const run = runtime.dispatcher.run(socket, runtime.logger); + const event = await observed.promise; + await waitFor(() => + loseAck + ? runtime.shutdownCalls.includes("driver.mcp_task_failed") + : socket.updates.some( + ({ commandId, status }) => commandId === input.commandId && status === "failed", + ), + ); + await run.catch(() => {}); + return event; + }; + + const first = await observeFailure("same failure", true); + const replay = await observeFailure("same failure", false); + const changed = await observeFailure("changed failure", false); + + expect(replay.sourceEventId).toBe(first.sourceEventId); + expect(changed.sourceEventId).not.toBe(first.sourceEventId); + expect([first, replay, changed].map(({ correlationId }) => correlationId)).toEqual([ + input.commandId, + input.commandId, + input.commandId, + ]); + expect(persisted.size).toBe(2); + }); + + test("keeps an aborted MCP command accepted when its cancelled tool event is not durable", async () => { + const input = command("cancelled-event-unavailable"); + const observed = Promise.withResolvers(); + const eventIds: string[] = []; + const shutdown = new AbortController(); + const socket = new FakeDriverRuntimeIo([input], DRIVER_TEST_IDS.runId); + socket.observeExternalToolEffect = async (_command, signal) => { + observed.resolve(); + return new Promise((_resolve, reject) => { + signal.addEventListener("abort", () => reject(signal.reason), { once: true }); + }); + }; + const push = socket.pushEvents.bind(socket); + socket.pushEvents = async (batch) => { + const cancelled = batch.events.find( + (event) => + event.kind === "tool.call.updated" && + typeof event.payload === "object" && + event.payload !== null && + Reflect.get(event.payload, "status") === "cancelled", + ); + if (cancelled !== undefined) { + eventIds.push(cancelled.sourceEventId!); + throw new Error("cancelled event unavailable"); + } + return push(batch); + }; + const runtime = createDispatcher({ + backend: createBackend(), + isShuttingDown: () => shutdown.signal.aborted, + runtimeState: new DriverRuntimeStateMachine("ready"), + shutdownSignal: shutdown.signal, + }); + const run = runtime.dispatcher.run(socket, runtime.logger); + + await observed.promise; + shutdown.abort(new Error("stop before claim")); + await run.catch(() => {}); + + expect(eventIds).toEqual([`mcp.execute.cancelled:${input.commandId}`]); + expect(socket.updates).toEqual([{ commandId: input.commandId, status: "accepted" }]); + }); + + test("rechecks exact run ownership after preparation and before claim", async () => { + const input = command("run-changed-before-claim"); + const shutdown = new AbortController(); + let currentRunId = DRIVER_TEST_IDS.runId; + let claims = 0; + let executions = 0; + class RunChangingSocket extends FakeDriverRuntimeIo { + override currentRunId() { + return currentRunId; + } + } + const socket = new RunChangingSocket([input], DRIVER_TEST_IDS.runId); + socket.claimExternalToolEffect = async (...arguments_) => { + claims += 1; + return FakeDriverRuntimeIo.prototype.claimExternalToolEffect.apply(socket, arguments_); + }; + const runtime = createDispatcher({ + backend: createBackend(), + isShuttingDown: () => shutdown.signal.aborted, + mcpPrepare: async () => { + currentRunId = DRIVER_TEST_IDS.secondRunId; + return { + async execute() { + executions += 1; + throw new Error("stale run must not execute"); + }, + async [Symbol.asyncDispose]() {}, + }; + }, + runtimeState: new DriverRuntimeStateMachine("ready"), + shutdownSignal: shutdown.signal, + }); + + const run = runtime.dispatcher.run(socket, runtime.logger); + await waitForUpdate(socket, (update) => update.status === "failed"); + shutdown.abort(new Error("test complete")); + await run; + + expect(claims).toBe(0); + expect(executions).toBe(0); + expect(socket.updates.map(({ status }) => status)).toEqual(["accepted", "failed"]); + }); + + test.each(["succeeded", "unknown", "claimed"] as const)( + "does not prepare an already-%s effect", + async (kind) => { + const input = command(`observed-${kind}`); + const result = { + outputText: "stored result", + requestId: input.requestId, + serverId: input.serverId, + toolName: input.toolName, + }; + const state: McpExternalToolEffectState = + kind === "succeeded" + ? { effectId: "effect-observed", kind, result } + : kind === "claimed" + ? { + attempt: 2, + effectId: "effect-observed", + idempotencyKey: "idempotency-observed", + kind, + } + : { effectId: "effect-observed", kind }; + const socket = new FakeDriverRuntimeIo([input], DRIVER_TEST_IDS.runId); + socket.observeExternalToolEffect = async () => state; + let preparations = 0; + let claims = 0; + socket.claimExternalToolEffect = async (...arguments_) => { + claims += 1; + return FakeDriverRuntimeIo.prototype.claimExternalToolEffect.apply(socket, arguments_); + }; + const runtime = createDispatcher({ + backend: createBackend(), + isShuttingDown: () => socket.isDrained(), + mcpPrepare: async () => { + preparations += 1; + throw new Error("observed effects must not be prepared"); + }, + runtimeState: new DriverRuntimeStateMachine("ready"), + }); + + const run = runtime.dispatcher.run(socket, runtime.logger); + if (kind === "succeeded") { + await waitForUpdate(socket, (update) => update.status === "completed"); + expect(socket.updates.at(-1)).toEqual({ + commandId: input.commandId, + result, + status: "completed", + }); + } else if (kind === "unknown") { + await waitForUpdate(socket, (update) => update.status === "failed"); + expect(socket.updates.at(-1)).toMatchObject({ + error: { + code: "driver.external_tool_effect_unknown", + details: { + commandId: input.commandId, + effectId: "effect-observed", + requestId: input.requestId, + serverId: input.serverId, + toolName: input.toolName, + }, + retryable: false, + }, + status: "failed", + }); + } else { + await waitFor(() => runtime.shutdownCalls.includes("driver.mcp_task_failed")); + expect(socket.updates).toEqual([{ commandId: input.commandId, status: "accepted" }]); + } + await run.catch(() => {}); + expect(preparations).toBe(0); + expect(claims).toBe(0); + }, + ); + + test.each([ + ["observe", "requestId", 0, 0], + ["claim", "serverId", 1, 0], + ["settle", "toolName", 1, 1], + ] as const)( + "fail-closes a canonical result with a mismatched identity from %s", + async (source, mismatchedField, expectedPreparations, expectedExecutions) => { + const input = command(`mismatched-${source}`); + const effectId = `test-effect-${input.commandId}`; + const matchingResult = { + outputText: "canonical result", + requestId: input.requestId, + serverId: input.serverId, + toolName: input.toolName, + }; + const mismatchedState: McpExternalToolEffectState = { + effectId, + kind: "succeeded", + result: { ...matchingResult, [mismatchedField]: `wrong-${mismatchedField}` }, + }; + const socket = new FakeDriverRuntimeIo([input], DRIVER_TEST_IDS.runId); + if (source === "observe") { + socket.observeExternalToolEffect = async () => mismatchedState; + } else if (source === "claim") { + socket.claimExternalToolEffect = async () => mismatchedState; + } else { + socket.settleExternalToolEffect = async () => mismatchedState; + } + + let executions = 0; + let preparations = 0; + const runtime = createDispatcher({ + backend: createBackend(), + isShuttingDown: () => socket.isDrained(), + mcpPrepare: async () => { + preparations += 1; + return { + async execute() { + executions += 1; + return matchingResult; + }, + async [Symbol.asyncDispose]() {}, + }; + }, + runtimeState: new DriverRuntimeStateMachine("ready"), + }); + + const run = runtime.dispatcher.run(socket, runtime.logger); + await waitFor(() => runtime.shutdownCalls.includes("driver.mcp_task_failed")); + await run.catch(() => {}); + + expect(preparations).toBe(expectedPreparations); + expect(executions).toBe(expectedExecutions); + expect(socket.updates).toEqual([{ commandId: input.commandId, status: "accepted" }]); + }, + ); + + test("retries a lost claim acknowledgement with the same token", async () => { + const input = command("claim-retry"); + const socket = new FakeDriverRuntimeIo([input], DRIVER_TEST_IDS.runId); + const claims: Parameters[0][] = []; + socket.claimExternalToolEffect = async (claim) => { + claims.push(structuredClone(claim)); + if (claims.length === 1) { + throw new Error("claim acknowledgement lost"); + } + return { + attempt: 1, + effectId: `test-effect-${input.commandId}`, + idempotencyKey: "claim-retry-key", + kind: "claimed", + }; + }; + let executions = 0; + const runtime = createDispatcher({ + backend: createBackend(), + isShuttingDown: () => socket.isDrained(), + mcpExecute: async () => { + executions += 1; + return { + outputText: "created once", + requestId: input.requestId, + serverId: input.serverId, + toolName: input.toolName, + }; + }, + runtimeState: new DriverRuntimeStateMachine("ready"), + }); + + await runtime.dispatcher.run(socket, runtime.logger); + await waitForUpdate(socket, (update) => update.status === "completed"); + + expect(claims).toHaveLength(2); + expect(claims[1]).toEqual(claims[0]); + expect(executions).toBe(1); + }); + + test("leaves the command accepted when both claim responses are unreachable", async () => { + const input = command("claim-unreachable"); + const socket = new FakeDriverRuntimeIo([input], DRIVER_TEST_IDS.runId); + const claims: Parameters[0][] = []; + let disposals = 0; + let executions = 0; + let settlements = 0; + socket.claimExternalToolEffect = async (claim) => { + claims.push(structuredClone(claim)); + throw new Error("claim response unreachable"); + }; + socket.settleExternalToolEffect = async (...arguments_) => { + settlements += 1; + return FakeDriverRuntimeIo.prototype.settleExternalToolEffect.apply(socket, arguments_); + }; + const runtime = createDispatcher({ + backend: createBackend(), + isShuttingDown: () => socket.isDrained(), + mcpPrepare: async () => ({ + async execute() { + executions += 1; + throw new Error("unreachable claim must not execute"); + }, + async [Symbol.asyncDispose]() { + disposals += 1; + }, + }), + runtimeState: new DriverRuntimeStateMachine("ready"), + }); + + const run = runtime.dispatcher.run(socket, runtime.logger); + await waitFor(() => runtime.shutdownCalls.includes("driver.mcp_task_failed")); + await run.catch(() => {}); + + expect(claims).toHaveLength(2); + expect(claims[1]).toEqual(claims[0]); + expect(executions).toBe(0); + expect(settlements).toBe(0); + expect(disposals).toBe(1); + expect(socket.updates).toEqual([{ commandId: input.commandId, status: "accepted" }]); + }); + + test("settles provider failure as unknown instead of cancelling the command", async () => { + const input = command("provider-unknown"); + const socket = new FakeDriverRuntimeIo([input], DRIVER_TEST_IDS.runId); + const settlements: Parameters[0][] = []; + socket.settleExternalToolEffect = async (settlement, signal) => { + expect(signal.aborted).toBeFalse(); + settlements.push(structuredClone(settlement)); + return { effectId: settlement.effectId, kind: "unknown" }; + }; + const runtime = createDispatcher({ + backend: createBackend(), + isShuttingDown: () => socket.isDrained(), + mcpExecute: async () => { + throw new Error("provider response lost"); + }, + runtimeState: new DriverRuntimeStateMachine("ready"), + }); + + await runtime.dispatcher.run(socket, runtime.logger); + await waitForUpdate(socket, (update) => update.status === "failed"); + + expect(settlements).toHaveLength(1); + expect(settlements[0]?.settlement).toEqual({ kind: "unknown" }); + expect(socket.updates.some((update) => update.status === "cancelled")).toBeFalse(); + const terminal = socket.updates.at(-1); + expect(terminal?.status === "failed" ? terminal.error.code : undefined).toBe( + "driver.external_tool_effect_unknown", + ); + }); + + test.each(["succeeded", "unknown"] as const)( + "treats a resolved claim as the commit point for a %s provider outcome", + async (outcome) => { + const input = command(`claim-commit-${outcome}`); + const claimStarted = Promise.withResolvers(); + const releaseClaim = Promise.withResolvers(); + class CommitPointSocket extends FakeDriverRuntimeIo { + #reads = 0; + + override async nextCommand(signal: AbortSignal) { + this.#reads += 1; + if (this.#reads === 2) { + await claimStarted.promise; + } + return super.nextCommand(signal); + } + } + const socket = new CommitPointSocket( + [ + input, + { + commandId: `cancel-${input.commandId}`, + kind: "turn.cancel", + reason: "cancel immediately after claim", + runId: DRIVER_TEST_IDS.runId, + }, + ], + DRIVER_TEST_IDS.runId, + ); + const settlements: Parameters[0][] = []; + const settlementSignals: boolean[] = []; + let disposals = 0; + let executions = 0; + + socket.claimExternalToolEffect = async () => { + claimStarted.resolve(); + await releaseClaim.promise; + return { + attempt: 1, + effectId: `test-effect-${input.commandId}`, + idempotencyKey: `test-effect-${input.commandId}`, + kind: "claimed", + }; + }; + socket.settleExternalToolEffect = async (settlement, signal) => { + settlements.push(structuredClone(settlement)); + settlementSignals.push(signal.aborted); + return settlement.settlement.kind === "succeeded" + ? { + effectId: settlement.effectId, + kind: "succeeded", + result: settlement.settlement.result, + } + : { effectId: settlement.effectId, kind: "unknown" }; + }; + const runtime = createDispatcher({ + backend: createBackend(), + isShuttingDown: () => socket.isDrained(), + mcpPrepare: async (_command, prepareSignal) => { + prepareSignal.addEventListener("abort", () => releaseClaim.resolve(), { once: true }); + return { + async execute() { + executions += 1; + expect(prepareSignal.aborted).toBeTrue(); + if (outcome === "unknown") { + throw new Error("provider response lost"); + } + return { + outputText: "committed result", + requestId: input.requestId, + serverId: input.serverId, + toolName: input.toolName, + }; + }, + async [Symbol.asyncDispose]() { + disposals += 1; + }, + }; + }, + runtimeState: new DriverRuntimeStateMachine("ready"), + }); + + await runtime.dispatcher.run(socket, runtime.logger); + + expect(executions).toBe(1); + expect(disposals).toBe(1); + expect(settlements).toHaveLength(1); + expect(settlements[0]?.settlement.kind).toBe(outcome); + expect(settlementSignals).toEqual([false]); + expect( + socket.updates.some( + (update) => update.commandId === input.commandId && update.status === "cancelled", + ), + ).toBeFalse(); + expect( + socket.updates.findLast( + (update) => update.commandId === input.commandId && update.status !== "accepted", + ), + ).toMatchObject( + outcome === "succeeded" + ? { result: { outputText: "committed result" }, status: "completed" } + : { + error: { code: "driver.external_tool_effect_unknown", retryable: false }, + status: "failed", + }, + ); + }, + ); + + test("uses a fresh settlement budget after cancellation claims the effect", async () => { + const input = command("cancel-after-claim"); + const socket = new FakeDriverRuntimeIo([input], DRIVER_TEST_IDS.runId); + const entered = Promise.withResolvers(); + const execution = Promise.withResolvers(); + const shutdown = new AbortController(); + const settlementSignals: boolean[] = []; + socket.settleExternalToolEffect = async (settlement, signal) => { + settlementSignals.push(signal.aborted); + return { effectId: settlement.effectId, kind: "unknown" }; + }; + const runtime = createDispatcher({ + backend: createBackend(), + isShuttingDown: () => shutdown.signal.aborted, + mcpExecute: async () => { + entered.resolve(); + return execution.promise; + }, + runtimeState: new DriverRuntimeStateMachine("ready"), + shutdownSignal: shutdown.signal, + }); + const run = runtime.dispatcher.run(socket, runtime.logger); + + await entered.promise; + shutdown.abort(new Error("test shutdown")); + execution.reject(new Error("provider response lost")); + await run; + + expect(settlementSignals).toEqual([false]); + expect(socket.updates.some((update) => update.status === "cancelled")).toBeFalse(); + const terminal = socket.updates.at(-1); + expect(terminal?.status === "failed" ? terminal.error.code : undefined).toBe( + "driver.external_tool_effect_unknown", + ); + }); + + test("retries the identical settlement and trusts its stored result", async () => { + const input = command("settlement-retry"); + const socket = new FakeDriverRuntimeIo([input], DRIVER_TEST_IDS.runId); + const settlements: Parameters[0][] = []; + const storedResult = { + outputText: "stored canonical result", + requestId: input.requestId, + serverId: input.serverId, + toolName: input.toolName, + }; + socket.settleExternalToolEffect = async (settlement) => { + settlements.push(structuredClone(settlement)); + if (settlements.length === 1) { + throw new Error("settlement acknowledgement lost"); + } + return { effectId: settlement.effectId, kind: "succeeded", result: storedResult }; + }; + const runtime = createDispatcher({ + backend: createBackend(), + isShuttingDown: () => socket.isDrained(), + mcpExecute: async () => ({ + outputText: "provider result", + requestId: input.requestId, + serverId: input.serverId, + toolName: input.toolName, + }), + runtimeState: new DriverRuntimeStateMachine("ready"), + }); + + await runtime.dispatcher.run(socket, runtime.logger); + await waitForUpdate(socket, (update) => update.status === "completed"); + + expect(settlements).toHaveLength(2); + expect(settlements[1]).toEqual(settlements[0]); + const terminal = socket.updates.at(-1); + expect(terminal?.status === "completed" ? terminal.result : undefined).toEqual(storedResult); + }); + + test("leaves a successfully invoked effect accepted when settlement is unreachable", async () => { + const input = command("settlement-unreachable"); + const socket = new FakeDriverRuntimeIo([input], DRIVER_TEST_IDS.runId); + let executions = 0; + let settlements = 0; + socket.settleExternalToolEffect = async () => { + settlements += 1; + throw new Error("settlement unreachable"); + }; + const runtime = createDispatcher({ + backend: createBackend(), + isShuttingDown: () => socket.isDrained(), + mcpExecute: async () => { + executions += 1; + return { + outputText: "provider result", + requestId: input.requestId, + serverId: input.serverId, + toolName: input.toolName, + }; + }, + runtimeState: new DriverRuntimeStateMachine("ready"), + }); + + const run = runtime.dispatcher.run(socket, runtime.logger); + await waitFor(() => runtime.shutdownCalls.includes("driver.mcp_task_failed")); + await run.catch(() => {}); + + expect(executions).toBe(1); + expect(settlements).toBe(2); + expect(socket.updates).toEqual([{ commandId: input.commandId, status: "accepted" }]); + }); + + test("treats MCP cleanup failure as diagnostic-only", async () => { + const input = command("cleanup-diagnostic"); + const socket = new FakeDriverRuntimeIo([input], DRIVER_TEST_IDS.runId); + const runtime = createDispatcher({ + backend: createBackend(), + isShuttingDown: () => socket.isDrained(), + mcpPrepare: async () => ({ + async execute() { + return { + outputText: "durable result", + requestId: input.requestId, + serverId: input.serverId, + toolName: input.toolName, + }; + }, + async [Symbol.asyncDispose]() { + throw new Error("cleanup failed"); + }, + }), + runtimeState: new DriverRuntimeStateMachine("ready"), + }); + + await runtime.dispatcher.run(socket, runtime.logger); + await waitForUpdate(socket, (update) => update.status === "completed"); + + expect(socket.updates.at(-1)).toMatchObject({ + commandId: input.commandId, + status: "completed", + }); + expect(runtime.shutdownCalls).toEqual([]); + }); + + test("waits for MCP cleanup beyond one internal cleanup epoch", async () => { + const input = command("cleanup-multiple-epochs"); + const socket = new FakeDriverRuntimeIo([input], DRIVER_TEST_IDS.runId); + let disposed = false; + const runtime = createDispatcher({ + backend: createBackend(), + isShuttingDown: () => socket.isDrained(), + mcpPrepare: async () => ({ + async execute() { + return { + outputText: "durable result", + requestId: input.requestId, + serverId: input.serverId, + toolName: input.toolName, + }; + }, + async [Symbol.asyncDispose]() { + await Bun.sleep(2_250); + disposed = true; + }, + }), + runtimeState: new DriverRuntimeStateMachine("ready"), + }); + + await runtime.dispatcher.run(socket, runtime.logger); + + expect(disposed).toBe(true); + expect(socket.updates.at(-1)).toMatchObject({ + commandId: input.commandId, + status: "completed", + }); + }); + + test("bounds a permanently stalled MCP cleanup", async () => { + const input = command("cleanup-stalled"); + const socket = new FakeDriverRuntimeIo([input], DRIVER_TEST_IDS.runId); + let disposeCalls = 0; + const runtime = createDispatcher({ + backend: createBackend(), + isShuttingDown: () => socket.isDrained(), + mcpPrepare: async () => ({ + async execute() { + return { + outputText: "durable result", + requestId: input.requestId, + serverId: input.serverId, + toolName: input.toolName, + }; + }, + async [Symbol.asyncDispose]() { + disposeCalls += 1; + await new Promise(() => {}); + }, + }), + runtimeState: new DriverRuntimeStateMachine("ready"), + }); + + await runtime.dispatcher.run(socket, runtime.logger); + await waitForUpdate(socket, (update) => update.status === "completed"); + + expect(disposeCalls).toBe(1); + expect(socket.updates.at(-1)).toMatchObject({ + commandId: input.commandId, + status: "completed", + }); + expect(runtime.shutdownCalls).toEqual([]); + }, 10_000); +}); diff --git a/tests/driver-golden-fixtures.test.ts b/tests/driver-golden-fixtures.test.ts index ab163ad..da57768 100644 --- a/tests/driver-golden-fixtures.test.ts +++ b/tests/driver-golden-fixtures.test.ts @@ -1,12 +1,23 @@ import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; -import { DRIVER_ID_FIXTURES } from "../src/protocol/boot/testing"; -import { parseRuntimeCommand } from "../src/runtime-command"; +import { DriverCommandDelivery } from "../src/core/driver-command-delivery"; +import type { EventId } from "../src/protocol/id"; +import { parseDriverEventEnvelope } from "../src/protocol/events"; +import { + DURABLE_RUN_ERROR_MAX_UTF8_BYTES, + RUNTIME_COMMAND_MAX_UTF8_BYTES, + measureRuntimeCommandJson, + normalizeDurableRunError, + parseRuntimeCommand, +} from "../src/runtime-command"; +import type { RuntimeCommandInput } from "../src/runtime-command"; import { ingestRuntimeEventInput } from "../src/runtime-events"; import type { RuntimeEventBuildContext } from "../src/runtime-events"; +import { DRIVER_TEST_IDS } from "./driver-boot-payload-fixture"; const occurredAt = "2026-05-26T00:00:00.000Z"; +const eventId = "01J0000000000000000000000G" as EventId; const commandFixtures = [ "input-start", @@ -17,10 +28,12 @@ const commandFixtures = [ ] as const; const runtimeEventFixtures = [ + "agent-tasks-replaced", "diagnostic-reported", "message-delta", "permission-requested", "run-started", + "tool-call-deltas", "tool-call-updated", "usage-updated", ] as const; @@ -29,14 +42,47 @@ function readJsonFixture(path: string): unknown { return JSON.parse(readFileSync(new URL(path, import.meta.url), "utf8")); } +function textFieldAtJsonSize( + targetBytes: number, + create: (text: string) => Value, + unit = "x", +): Value { + const baseBytes = measureRuntimeCommandJson(create("")); + const unitBytes = measureRuntimeCommandJson(create(unit)) - baseBytes; + const remaining = targetBytes - baseBytes; + const value = create( + unit.repeat(Math.floor(remaining / unitBytes)) + "x".repeat(remaining % unitBytes), + ); + + expect(measureRuntimeCommandJson(value)).toBe(targetBytes); + return value; +} + +function mcpCommandAtSize(targetBytes: number, unit = "x") { + return textFieldAtJsonSize( + targetBytes, + (argumentsJson) => ({ + argumentsJson, + commandId: "command-1", + kind: "mcp.execute" as const, + requestId: "request-1", + runId: DRIVER_TEST_IDS.runId, + serverId: "server-1", + toolCallId: "tool-call-1", + toolName: "tool-1", + }), + unit, + ); +} + function createRuntimeEventContext(): RuntimeEventBuildContext { return { - createId: () => DRIVER_ID_FIXTURES.event, - driverInstanceId: DRIVER_ID_FIXTURES.driverInstance, + createId: () => eventId, + driverInstanceId: DRIVER_TEST_IDS.driverInstanceId, occurredAt, - runId: DRIVER_ID_FIXTURES.run, + runId: DRIVER_TEST_IDS.runId, runtimeId: "runtime-1", - sessionId: DRIVER_ID_FIXTURES.session, + sessionId: DRIVER_TEST_IDS.sessionId, traceId: "trace-1", }; } @@ -47,17 +93,34 @@ describe("Driver golden fixtures", () => { expect(parseRuntimeCommand(fixture)).toEqual(fixture); }); - test("preserves v1 compatibility for API-materialized input attachments", () => { + test("preserves validated attachment provenance on the exported command input", () => { + const fixture = readJsonFixture("./fixtures/driver/commands/input-start.json") as { + readonly input: { readonly text: string }; + }; + const attachmentIds = ["file-1"]; + const input = { + ...fixture.input, + attachmentIds, + } satisfies RuntimeCommandInput; + const parsed = parseRuntimeCommand({ ...fixture, input }); + attachmentIds[0] = "mutated"; + + expect(parsed).toEqual({ ...fixture, input: { ...input, attachmentIds: ["file-1"] } }); + }); + + test("keeps attachment provenance in replay identity after provider projection", () => { const fixture = readJsonFixture("./fixtures/driver/commands/input-start.json") as { readonly input: Record; }; + const first = { ...fixture, input: { ...fixture.input, attachmentIds: ["file-1"] } }; + const changed = { ...fixture, input: { ...fixture.input, attachmentIds: ["file-2"] } }; + const delivery = new DriverCommandDelivery(new AbortController().signal); - expect( - parseRuntimeCommand({ - ...fixture, - input: { ...fixture.input, attachmentIds: ["file-1"] }, - }), - ).toEqual(fixture); + delivery.receive(parseRuntimeCommand(first), first); + + expect(() => delivery.replay(parseRuntimeCommand(changed), changed)).toThrow( + "replayed with changed identity or content", + ); }); test("rejects malformed input attachment IDs", () => { @@ -73,6 +136,37 @@ describe("Driver golden fixtures", () => { ).toThrow("input.attachmentIds must be an array of non-empty strings."); }); + test.each(commandFixtures)("rejects undeclared fields on runtime command %s", (name) => { + const fixture = readJsonFixture(`./fixtures/driver/commands/${name}.json`); + + expect(() => parseRuntimeCommand({ ...(fixture as object), extra: true })).toThrow( + "runtime command.extra is not allowed.", + ); + }); + + test("rejects undeclared fields on runtime command input", () => { + const fixture = readJsonFixture("./fixtures/driver/commands/input-start.json") as { + readonly input: Record; + }; + + expect(() => + parseRuntimeCommand({ ...fixture, input: { ...fixture.input, extra: true } }), + ).toThrow("input.extra is not allowed."); + }); + + test.each(["input-start", "mcp-execute", "permission-resolve", "turn-cancel"] as const)( + "requires runId on run-scoped runtime command %s", + (name) => { + const fixture = readJsonFixture(`./fixtures/driver/commands/${name}.json`) as Record< + string, + unknown + >; + const { runId: _runId, ...withoutRunId } = fixture; + + expect(() => parseRuntimeCommand(withoutRunId)).toThrow("runId must be a non-empty string."); + }, + ); + test.each(runtimeEventFixtures)("ingests runtime event fixture %s", (name) => { const outcome = ingestRuntimeEventInput( createRuntimeEventContext(), @@ -91,4 +185,425 @@ describe("Driver golden fixtures", () => { readJsonFixture(`./fixtures/driver/runtime-event-envelopes/${name}.json`), ); }); + + test("rejects extensions on fixed event envelope layers while preserving payload extensions", () => { + const context = createRuntimeEventContext(); + const fixture = readJsonFixture( + "./fixtures/driver/runtime-event-envelopes/diagnostic-reported.json", + ) as Record; + + expect(ingestRuntimeEventInput(context, { ...fixture, context: {} })).toMatchObject({ + rejection: { message: "Runtime event context is not allowed." }, + status: "rejected", + }); + expect( + ingestRuntimeEventInput(context, { + ...fixture, + native: { future: true, provider: "openai" }, + }), + ).toMatchObject({ + rejection: { message: "Runtime event native reference future is not allowed." }, + status: "rejected", + }); + expect(() => + parseDriverEventEnvelope({ event: fixture, eventId: "event-1", sequence: 1 }), + ).toThrow("Driver event envelope sequence is not allowed."); + + const accepted = ingestRuntimeEventInput(context, { + kind: "diagnostic.reported", + payload: { code: "driver.test", future: { nested: true } }, + }); + expect(accepted).toMatchObject({ + event: { payload: { code: "driver.test", future: { nested: true } } }, + status: "accepted", + }); + if (accepted.status === "accepted") { + expect(ingestRuntimeEventInput(context, accepted.event)).toEqual(accepted); + } + }); + + test("owns tool call snapshot and delta payload semantics", () => { + const context = createRuntimeEventContext(); + const event = { + kind: "tool.call.updated" as const, + payload: { + future: { nested: true }, + rawInputDelta: '{"path":', + rawOutputDelta: "chunk", + status: "running", + toolCallId: "tool-delta-1", + }, + }; + + expect(ingestRuntimeEventInput(context, event)).toMatchObject({ + event: { payload: event.payload }, + status: "accepted", + }); + expect( + ingestRuntimeEventInput(context, { + ...event, + payload: { ...event.payload, rawInput: '{"path":"src"}' }, + }), + ).toMatchObject({ + rejection: { + message: + "Runtime event tool.call.updated payload cannot contain both rawInput and rawInputDelta.", + }, + status: "rejected", + }); + expect( + ingestRuntimeEventInput(context, { + ...event, + payload: { ...event.payload, rawOutput: "complete" }, + }), + ).toMatchObject({ + rejection: { + message: + "Runtime event tool.call.updated payload cannot contain both rawOutput and rawOutputDelta.", + }, + status: "rejected", + }); + + for (const payload of [ + { ...event.payload, rawInputDelta: 1 }, + { ...event.payload, rawOutputDelta: null }, + ]) { + expect(ingestRuntimeEventInput(context, { ...event, payload })).toMatchObject({ + status: "rejected", + }); + } + + expect( + ingestRuntimeEventInput(context, { + ...event, + payload: { ...event.payload, rawInputMode: "future-extension" }, + }), + ).toMatchObject({ + event: { payload: { ...event.payload, rawInputMode: "future-extension" } }, + status: "accepted", + }); + }); + + test.each([Number.NaN, Number.POSITIVE_INFINITY, undefined])( + "rejects non-JSON usage payload value %p at the shared event boundary", + (tokens) => { + expect( + ingestRuntimeEventInput(createRuntimeEventContext(), { + kind: "usage.updated", + payload: { tokens }, + }), + ).toMatchObject({ status: "rejected" }); + }, + ); + + test("enforces the shared agent task payload contract", () => { + expect( + ingestRuntimeEventInput(createRuntimeEventContext(), { + kind: "agent.task.updated", + payload: { + active: true, + agentId: "agent-1", + status: "running", + taskId: "task-1", + taskType: "local_agent", + }, + }), + ).toMatchObject({ status: "accepted" }); + + for (const payload of [ + { status: "running" }, + { status: "unknown", taskId: "task-1" }, + { active: "yes", taskId: "task-1" }, + ]) { + expect( + ingestRuntimeEventInput(createRuntimeEventContext(), { + kind: "agent.task.updated", + payload, + }), + ).toMatchObject({ status: "rejected" }); + } + }); + + test("enforces the agent task replacement envelope and payload contract", () => { + const context = createRuntimeEventContext(); + const event = { + delivery: "lossless" as const, + kind: "agent.tasks.replaced" as const, + payload: { + tasks: [{ taskId: "task-1", taskType: "local_agent", title: "Inspect repository" }], + }, + visibility: "participant" as const, + }; + + expect(ingestRuntimeEventInput(context, event)).toMatchObject({ status: "accepted" }); + + for (const input of [ + { ...event, delivery: "best_effort" }, + { ...event, visibility: "owner_debug" }, + { ...event, payload: {} }, + { ...event, payload: { tasks: [{ taskId: "" }] } }, + { ...event, payload: { tasks: [{ private: true, taskId: "task-1" }] } }, + { ...event, payload: { tasks: [{ taskId: "task-1" }, { taskId: "task-1" }] } }, + ]) { + expect(ingestRuntimeEventInput(context, input)).toMatchObject({ status: "rejected" }); + } + + expect( + ingestRuntimeEventInput({ ...context, driverInstanceId: undefined }, event), + ).toMatchObject({ status: "rejected" }); + expect(ingestRuntimeEventInput({ ...context, runId: undefined }, event)).toMatchObject({ + status: "rejected", + }); + + expect( + ingestRuntimeEventInput(context, { + ...event, + payload: { + tasks: Array.from({ length: 256 }, (_, index) => ({ + taskId: `task-${String(index)}`, + title: "界".repeat(4_096), + })), + }, + }), + ).toMatchObject({ status: "rejected" }); + }); + + test("owns the completed-run final message reference schema", () => { + const context = createRuntimeEventContext(); + + for (const payload of [{ finalMessageId: "message-1" }, {}]) { + expect(ingestRuntimeEventInput(context, { kind: "run.completed", payload })).toMatchObject({ + status: "accepted", + }); + } + + for (const payload of [ + { finalMessageId: "" }, + { finalMessageId: null }, + { finalMessageId: 1 }, + { finalMessageId: "message-1", finalMessageText: "answer" }, + { finalMessageText: "answer" }, + ]) { + expect(ingestRuntimeEventInput(context, { kind: "run.completed", payload })).toMatchObject({ + status: "rejected", + }); + } + }); + + test.each([ + ["run.cancel.requested", "running", "completed"], + ["run.cancelled", "cancelled", "completed"], + ["run.completed", "completed", "failed"], + ["run.dispatched", "booting", "completed"], + ["run.failed", "failed", "completed"], + ["run.queued", "queued", "running"], + ["run.started", "running", "completed"], + ["run.steered", "waiting_input", "completed"], + ["run.waiting", "waiting_input", "completed"], + ] as const)("requires %s to agree with payload run.status", (kind, status, inconsistent) => { + const error = { code: "test.failed", details: {}, message: "failed", retryable: false }; + const payload = { + ...(kind === "run.failed" ? { error, recoverable: false } : {}), + run: { + completedAt: null, + error: kind === "run.failed" ? error : null, + startedAt: occurredAt, + status, + }, + }; + + expect(ingestRuntimeEventInput(createRuntimeEventContext(), { kind, payload })).toMatchObject({ + status: "accepted", + }); + expect( + ingestRuntimeEventInput(createRuntimeEventContext(), { + kind, + payload: { ...payload, run: { ...payload.run, status: inconsistent } }, + }), + ).toMatchObject({ + rejection: { message: `Runtime event ${kind} payload run.status is inconsistent.` }, + status: "rejected", + }); + }); + + test.each([ + ["run.cancel.requested", "running", "completed"], + ["run.cancelled", "cancelled", "completed"], + ["run.completed", "completed", "failed"], + ["run.dispatched", "booting", "completed"], + ["run.failed", "failed", "completed"], + ["run.queued", "queued", "running"], + ["run.started", "running", "completed"], + ["run.steered", "waiting_input", "completed"], + ["run.waiting", "waiting_input", "completed"], + ] as const)("requires %s to agree with payload status", (kind, status, inconsistent) => { + const error = { code: "test.failed", details: {}, message: "failed", retryable: false }; + const payload = { + ...(kind === "run.failed" ? { error, recoverable: false } : {}), + ...(kind === "run.started" ? { startedAt: occurredAt } : {}), + status, + }; + + expect(ingestRuntimeEventInput(createRuntimeEventContext(), { kind, payload })).toMatchObject({ + status: "accepted", + }); + expect( + ingestRuntimeEventInput(createRuntimeEventContext(), { + kind, + payload: { ...payload, status: inconsistent }, + }), + ).toMatchObject({ + rejection: { message: `Runtime event ${kind} payload status is inconsistent.` }, + status: "rejected", + }); + }); + + test("requires failed-run recoverability to agree with the durable error", () => { + const event = { + kind: "run.failed" as const, + payload: { + error: { + code: "driver.retryable", + details: {}, + message: "retryable", + retryable: true, + }, + recoverable: true, + status: "failed", + }, + }; + + expect(ingestRuntimeEventInput(createRuntimeEventContext(), event)).toMatchObject({ + status: "accepted", + }); + expect( + ingestRuntimeEventInput(createRuntimeEventContext(), { + ...event, + payload: { ...event.payload, recoverable: false }, + }), + ).toMatchObject({ + rejection: { + message: "Runtime event run.failed payload recoverable must agree with error.retryable.", + }, + status: "rejected", + }); + expect( + ingestRuntimeEventInput(createRuntimeEventContext(), { + ...event, + payload: { ...event.payload, recoverable: "yes" }, + }), + ).toMatchObject({ status: "rejected" }); + expect( + ingestRuntimeEventInput(createRuntimeEventContext(), { + ...event, + payload: { ...event.payload, recoverable: undefined }, + }), + ).toMatchObject({ status: "rejected" }); + expect( + ingestRuntimeEventInput(createRuntimeEventContext(), { + ...event, + payload: { + ...event.payload, + error: { ...event.payload.error, retryable: undefined }, + }, + }), + ).toMatchObject({ status: "rejected" }); + }); + + test("rejects non-primitive durable run error details instead of filtering them", () => { + expect( + ingestRuntimeEventInput(createRuntimeEventContext(), { + kind: "run.failed", + payload: { + error: { + code: "driver.invalid_details", + details: { nested: { value: "lost" } }, + message: "invalid details", + retryable: false, + }, + recoverable: false, + status: "failed", + }, + }), + ).toMatchObject({ + rejection: { + message: + "Runtime event run.failed payload error.details.nested must be a primitive JSON value.", + }, + status: "rejected", + }); + }); + + test("preserves control reasons within the durable command limit", () => { + const reason = `${"x".repeat(16 * 1_024)}界`; + + expect( + parseRuntimeCommand({ + commandId: "cancel", + kind: "turn.cancel", + reason, + runId: DRIVER_TEST_IDS.runId, + }), + ).toMatchObject({ kind: "turn.cancel", reason }); + expect(parseRuntimeCommand({ commandId: "stop", kind: "session.stop", reason })).toMatchObject({ + kind: "session.stop", + reason, + }); + }); + + test.each(["x", "界", "\0"])( + "bounds canonical runtime command JSON after %p UTF-8 encoding", + (unit) => { + const exact = mcpCommandAtSize(RUNTIME_COMMAND_MAX_UTF8_BYTES, unit); + + expect(parseRuntimeCommand(exact)).toEqual(exact); + expect(() => + parseRuntimeCommand({ ...exact, argumentsJson: `${exact.argumentsJson}x` }), + ).toThrow(`Runtime command exceeds ${String(RUNTIME_COMMAND_MAX_UTF8_BYTES)} UTF-8 bytes.`); + }, + ); + + test.each(["x", "界", "\0"])( + "omits an oversized command error after %p UTF-8 encoding", + (unit) => { + const exact = textFieldAtJsonSize( + DURABLE_RUN_ERROR_MAX_UTF8_BYTES, + (message) => ({ code: "driver.failed", details: {}, message, retryable: false }), + unit, + ); + const oversized = { ...exact, message: `${exact.message}x` }; + + expect(normalizeDurableRunError(exact)).toBe(exact); + expect(normalizeDurableRunError(oversized)).toEqual({ + code: "driver.error_oversized", + details: { originalBytes: DURABLE_RUN_ERROR_MAX_UTF8_BYTES + 1 }, + message: `Driver error exceeded ${String(DURABLE_RUN_ERROR_MAX_UTF8_BYTES)} UTF-8 bytes and was omitted.`, + retryable: false, + }); + }, + ); +}); + +describe("Driver runtime stream identity contract", () => { + test.each([ + ["message.added", "messageId", { content: "message" }], + ["message.cancelled", "messageId", {}], + ["message.completed", "messageId", {}], + ["message.delta", "messageId", { contentDelta: "message" }], + [ + "message.failed", + "messageId", + { error: { code: "failed", message: "failed", retryable: false } }, + ], + ["message.started", "messageId", {}], + ["thought.cancelled", "thoughtId", {}], + ["thought.completed", "thoughtId", {}], + ["thought.delta", "thoughtId", { contentDelta: "thought" }], + ["thought.started", "thoughtId", {}], + ] as const)("rejects %s without %s", (kind, identity, payload) => { + expect(ingestRuntimeEventInput(createRuntimeEventContext(), { kind, payload })).toMatchObject({ + rejection: { message: `${kind} ${identity} must be a non-empty string.` }, + status: "rejected", + }); + }); }); diff --git a/tests/driver-heartbeat-loop.test.ts b/tests/driver-heartbeat-loop.test.ts index 1acc844..2931d81 100644 --- a/tests/driver-heartbeat-loop.test.ts +++ b/tests/driver-heartbeat-loop.test.ts @@ -2,20 +2,13 @@ import { describe, expect, test } from "bun:test"; import { DriverHeartbeatLoop } from "../src/core/driver-heartbeat-loop"; import type { DriverRuntimeHeartbeatPort } from "../src/core/driver-runtime-io"; -import { createBufferedSinkLogger } from "../src/observability"; +import { createDisabledLogger } from "../src/observability"; import { promiseWithTimeout, sleepPromise } from "../src/utils/async"; -function createTestLogger() { - return createBufferedSinkLogger({ - level: "debug", - service: "driver-heartbeat-loop-test", - sink: async () => {}, - }); -} +const logger = createDisabledLogger(); describe("DriverHeartbeatLoop", () => { test("clamps a huge negotiated interval only when scheduling the timer", async () => { - const logger = createTestLogger(); const scheduledDelays: number[] = []; const nativeSetTimeout = globalThis.setTimeout; const loop = new DriverHeartbeatLoop({ @@ -43,13 +36,11 @@ describe("DriverHeartbeatLoop", () => { } finally { loop.stop(logger, "test.complete"); globalThis.setTimeout = nativeSetTimeout; - await logger.destroy(); } }); test("never overlaps heartbeat requests", async () => { const heartbeat = Promise.withResolvers<{ heartbeatCount: number; ok: true }>(); - const logger = createTestLogger(); let heartbeatCalls = 0; const socket: DriverRuntimeHeartbeatPort = { heartbeat: async () => { @@ -67,14 +58,12 @@ describe("DriverHeartbeatLoop", () => { loop.stop(logger, "test.complete"); heartbeat.resolve({ heartbeatCount: 1, ok: true }); await sleepPromise(0); - await logger.destroy(); expect(heartbeatCalls).toBe(1); }); test("reports one failed heartbeat to the supervisor and stops", async () => { const failed = Promise.withResolvers(); - const logger = createTestLogger(); let heartbeatCalls = 0; const socket: DriverRuntimeHeartbeatPort = { heartbeat: async () => { @@ -93,7 +82,6 @@ describe("DriverHeartbeatLoop", () => { timeoutMs: 100, }); await sleepPromise(10); - await logger.destroy(); expect(error).toBeInstanceOf(Error); expect(heartbeatCalls).toBe(1); @@ -104,7 +92,6 @@ describe("DriverHeartbeatLoop", () => { async (termination) => { const heartbeatEntered = Promise.withResolvers(); const heartbeatResult = Promise.withResolvers<{ heartbeatCount: number; ok: true }>(); - const logger = createTestLogger(); const failures: unknown[] = []; let shuttingDown = false; const loop = new DriverHeartbeatLoop({ @@ -130,7 +117,6 @@ describe("DriverHeartbeatLoop", () => { heartbeatResult.reject(new Error("late heartbeat failure")); await sleepPromise(0); loop.stop(logger, "test.cleanup"); - await logger.destroy(); expect(failures).toEqual([]); }, diff --git a/tests/driver-instance-socket-process.test.ts b/tests/driver-instance-socket-process.test.ts index f565533..836dd7f 100644 --- a/tests/driver-instance-socket-process.test.ts +++ b/tests/driver-instance-socket-process.test.ts @@ -4,10 +4,11 @@ import { DriverProcess } from "../src/bin/driver-process"; import { DriverInstanceSocket } from "../src/infrastructure/runtime/driver-instance-socket"; import type { AgentDriverContext } from "../src/core/agent-driver-backend"; import { DriverTurnCancelledError } from "../src/core/driver-runtime-state"; +import type { DriverBootPayload } from "../src/protocol/boot"; import type { RuntimeCommand } from "../src/runtime-command"; import { settlePromiseWithTimeout } from "../src/utils/async"; import { DRIVER_TEST_IDS, driverBootPayload } from "./driver-boot-payload-fixture"; -import { createBackend } from "./driver-runtime-boundary-fixtures"; +import { createBackend, settleBackendInput } from "./driver-runtime-boundary-fixtures"; const nativeWebSocket = globalThis.WebSocket; const nativeAbortSignalTimeout = AbortSignal.timeout; @@ -59,6 +60,7 @@ class RpcWebSocket extends OpenWebSocket { static eventBatchMaxSize = 2; static heartbeatFails = false; static heartbeatIntervalMs = 1_000; + static helloRunId: string | null = null; static lostResponsePath: string | null = null; static pathObserver: ((path: string) => void) | null = null; static receiptOverride: Partial<{ eventId: string; seq: number; type: string }> | null = null; @@ -135,15 +137,20 @@ class RpcWebSocket extends OpenWebSocket { eventBatchMaxSize: RpcWebSocket.eventBatchMaxSize, organizationPath: "/workspace", }, - runId: null, + runId: RpcWebSocket.helloRunId, }; } else if (path === "/driver/pushEvents") { - const events = (input as { events: { event: { kind: string } }[] }).events; + const events = ( + input as { + events: { event: { id: string; kind: string; sourceEventId?: string } }[]; + } + ).events; this.eventBatchSizes.push(events.length); const acceptedCount = RpcWebSocket.acceptedEventCounts.shift() ?? events.length; const accepted = events.slice(0, acceptedCount).map(({ event }) => { this.#nextEventSeq += 1; return { + eventId: event.sourceEventId ?? event.id, seq: this.#nextEventSeq, type: event.kind, }; @@ -201,6 +208,7 @@ afterEach(() => { RpcWebSocket.eventBatchMaxSize = 2; RpcWebSocket.heartbeatFails = false; RpcWebSocket.heartbeatIntervalMs = 1_000; + RpcWebSocket.helloRunId = null; RpcWebSocket.lostResponsePath = null; RpcWebSocket.pathObserver = null; RpcWebSocket.receiptOverride = null; @@ -331,6 +339,60 @@ describe("DriverProcess lifecycle", () => { expect(ready).toBeLessThan(heartbeat); }); + test("keeps the RPC lane open until backend-owned file reporting drains", async () => { + globalThis.WebSocket = RpcWebSocket as unknown as typeof WebSocket; + RpcWebSocket.stalledPath = "/driverInstance/nextCommand"; + RpcWebSocket.delayedEventKind = "file.changed"; + const existingSignalListeners = new Set(process.listeners("SIGTERM")); + const stopEntered = Promise.withResolvers(); + const backend = createBackend(); + let context: AgentDriverContext | null = null; + let report: Promise | null = null; + backend.start = async (startedContext) => { + context = startedContext; + }; + backend.stop = async () => { + stopEntered.resolve(); + await report; + }; + const run = new DriverProcess(driverBootPayload, () => backend).run(); + + await RpcWebSocket.stalled.promise; + RpcWebSocket.stalled = Promise.withResolvers(); + report = (context as AgentDriverContext | null)!.ports.file.reportChanged( + { + change: "upsert", + path: "/workspace/committed.txt", + reason: "test.commit", + }, + AbortSignal.timeout(5_000), + ); + void report.catch(() => {}); + await RpcWebSocket.stalled.promise; + + const shutdown = process + .listeners("SIGTERM") + .find((listener) => !existingSignalListeners.has(listener)); + expect(shutdown).toBeDefined(); + shutdown?.("SIGTERM"); + await stopEntered.promise; + expect((PendingWebSocket.instances[0] as RpcWebSocket).readyState).toBe(1); + + RpcWebSocket.delayedResponse.resolve(); + await expect(run).resolves.toBeUndefined(); + const fileEvents = (PendingWebSocket.instances[0] as RpcWebSocket).requests.flatMap( + ({ input, path }) => + path === "/driver/pushEvents" + ? ( + input as { + events: { event: { kind: string } }[]; + } + ).events.filter(({ event }) => event.kind === "file.changed") + : [], + ); + expect(fileEvents).toHaveLength(1); + }); + test("fails after an unexpected control disconnect and still cleans the backend", async () => { globalThis.WebSocket = RpcWebSocket as unknown as typeof WebSocket; RpcWebSocket.stalledPath = "/driverInstance/nextCommand"; @@ -392,6 +454,61 @@ describe("DriverProcess lifecycle", () => { expect(paths.filter((path) => path === "/driver/failRun")).toHaveLength(2); }); + test("reports startup failure to the run adopted during hello", async () => { + globalThis.WebSocket = RpcWebSocket as unknown as typeof WebSocket; + RpcWebSocket.helloRunId = DRIVER_TEST_IDS.secondRunId; + const prewarmPayload = { + ...driverBootPayload, + execution: { + ...driverBootPayload.execution, + configRevision: { + ...driverBootPayload.execution.configRevision, + runId: null, + }, + }, + } satisfies DriverBootPayload; + + await expect( + new DriverProcess(prewarmPayload, () => { + throw new Error("backend factory failed"); + }).run(), + ).rejects.toThrow("backend factory failed"); + + const failure = (PendingWebSocket.instances[0] as RpcWebSocket).requests.find( + ({ path }) => path === "/driver/failRun", + ); + expect(failure?.input).toMatchObject({ runId: DRIVER_TEST_IDS.secondRunId }); + }); + + test("fails the exact second run when its provider exits before a run event", async () => { + globalThis.WebSocket = RpcWebSocket as unknown as typeof WebSocket; + RpcWebSocket.commands = [ + { + commandId: "second-run-provider-failure", + input: { text: "fail before terminal" }, + kind: "input.start", + requestId: "second-run-provider-failure-request", + runId: DRIVER_TEST_IDS.secondRunId, + }, + ]; + const backend = createBackend(); + backend.handleInput = async () => { + throw new Error("second run provider exited"); + }; + + await expect(new DriverProcess(driverBootPayload, () => backend).run()).rejects.toThrow( + "second run provider exited", + ); + + const failure = (PendingWebSocket.instances[0] as RpcWebSocket).requests.find( + ({ path }) => path === "/driver/failRun", + ); + expect(failure?.input).toMatchObject({ runId: DRIVER_TEST_IDS.secondRunId }); + expect(failure?.input).not.toMatchObject({ + runId: driverBootPayload.execution.configRevision.runId, + }); + }); + test("does not report a control terminal when backend cleanup remains failed", async () => { globalThis.WebSocket = RpcWebSocket as unknown as typeof WebSocket; RpcWebSocket.stalledPath = "/driverInstance/nextCommand"; @@ -432,7 +549,7 @@ describe("DriverProcess lifecycle", () => { let decision: "allow_once" | "reject_once" | null = null; let sideEffect = false; let stoppedBeforePermissionSettled = false; - backend.handleInput = async (context) => { + backend.handleInput = async (context, _input, runId, signal) => { decision = await context.ports.permission.request({ rawInput: '{"command":"touch should-not-exist"}', requestId: "pending-permission", @@ -446,19 +563,7 @@ describe("DriverProcess lifecycle", () => { return; } - await context.ports.eventSink.pushEvents({ - events: [ - { - kind: "run.cancelled", - payload: { - reason: "signal.sigterm", - requestedBy: "user", - stopReason: "cancelled", - }, - runId: DRIVER_TEST_IDS.runId, - }, - ], - }); + await settleBackendInput(context, runId, signal); }; backend.stop = async () => { stoppedBeforePermissionSettled = decision === null; @@ -538,6 +643,75 @@ describe("DriverProcess lifecycle", () => { expect(process.listeners("SIGTERM")).toEqual([...existingSignalListeners]); }, 15_000); + test.each(["before", "after"] as const)( + "linearizes a completed run terminal selected %s input cancellation", + async (selectionOrder) => { + globalThis.WebSocket = RpcWebSocket as unknown as typeof WebSocket; + RpcWebSocket.commands = [ + { + commandId: `terminal-${selectionOrder}-input`, + input: { text: "complete once" }, + kind: "input.start", + requestId: `terminal-${selectionOrder}-request`, + runId: DRIVER_TEST_IDS.runId, + }, + { + commandId: `terminal-${selectionOrder}-cancel`, + kind: "turn.cancel", + reason: "test cancellation", + runId: DRIVER_TEST_IDS.runId, + }, + { + commandId: `terminal-${selectionOrder}-stop`, + kind: "session.stop", + reason: "test complete", + }, + ]; + const inputEntered = Promise.withResolvers(); + const cancellationEntered = Promise.withResolvers(); + const allowTerminalSelection = Promise.withResolvers(); + const backend = createBackend(); + backend.handleInput = async (context, _input, runId, signal) => { + inputEntered.resolve(); + if (selectionOrder === "after") { + await allowTerminalSelection.promise; + } + await settleBackendInput(context, runId, signal); + }; + backend.cancelActiveTurn = async () => { + cancellationEntered.resolve(); + allowTerminalSelection.resolve(); + }; + if (selectionOrder === "before") { + RpcWebSocket.delayedEventKind = "run.completed"; + } + + const run = new DriverProcess(driverBootPayload, () => backend).run(); + await inputEntered.promise; + if (selectionOrder === "before") { + await RpcWebSocket.stalled.promise; + RpcWebSocket.delayedResponse.resolve(); + } else { + await cancellationEntered.promise; + } + await expect(run).resolves.toBeUndefined(); + + const inputTerminals = (PendingWebSocket.instances[0] as RpcWebSocket).requests + .filter(({ path }) => path === "/driver/commandUpdate") + .map(({ input }) => input as { commandId: string; status: string }) + .filter( + ({ commandId, status }) => + commandId === `terminal-${selectionOrder}-input` && status !== "accepted", + ); + expect(inputTerminals).toEqual([ + expect.objectContaining({ + commandId: `terminal-${selectionOrder}-input`, + status: selectionOrder === "before" ? "completed" : "cancelled", + }), + ]); + }, + ); + test.each([ ["hello", "/driver/hello", false, 0], ["backend start", "/driverInstance/nextCommand", true, 2], @@ -634,71 +808,6 @@ describe("DriverProcess lifecycle", () => { expect(process.listeners("SIGTERM")).toEqual([...existingSignalListeners]); }); - test("automatically retries final cleanup after shutdown times out during backend start", async () => { - globalThis.WebSocket = RpcWebSocket as unknown as typeof WebSocket; - const existingSignalListeners = new Set(process.listeners("SIGTERM")); - const startEntered = Promise.withResolvers(); - const releaseStart = Promise.withResolvers(); - const finalCleanup = Promise.withResolvers(); - const backend = createBackend(); - let resourceActive = false; - let stopCount = 0; - let startSignal: AbortSignal | undefined; - backend.start = async (_context, signal) => { - startSignal = signal; - startEntered.resolve(); - await releaseStart.promise; - signal.throwIfAborted(); - resourceActive = true; - }; - backend.stop = async () => { - stopCount += 1; - resourceActive = false; - - if (stopCount === 3) { - finalCleanup.resolve(); - } - }; - const nativeSetTimeout = globalThis.setTimeout; - const acceleratedSetTimeout = ( - callback: (...args: unknown[]) => void, - delay?: number, - ...args: unknown[] - ) => nativeSetTimeout(callback, delay === 5_000 ? 10 : delay, ...args); - globalThis.setTimeout = acceleratedSetTimeout as typeof setTimeout; - - try { - const run = new DriverProcess(driverBootPayload, () => backend).run(); - const outcome = run.then( - () => null, - (error: unknown) => error, - ); - - await startEntered.promise; - const shutdown = process - .listeners("SIGTERM") - .find((listener) => !existingSignalListeners.has(listener)); - expect(shutdown).toBeDefined(); - shutdown?.("SIGTERM"); - await outcome; - expect(startSignal?.aborted).toBe(true); - expect(startSignal?.reason).toMatchObject({ message: "signal.sigterm" }); - releaseStart.resolve(); - - const cleaned = await Promise.race([ - finalCleanup.promise.then(() => true), - Bun.sleep(50).then(() => false), - ]); - expect(cleaned).toBe(true); - expect(stopCount).toBe(3); - expect(resourceActive).toBe(false); - expect(process.listeners("SIGTERM")).toEqual([...existingSignalListeners]); - } finally { - releaseStart.resolve(); - globalThis.setTimeout = nativeSetTimeout; - } - }); - test("propagates a heartbeat failure instead of treating it as a normal shutdown", async () => { globalThis.WebSocket = RpcWebSocket as unknown as typeof WebSocket; RpcWebSocket.heartbeatFails = true; @@ -860,10 +969,10 @@ describe("DriverProcess lifecycle", () => { const inputEntered = Promise.withResolvers(); const stopInput = Promise.withResolvers(); const backend = createBackend(); - backend.handleInput = async () => { + backend.handleInput = async (context, _input, runId, signal) => { inputEntered.resolve(); await stopInput.promise; - throw new DriverTurnCancelledError("signal shutdown"); + await settleBackendInput(context, runId, signal); }; backend.stop = async () => stopInput.resolve(); const run = new DriverProcess(driverBootPayload, () => backend).run(); @@ -937,99 +1046,6 @@ describe("DriverProcess lifecycle", () => { expect(socket.paths.filter((path) => path === "/driver/failRun")).toHaveLength(1); }); - test.each([ - ["transient", 1, "completed"], - ["persistent", Number.POSITIVE_INFINITY, "failed"], - ] as const)( - "bounds and retries a %s backend cleanup failure during process shutdown", - async (_name, failures, expectedStatus) => { - globalThis.WebSocket = RpcWebSocket as unknown as typeof WebSocket; - RpcWebSocket.stalledPath = "/driverInstance/nextCommand"; - const existingSignalListeners = new Set(process.listeners("SIGTERM")); - const backend = createBackend(); - let stopCount = 0; - backend.stop = async () => { - stopCount += 1; - - if (stopCount <= failures) { - throw new Error("cleanup failed"); - } - }; - const driver = new DriverProcess(driverBootPayload, () => backend); - const run = driver.run(); - - await RpcWebSocket.stalled.promise; - const shutdown = process - .listeners("SIGTERM") - .find((listener) => !existingSignalListeners.has(listener)); - expect(shutdown).toBeDefined(); - shutdown?.("SIGTERM"); - - const outcome = await settlePromiseWithTimeout(run, { - label: "driver process cleanup retry", - timeoutMs: 1_000, - }); - - expect(outcome.status).toBe(expectedStatus); - expect(stopCount).toBe(2); - expect(process.listeners("SIGTERM")).toEqual([...existingSignalListeners]); - }, - ); - - test("starts a new backend cleanup attempt after the previous one times out", async () => { - globalThis.WebSocket = RpcWebSocket as unknown as typeof WebSocket; - RpcWebSocket.stalledPath = "/driverInstance/nextCommand"; - const existingSignalListeners = new Set(process.listeners("SIGTERM")); - const firstStopAborted = Promise.withResolvers(); - const backend = createBackend(); - const stopSignals: AbortSignal[] = []; - let stopCount = 0; - backend.stop = async (_context, _reason, signal) => { - stopCount += 1; - stopSignals.push(signal); - - if (stopCount === 1) { - await new Promise((_resolve, reject) => { - signal.addEventListener( - "abort", - () => { - firstStopAborted.resolve(); - reject(signal.reason); - }, - { once: true }, - ); - }); - } - }; - const nativeSetTimeout = globalThis.setTimeout; - const acceleratedSetTimeout = ( - callback: (...args: unknown[]) => void, - delay?: number, - ...args: unknown[] - ) => nativeSetTimeout(callback, delay === 5_000 ? 10 : delay, ...args); - globalThis.setTimeout = acceleratedSetTimeout as typeof setTimeout; - - try { - const run = new DriverProcess(driverBootPayload, () => backend).run(); - await RpcWebSocket.stalled.promise; - const shutdown = process - .listeners("SIGTERM") - .find((listener) => !existingSignalListeners.has(listener)); - expect(shutdown).toBeDefined(); - shutdown?.("SIGTERM"); - - await expect(run).resolves.toBeUndefined(); - await firstStopAborted.promise; - expect(stopCount).toBe(2); - expect(stopSignals[0]?.aborted).toBe(true); - expect(stopSignals[1]).not.toBe(stopSignals[0]); - expect(stopSignals[1]?.aborted).toBe(false); - expect(process.listeners("SIGTERM")).toEqual([...existingSignalListeners]); - } finally { - globalThis.setTimeout = nativeSetTimeout; - } - }); - test("retries a shutdown failure joined by process finalization", async () => { globalThis.WebSocket = RpcWebSocket as unknown as typeof WebSocket; RpcWebSocket.stalledPath = "/driverInstance/nextCommand"; @@ -1056,8 +1072,6 @@ describe("DriverProcess lifecycle", () => { expect(shutdown).toBeDefined(); shutdown?.("SIGTERM"); await firstStopEntered.promise; - await Bun.sleep(10); - expect(stopCount).toBe(1); releaseFirstStop.resolve(); const outcome = await settlePromiseWithTimeout(run, { @@ -1070,6 +1084,33 @@ describe("DriverProcess lifecycle", () => { expect(process.listeners("SIGTERM")).toEqual([...existingSignalListeners]); }); + test("reports a persistent cleanup failure during signal shutdown", async () => { + globalThis.WebSocket = RpcWebSocket as unknown as typeof WebSocket; + RpcWebSocket.stalledPath = "/driverInstance/nextCommand"; + const existingSignalListeners = new Set(process.listeners("SIGTERM")); + const backend = createBackend(); + let stopCount = 0; + backend.stop = async () => { + stopCount += 1; + throw new Error("cleanup failed"); + }; + const run = new DriverProcess(driverBootPayload, () => backend).run(); + + await RpcWebSocket.stalled.promise; + process.listeners("SIGTERM").find((listener) => !existingSignalListeners.has(listener))?.( + "SIGTERM", + ); + + const outcome = await settlePromiseWithTimeout(run, { + label: "driver process persistent cleanup failure", + timeoutMs: 1_000, + }); + + expect(outcome).toMatchObject({ error: { message: "cleanup failed" }, status: "failed" }); + expect(stopCount).toBe(2); + expect(process.listeners("SIGTERM")).toEqual([...existingSignalListeners]); + }); + test.each([ ["transient", 1], ["persistent", Number.POSITIVE_INFINITY], diff --git a/tests/driver-instance-socket-socket.test.ts b/tests/driver-instance-socket-socket.test.ts index f37e2ac..854bdb2 100644 --- a/tests/driver-instance-socket-socket.test.ts +++ b/tests/driver-instance-socket-socket.test.ts @@ -1,6 +1,11 @@ import { afterEach, describe, expect, test } from "bun:test"; import { DriverInstanceSocket } from "../src/infrastructure/runtime/driver-instance-socket"; +import type { DriverEventInput } from "../src/protocol/events"; +import { + DURABLE_RUN_ERROR_MAX_UTF8_BYTES, + measureRuntimeCommandJson, +} from "../src/runtime-command"; import { settlePromiseWithTimeout } from "../src/utils/async"; import { DRIVER_TEST_IDS, driverBootPayload } from "./driver-boot-payload-fixture"; @@ -54,6 +59,7 @@ class RpcWebSocket extends OpenWebSocket { static lostResponsePath: string | null = null; static nextCommand: unknown = null; static receiptOverride: Partial<{ eventId: string; seq: number; type: string }> | null = null; + static responseOverrides = new Map(); static sendFailurePath: string | null = null; static stalledPath: string | null = null; static stalled = Promise.withResolvers(); @@ -118,12 +124,17 @@ class RpcWebSocket extends OpenWebSocket { runId: null, }; } else if (path === "/driver/pushEvents") { - const events = (input as { events: { event: { kind: string } }[] }).events; + const events = ( + input as { + events: { event: { id: string; kind: string; sourceEventId?: string } }[]; + } + ).events; this.eventBatchSizes.push(events.length); const acceptedCount = RpcWebSocket.acceptedEventCounts.shift() ?? events.length; const accepted = events.slice(0, acceptedCount).map(({ event }) => { this.#nextEventSeq += 1; return { + eventId: event.sourceEventId ?? event.id, seq: this.#nextEventSeq, type: event.kind, }; @@ -147,6 +158,10 @@ class RpcWebSocket extends OpenWebSocket { output = { ok: true }; } + if (RpcWebSocket.responseOverrides.has(path)) { + output = RpcWebSocket.responseOverrides.get(path); + } + queueMicrotask(() => { this.dispatchEvent( new MessageEvent("message", { @@ -183,6 +198,7 @@ afterEach(() => { RpcWebSocket.lostResponsePath = null; RpcWebSocket.nextCommand = null; RpcWebSocket.receiptOverride = null; + RpcWebSocket.responseOverrides.clear(); RpcWebSocket.sendFailurePath = null; RpcWebSocket.stalledPath = null; RpcWebSocket.stalled = Promise.withResolvers(); @@ -332,6 +348,27 @@ describe("DriverInstanceSocket lifecycle", () => { expect(closeNotifications).toEqual([[1000, "test shutdown"]]); }); + test("bounds close reasons at a complete UTF-8 character", async () => { + globalThis.WebSocket = OpenWebSocket as unknown as typeof WebSocket; + const socket = new DriverInstanceSocket(driverBootPayload, { + onClose: () => {}, + }); + await socket.connect(); + const reason = `${"x".repeat(121)}🙂diagnostic detail`; + const heartbeat = socket.heartbeat({ + at: new Date(0).toISOString(), + reason: "interval", + }); + await Promise.resolve(); + + socket.close(1000, reason); + + await expect(heartbeat).rejects.toThrow(reason); + const closeReason = PendingWebSocket.instances[0]?.closeReason; + expect(closeReason).toBe("x".repeat(121)); + expect(Buffer.byteLength(closeReason ?? "", "utf8")).toBeLessThanOrEqual(123); + }); + test("requires a fresh hello after reconnecting", async () => { globalThis.WebSocket = RpcWebSocket as unknown as typeof WebSocket; const socket = new DriverInstanceSocket(driverBootPayload, { @@ -406,6 +443,118 @@ describe("DriverInstanceSocket lifecycle", () => { socket.close(); }); + test("keeps durable effect settlement independent from aborted ordinary RPCs", async () => { + const socket = await connectRpcSocket(); + socket.abortPendingRequests("test shutdown"); + const signal = new AbortController().signal; + const result = { + outputText: "done", + requestId: "effect-request", + serverId: "effect-server", + toolName: "lookup", + }; + RpcWebSocket.responseOverrides.set("/driver/settleExternalToolEffect", { + effectId: "effect-1", + kind: "succeeded", + result, + }); + + await expect( + socket.settleExternalToolEffect( + { + claimToken: "00000000-0000-4000-8000-000000000001", + commandId: "effect-command", + effectId: "effect-1", + settlement: { kind: "succeeded", result }, + }, + signal, + ), + ).resolves.toEqual({ effectId: "effect-1", kind: "succeeded", result }); + + expect((PendingWebSocket.instances[0] as RpcWebSocket).paths).toEqual([ + "/driver/settleExternalToolEffect", + ]); + }); + + test("validates control-plane responses before returning them", async () => { + const socket = await connectRpcSocket(); + const signal = new AbortController().signal; + + RpcWebSocket.responseOverrides.set("/driver/heartbeat", { + heartbeatCount: 1, + ok: false, + }); + await expect( + socket.heartbeat({ at: new Date(0).toISOString(), reason: "interval" }), + ).rejects.toThrow("expected true"); + + RpcWebSocket.responseOverrides.set("/driver/ready", { ok: false }); + await expect(socket.ready({ at: new Date(0).toISOString() })).rejects.toThrow("expected true"); + + RpcWebSocket.responseOverrides.set("/driver/claimExternalToolEffect", { + attempt: 0, + effectId: "effect-1", + idempotencyKey: "effect-1", + kind: "claimed", + }); + await expect( + socket.claimExternalToolEffect( + { + claimToken: "00000000-0000-4000-8000-000000000001", + commandId: "command-1", + }, + signal, + ), + ).rejects.toThrow("attempt must be a positive safe integer"); + }); + + test("marks a run terminal delivered only after validating its response", async () => { + const socket = await connectRpcSocket(); + socket.beginRun(DRIVER_TEST_IDS.runId); + RpcWebSocket.responseOverrides.set("/driver/completeRun", { ok: false }); + + await expect(socket.completeRun()).rejects.toThrow("expected true"); + RpcWebSocket.responseOverrides.delete("/driver/completeRun"); + await expect(socket.completeRun()).resolves.toBeUndefined(); + + expect( + (PendingWebSocket.instances[0] as RpcWebSocket).paths.filter( + (path) => path === "/driver/completeRun", + ), + ).toHaveLength(2); + }); + + test("targets the active second run after the first run is released", async () => { + const socket = await connectRpcSocket(); + await socket.hello({ + capabilities: [], + driverVersion: "test", + protocolVersion: driverBootPayload.protocolVersion, + startedAt: new Date(0).toISOString(), + }); + const first = socket.beginRun(DRIVER_TEST_IDS.runId); + await socket.pushEvents({ + events: [{ kind: "run.completed", payload: { stopReason: "end_turn" } }], + }); + socket.releaseRun(first, "command_acked"); + socket.beginRun(DRIVER_TEST_IDS.secondRunId); + + await socket.failRun({ + code: "second.failed", + details: {}, + message: "second failed", + retryable: false, + }); + + expect( + ( + (PendingWebSocket.instances[0] as RpcWebSocket).requests.find( + ({ path }) => path === "/driver/failRun", + )!.input as { runId: string } + ).runId, + ).toBe(DRIVER_TEST_IDS.secondRunId); + }); + test("uses the terminal-attempt signal to abort only that command update", async () => { globalThis.WebSocket = RpcWebSocket as unknown as typeof WebSocket; RpcWebSocket.stalledPath = "/driver/commandUpdate"; @@ -439,28 +588,31 @@ describe("DriverInstanceSocket lifecycle", () => { test.each([ ["completeRun", "/driver/completeRun", "/driver/failRun"], ["failRun", "/driver/failRun", "/driver/completeRun"], - ] as const)("keeps a claimed %s run terminal monotonic", async (first, sent, skipped) => { - const socket = await connectRpcSocket(); - socket.beginRun(DRIVER_TEST_IDS.runId); - const failure = { - code: "test.failure", - details: {}, - message: "failed", - retryable: false, - }; + ] as const)( + "rejects a conflicting terminal after %s is acknowledged", + async (first, sent, skipped) => { + const socket = await connectRpcSocket(); + socket.beginRun(DRIVER_TEST_IDS.runId); + const failure = { + code: "test.failure", + details: {}, + message: "failed", + retryable: false, + }; - if (first === "completeRun") { - await socket.completeRun(); - await socket.failRun(failure); - } else { - await socket.failRun(failure); - await socket.completeRun(); - } + if (first === "completeRun") { + await socket.completeRun(); + await expect(socket.failRun(failure)).rejects.toThrow("conflicts"); + } else { + await socket.failRun(failure); + await expect(socket.completeRun()).rejects.toThrow("conflicts"); + } - const paths = (PendingWebSocket.instances[0] as RpcWebSocket).paths; - expect(paths.filter((path) => path === sent)).toHaveLength(1); - expect(paths).not.toContain(skipped); - }); + const paths = (PendingWebSocket.instances[0] as RpcWebSocket).paths; + expect(paths.filter((path) => path === sent)).toHaveLength(1); + expect(paths).not.toContain(skipped); + }, + ); test.each([ ["completeRun", "/driver/completeRun", "/driver/failRun"], @@ -483,7 +635,7 @@ describe("DriverInstanceSocket lifecycle", () => { RpcWebSocket.sendFailurePath = sent; await expect(deliver()).rejects.toThrow("test wire send failed"); - await expect(deliverOpposite()).resolves.toBeUndefined(); + await expect(deliverOpposite()).rejects.toThrow("conflicts"); await expect(deliver()).resolves.toBeUndefined(); await expect(deliver()).resolves.toBeUndefined(); @@ -546,7 +698,7 @@ describe("DriverInstanceSocket lifecycle", () => { failure.message = "mutated failure"; await expect(first).rejects.toThrow("test wire send failed"); - await expect(socket.failRun(failure)).rejects.toThrow("different error"); + await expect(socket.failRun(failure)).rejects.toThrow("conflicts"); await expect(socket.failRun(selected)).resolves.toBeUndefined(); await expect(socket.failRun(selected)).resolves.toBeUndefined(); @@ -556,11 +708,46 @@ describe("DriverInstanceSocket lifecycle", () => { input: { driverInstanceId: DRIVER_TEST_IDS.driverInstanceId, error: selected, + runId: DRIVER_TEST_IDS.runId, }, path: "/driver/failRun", }); }); + test.each([ + ["command update", "/driver/commandUpdate"], + ["run terminal", "/driver/failRun"], + ] as const)("omits an oversized %s error before RPC delivery", async (kind, path) => { + const socket = await connectRpcSocket(); + const base = { code: "test.failure", details: {}, message: "", retryable: false }; + const oversized = { + ...base, + message: "x".repeat(DURABLE_RUN_ERROR_MAX_UTF8_BYTES + 1 - measureRuntimeCommandJson(base)), + }; + + if (kind === "command update") { + await socket.commandUpdate( + { commandId: "oversized-error-command", error: oversized, status: "failed" }, + new AbortController().signal, + ); + } else { + socket.beginRun(DRIVER_TEST_IDS.runId); + await socket.failRun(oversized); + } + + expect(measureRuntimeCommandJson(oversized)).toBe(DURABLE_RUN_ERROR_MAX_UTF8_BYTES + 1); + const request = (PendingWebSocket.instances[0] as RpcWebSocket).requests.find( + (candidate) => candidate.path === path, + ); + expect(request).toBeDefined(); + expect((request!.input as { error: unknown }).error).toEqual({ + code: "driver.error_oversized", + details: { originalBytes: DURABLE_RUN_ERROR_MAX_UTF8_BYTES + 1 }, + message: `Driver error exceeded ${String(DURABLE_RUN_ERROR_MAX_UTF8_BYTES)} UTF-8 bytes and was omitted.`, + retryable: false, + }); + }); + test.each([ ["completeRun", "/driver/completeRun"], ["failRun", "/driver/failRun"], @@ -593,6 +780,86 @@ describe("DriverInstanceSocket lifecycle", () => { const secondWire = PendingWebSocket.instances[1] as RpcWebSocket; expect(firstWire.paths.filter((sent) => sent === path)).toHaveLength(1); expect(secondWire.paths.filter((sent) => sent === path)).toHaveLength(1); + expect( + [firstWire, secondWire].map( + (wire) => + (wire.requests.find((request) => request.path === path)!.input as { runId: string }) + .runId, + ), + ).toEqual([DRIVER_TEST_IDS.runId, DRIVER_TEST_IDS.runId]); + }); + + test("reuses an implicit run terminal identity after its persisted ACK is lost", async () => { + globalThis.WebSocket = RpcWebSocket as unknown as typeof WebSocket; + const socket = new DriverInstanceSocket(driverBootPayload, { onClose: () => {} }); + await socket.connect(); + await socket.hello({ + capabilities: [], + driverVersion: "test", + protocolVersion: driverBootPayload.protocolVersion, + startedAt: new Date(0).toISOString(), + }); + socket.beginRun(DRIVER_TEST_IDS.runId); + const event: DriverEventInput = { + kind: "run.completed", + payload: { stopReason: "end_turn" }, + }; + const firstWire = PendingWebSocket.instances[0] as RpcWebSocket; + RpcWebSocket.lostResponsePath = "/driver/pushEvents"; + + const first = socket.pushEvents({ events: [event] }); + await RpcWebSocket.stalled.promise; + firstWire.close(1006, "terminal ACK lost"); + await expect(first).rejects.toThrow("terminal ACK lost"); + + await socket.connect(); + await socket.hello({ + capabilities: [], + driverVersion: "test", + protocolVersion: driverBootPayload.protocolVersion, + startedAt: new Date(0).toISOString(), + }); + await expect(socket.pushEvents({ events: [event] })).resolves.toMatchObject({ + accepted: [{ type: "run.completed" }], + }); + + const sourceIds = [firstWire, PendingWebSocket.instances[1] as RpcWebSocket].map( + (wire) => + ( + wire.requests.find((request) => request.path === "/driver/pushEvents")!.input as { + events: { event: { sourceEventId: string } }[]; + } + ).events[0]!.event.sourceEventId, + ); + expect(sourceIds[0]).toBeString(); + expect(sourceIds[1]).toBe(sourceIds[0]); + }); + + test("rejects event receipts that are not a submitted-prefix", async () => { + const socket = await connectRpcSocket(); + await socket.hello({ + capabilities: [], + driverVersion: "test", + protocolVersion: driverBootPayload.protocolVersion, + startedAt: new Date(0).toISOString(), + }); + RpcWebSocket.responseOverrides.set("/driver/pushEvents", { + accepted: [ + { eventId: "extra-1", seq: 1, type: "message.completed" }, + { eventId: "extra-2", seq: 2, type: "message.completed" }, + ], + }); + + await expect( + socket.pushEvents({ + events: [ + { + kind: "message.completed", + payload: { messageId: "message-1", stopReason: "end_turn" }, + }, + ], + }), + ).rejects.toThrow("receipt count exceeds"); }); test("splits event delivery at the negotiated batch limit", async () => { @@ -655,6 +922,308 @@ describe("DriverInstanceSocket lifecycle", () => { expect(result.accepted).toHaveLength(3); }); + test("isolates each run terminal before reserving or delivering it", async () => { + RpcWebSocket.eventBatchMaxSize = 2; + const socket = await connectRpcSocket(); + await socket.hello({ + capabilities: [], + driverVersion: "test", + protocolVersion: driverBootPayload.protocolVersion, + startedAt: new Date(0).toISOString(), + }); + socket.beginRun(DRIVER_TEST_IDS.runId); + const delta: DriverEventInput = { + kind: "message.delta", + payload: { contentDelta: "x", messageId: "message-1", role: "agent" }, + }; + const completed: DriverEventInput = { + kind: "run.completed", + payload: { stopReason: "end_turn" }, + sourceEventId: "linearized-run-completed", + }; + let barrierCalls = 0; + socket.registerRunTerminalBarrier(() => { + barrierCalls += 1; + }); + + await expect(socket.pushEvents({ events: [completed, delta] })).rejects.toThrow( + "must be the only event", + ); + await expect( + socket.pushEvents({ + events: [ + completed, + { + kind: "run.failed", + payload: { + error: { code: "failed", message: "failed", retryable: false }, + recoverable: false, + }, + }, + ], + }), + ).rejects.toThrow("multiple run terminals"); + await expect( + socket.pushEvents({ events: [{ ...completed, delivery: "best_effort" }] }), + ).rejects.toThrow("must use lossless delivery"); + expect(socket.runSnapshot(DRIVER_TEST_IDS.runId)?.terminal).toBeNull(); + expect(barrierCalls).toBe(1); + + await expect(socket.pushEvents({ events: [delta, completed] })).rejects.toThrow( + "must be the only event", + ); + expect(socket.runSnapshot(DRIVER_TEST_IDS.runId)?.terminal).toBeNull(); + expect(barrierCalls).toBe(1); + + await expect(socket.pushEvents({ events: [completed] })).resolves.toMatchObject({ + accepted: [{ type: "run.completed" }], + }); + expect(barrierCalls).toBe(2); + expect(socket.runSnapshot(DRIVER_TEST_IDS.runId)?.terminal).toMatchObject({ + phase: "acked", + value: { status: "completed" }, + }); + await expect(socket.pushEvents({ events: [delta] })).rejects.toThrow( + "cannot target a terminated run", + ); + await expect(socket.pushEvents({ events: [{ ...delta, runId: null }] })).resolves.toMatchObject( + { accepted: [{ type: "message.delta" }] }, + ); + await expect( + socket.pushEvents({ events: [{ ...delta, runId: DRIVER_TEST_IDS.secondRunId }] }), + ).rejects.toThrow("must target the active run"); + }); + + test("does not replace an active run or discard its terminal state", async () => { + const socket = await connectRpcSocket(); + await socket.hello({ + capabilities: [], + driverVersion: "test", + protocolVersion: driverBootPayload.protocolVersion, + startedAt: new Date(0).toISOString(), + }); + const ticket = socket.beginRun(DRIVER_TEST_IDS.runId); + await socket.pushEvents({ + events: [{ kind: "run.completed", payload: { stopReason: "end_turn" } }], + }); + + expect(() => socket.beginRun(DRIVER_TEST_IDS.secondRunId)).toThrow("already active"); + expect(socket.currentRunId()).toBe(DRIVER_TEST_IDS.runId); + expect(socket.runSnapshot(DRIVER_TEST_IDS.runId)?.terminal).toMatchObject({ + phase: "acked", + value: { status: "completed" }, + }); + + socket.releaseRun(ticket, "command_acked"); + socket.beginRun(DRIVER_TEST_IDS.secondRunId); + expect(socket.currentRunId()).toBe(DRIVER_TEST_IDS.secondRunId); + expect(socket.runSnapshot(DRIVER_TEST_IDS.runId)).toBeNull(); + }); + + test("reserves a terminal before its RPC and serializes the matching control terminal", async () => { + const socket = await connectRpcSocket(); + await socket.hello({ + capabilities: [], + driverVersion: "test", + protocolVersion: driverBootPayload.protocolVersion, + startedAt: new Date(0).toISOString(), + }); + socket.beginRun(DRIVER_TEST_IDS.runId); + RpcWebSocket.stalledPath = "/driver/pushEvents"; + const completed: DriverEventInput = { + kind: "run.completed", + payload: { stopReason: "end_turn" }, + }; + const failed: DriverEventInput = { + kind: "run.failed", + payload: { + error: { code: "failed", message: "failed", retryable: false }, + recoverable: false, + }, + }; + const failure = { + code: "failed", + details: {}, + message: "failed", + retryable: false, + }; + + const eventTerminal = socket.pushEvents({ events: [completed] }); + await RpcWebSocket.stalled.promise; + expect(socket.runSnapshot(DRIVER_TEST_IDS.runId)?.terminal).toMatchObject({ + phase: "selected", + value: { status: "completed" }, + }); + await expect(socket.pushEvents({ events: [failed] })).rejects.toThrow( + "conflicts with the selected terminal", + ); + const matchingControl = socket.completeRun(); + const eventTerminalOutcome = eventTerminal.then( + () => null, + (error: unknown) => error, + ); + const matchingControlOutcome = matchingControl.then( + () => null, + (error: unknown) => error, + ); + await expect(socket.failRun(failure)).rejects.toThrow("conflicts"); + await Bun.sleep(0); + + const firstWire = PendingWebSocket.instances[0] as RpcWebSocket; + expect(firstWire.paths.filter((path) => path !== "/driver/hello")).toEqual([ + "/driver/pushEvents", + ]); + firstWire.close(1006, "terminal response lost"); + const eventTerminalError = await eventTerminalOutcome; + const matchingControlError = await matchingControlOutcome; + expect(eventTerminalError).toBeInstanceOf(Error); + expect(matchingControlError).toBeInstanceOf(Error); + expect(matchingControlError as Error).toHaveProperty( + "message", + expect.stringContaining("connection changed"), + ); + expect(socket.runSnapshot(DRIVER_TEST_IDS.runId)?.terminal?.phase).toBe("selected"); + + await socket.connect(); + await socket.hello({ + capabilities: [], + driverVersion: "test", + protocolVersion: driverBootPayload.protocolVersion, + startedAt: new Date(0).toISOString(), + }); + await expect(socket.pushEvents({ events: [failed] })).rejects.toThrow( + "conflicts with the selected terminal", + ); + expect((PendingWebSocket.instances[1] as RpcWebSocket).paths).toEqual(["/driver/hello"]); + }); + + test.each([ + ["completeRun", "/driver/completeRun"], + ["failRun", "/driver/failRun"], + ] as const)("does not begin another run after an in-flight %s", async (selected, path) => { + const socket = await connectRpcSocket(); + const ticket = socket.beginRun(DRIVER_TEST_IDS.runId); + const failure = { + code: "test.failure", + details: {}, + message: "failed", + retryable: false, + }; + RpcWebSocket.lostResponsePath = path; + const terminal = selected === "completeRun" ? socket.completeRun() : socket.failRun(failure); + await RpcWebSocket.stalled.promise; + + socket.releaseRun(ticket, "driver_failing"); + expect(() => socket.beginRun(DRIVER_TEST_IDS.secondRunId)).toThrow("instance terminal"); + expect(socket.currentRunId()).toBeNull(); + expect(selected === "completeRun" ? socket.completeRun() : socket.failRun(failure)).toBe( + terminal, + ); + await expect( + selected === "completeRun" ? socket.failRun(failure) : socket.completeRun(), + ).rejects.toThrow("conflicts"); + + (PendingWebSocket.instances[0] as RpcWebSocket).close(1006, "terminal response lost"); + await expect(terminal).rejects.toThrow("terminal response lost"); + expect(() => socket.beginRun(DRIVER_TEST_IDS.secondRunId)).toThrow("instance terminal"); + }); + + test("does not redirect a queued run event after the active run ends", async () => { + const socket = await connectRpcSocket(); + await socket.hello({ + capabilities: [], + driverVersion: "test", + protocolVersion: driverBootPayload.protocolVersion, + startedAt: new Date(0).toISOString(), + }); + const ticket = socket.beginRun(DRIVER_TEST_IDS.runId); + RpcWebSocket.stalledPath = "/driver/pushEvents"; + const controller = new AbortController(); + const blocker = socket.pushEvents({ + events: [ + { + kind: "diagnostic.reported", + payload: { code: "queue.blocker", message: "block", severity: "info" }, + runId: null, + }, + ], + signal: controller.signal, + }); + await RpcWebSocket.stalled.promise; + const queuedTerminal = socket.pushEvents({ + events: [{ kind: "run.completed", payload: { stopReason: "end_turn" } }], + }); + const queuedOutcome = queuedTerminal.then( + () => null, + (error: unknown) => error, + ); + + socket.releaseRun(ticket, "driver_failing"); + socket.beginRun(DRIVER_TEST_IDS.secondRunId); + controller.abort(new Error("release old run")); + await expect(blocker).rejects.toThrow(); + const queuedError = await queuedOutcome; + expect(queuedError).toBeInstanceOf(Error); + expect(queuedError as Error).toHaveProperty( + "message", + expect.stringContaining("active run changed"), + ); + expect(socket.runSnapshot(DRIVER_TEST_IDS.runId)).toBeNull(); + expect( + (PendingWebSocket.instances[0] as RpcWebSocket).paths.filter( + (path) => path === "/driver/pushEvents", + ), + ).toHaveLength(1); + + RpcWebSocket.stalledPath = null; + await expect( + socket.pushEvents({ + events: [ + { + kind: "message.delta", + payload: { contentDelta: "new", messageId: "message-2", role: "agent" }, + }, + ], + }), + ).resolves.toMatchObject({ accepted: [{ type: "message.delta" }] }); + }); + + test("keeps a queued instance terminal owned after its run ends", async () => { + const socket = await connectRpcSocket(); + await socket.hello({ + capabilities: [], + driverVersion: "test", + protocolVersion: driverBootPayload.protocolVersion, + startedAt: new Date(0).toISOString(), + }); + const ticket = socket.beginRun(DRIVER_TEST_IDS.runId); + RpcWebSocket.stalledPath = "/driver/pushEvents"; + const controller = new AbortController(); + const blocker = socket.pushEvents({ + events: [ + { + kind: "diagnostic.reported", + payload: { code: "queue.blocker", message: "block", severity: "info" }, + runId: null, + }, + ], + signal: controller.signal, + }); + await RpcWebSocket.stalled.promise; + const oldTerminal = socket.completeRun(); + + socket.releaseRun(ticket, "driver_failing"); + expect(() => socket.beginRun(DRIVER_TEST_IDS.secondRunId)).toThrow("instance terminal"); + controller.abort(new Error("release old run")); + + await expect(blocker).rejects.toThrow(); + await expect(oldTerminal).resolves.toBeUndefined(); + const paths = (PendingWebSocket.instances[0] as RpcWebSocket).paths; + expect(paths.filter((path) => path === "/driver/pushEvents")).toHaveLength(1); + expect(paths).toContain("/driver/completeRun"); + expect(socket.currentRunId()).toBeNull(); + }); + test("does not retry an unaccepted best-effort suffix", async () => { RpcWebSocket.eventBatchMaxSize = 2; RpcWebSocket.acceptedEventCounts = [1]; diff --git a/tests/driver-logger.test.ts b/tests/driver-logger.test.ts index 7fb24d5..fb5d519 100644 --- a/tests/driver-logger.test.ts +++ b/tests/driver-logger.test.ts @@ -5,12 +5,14 @@ import type { DriverInstanceSocket } from "../src/infrastructure/runtime/driver- import type { DriverBootPayload } from "../src/protocol/boot"; import type { DriverLogBatchInput } from "../src/protocol/orpc"; -const FLUSH_INTERVAL_MS = 200; - function createBootPayload(): DriverBootPayload { return { driverInstanceId: "01J00000000000000000000DRV", - sandboxId: "01J00000000000000000000SBX", + execution: { + session: { + context: { sandboxId: "01J00000000000000000000SBX" }, + }, + }, } as DriverBootPayload; } @@ -42,10 +44,6 @@ function createFakeSocket(): FakeSocket { return state; } -async function waitForFlushWindow(): Promise { - await Bun.sleep(FLUSH_INTERVAL_MS + 100); -} - describe("createDriverLogger", () => { test("holds log batches until the uplink gate opens", async () => { const fake = createFakeSocket(); @@ -54,11 +52,16 @@ describe("createDriverLogger", () => { logger.info("driver.runtime.boot.loaded"); logger.info("driver.runtime.hello.sending"); - await waitForFlushWindow(); + let flushed = false; + const flush = logger.flush().then(() => { + flushed = true; + }); + await Promise.resolve(); + expect(flushed).toBe(false); expect(fake.batches).toHaveLength(0); uplink.open(); - await logger.flush(); + await flush; const sent = fake.batches.flatMap((batch) => batch.logs); expect(sent.map((entry) => entry.message)).toEqual([ diff --git a/tests/driver-permission-broker.test.ts b/tests/driver-permission-broker.test.ts index b702203..09f4009 100644 --- a/tests/driver-permission-broker.test.ts +++ b/tests/driver-permission-broker.test.ts @@ -5,9 +5,16 @@ import { PermissionEventDeliveryError, } from "../src/core/driver-permission-broker"; import type { DriverRuntimeEventPort } from "../src/core/driver-runtime-io"; +import { toDriverEventEnvelopes } from "../src/infrastructure/runtime/driver-instance-socket"; import type { DriverEventInput } from "../src/protocol/events"; +import type { RunId } from "../src/protocol/id"; import type { DriverEventBatchOutput } from "../src/protocol/orpc"; +import { CMA_MAX_EVENT_BYTES } from "../src/stores/cma-store"; +import { createCmaMemoryStore } from "../src/stores/memory"; import { settlePromiseWithTimeout } from "../src/utils/async"; +import { DRIVER_TEST_IDS, driverBootPayload } from "./driver-boot-payload-fixture"; + +const runId = "run-1" as RunId; interface RecordingSocket extends DriverRuntimeEventPort { readonly pushedEvents: DriverEventInput[]; @@ -16,6 +23,7 @@ interface RecordingSocket extends DriverRuntimeEventPort { function acceptEvents(events: readonly DriverEventInput[]): DriverEventBatchOutput { return { accepted: events.map((event, index) => ({ + eventId: event.sourceEventId!, seq: index + 1, type: event.kind, })), @@ -26,6 +34,7 @@ function createRecordingSocket(): RecordingSocket { const pushedEvents: DriverEventInput[] = []; return { + currentRunId: () => runId, pushedEvents, pushEvents: async (input) => { pushedEvents.push(...input.events); @@ -35,6 +44,15 @@ function createRecordingSocket(): RecordingSocket { } const permissionInput = { + agentId: "subagent-1", + blockedPath: "/workspace/secret", + decisionReason: "Path is outside the allowed roots.", + description: "Read access to /workspace/secret", + matchedAskRule: { + ruleContent: "Read(/workspace/secret/**)", + source: "project", + toolName: "Read", + }, rawInput: '{"command":"fd ."}', requestId: "permission-1", title: "Approve command execution", @@ -58,7 +76,16 @@ describe("DriverPermissionBroker", () => { { kind: "permission.requested", payload: { + agentId: "subagent-1", + blockedPath: "/workspace/secret", + decisionReason: "Path is outside the allowed roots.", + description: "Read access to /workspace/secret", details: '{"command":"fd ."}', + matchedAskRule: { + ruleContent: "Read(/workspace/secret/**)", + source: "project", + toolName: "Read", + }, requestId: "permission-1", targetItemId: "tool-1", title: "Approve command execution", @@ -97,6 +124,29 @@ describe("DriverPermissionBroker", () => { await expect(first).resolves.toBe("allow_once"); }); + test("rejects a request whose delivery outlives its run generation", async () => { + const deliveryEntered = Promise.withResolvers(); + const releaseDelivery = Promise.withResolvers(); + const broker = new DriverPermissionBroker(() => null); + let ownsRun = true; + const socket: DriverRuntimeEventPort = { + currentRunId: () => runId, + pushEvents: async ({ events }) => { + deliveryEntered.resolve(); + await releaseDelivery.promise; + return acceptEvents(events); + }, + }; + + const request = broker.request(socket, permissionInput, undefined, () => ownsRun); + await deliveryEntered.promise; + ownsRun = false; + releaseDelivery.resolve(); + + await expect(request).resolves.toBe("reject_once"); + expect(broker.hasPending()).toBe(false); + }); + test("bounds the number of pending requests", async () => { const broker = new DriverPermissionBroker(() => null, { maxPendingRequestBytes: 1_024, @@ -153,6 +203,55 @@ describe("DriverPermissionBroker", () => { expect(socket.pushedEvents).toEqual([]); }); + test("rejects an oversized permission event before the real CMA boundary", async () => { + const broker = new DriverPermissionBroker(() => null); + const store = createCmaMemoryStore({ sessions: [{ id: DRIVER_TEST_IDS.sessionId }] }); + const pushedEvents: DriverEventInput[] = []; + let sequence = 0; + const socket: RecordingSocket = { + currentRunId: () => DRIVER_TEST_IDS.runId, + pushedEvents, + pushEvents: async ({ events }) => { + for (const event of events) { + const [envelope] = toDriverEventEnvelopes( + driverBootPayload, + event, + DRIVER_TEST_IDS.runId, + ); + await store.appendDriverEvent(DRIVER_TEST_IDS.sessionId, envelope!.event); + } + pushedEvents.push(...events); + return { + accepted: events.map((event) => ({ + eventId: event.sourceEventId!, + seq: (sequence += 1), + type: event.kind, + })), + }; + }, + }; + const safeInput = { ...permissionInput, rawInput: "x".repeat(500_000) }; + const safeRequest = broker.request(socket, safeInput); + + await Bun.sleep(0); + expect(broker.resolve(safeInput.requestId, "reject_once")).toBe(true); + await expect(safeRequest).resolves.toBe("reject_once"); + expect(pushedEvents.map(({ kind }) => kind)).toEqual([ + "permission.requested", + "permission.resolved", + ]); + + await expect( + broker.request(socket, { + ...permissionInput, + rawInput: "x".repeat(CMA_MAX_EVENT_BYTES), + requestId: "permission-oversized", + }), + ).rejects.toThrow("permission request event exceeds 524288 UTF-8 bytes"); + expect(pushedEvents).toHaveLength(2); + expect(broker.hasPending()).toBe(false); + }); + test.each([0, -1, 1.5, Number.POSITIVE_INFINITY, Number.NaN])( "rejects invalid pending limits %p", (limit) => { @@ -181,12 +280,13 @@ describe("DriverPermissionBroker", () => { expect(() => new DriverPermissionBroker(() => null, { requestTimeoutMs: 0 })).not.toThrow(); }); - test.each(["resolve", "abort", "rejectAll", "timeout", "publish failure"] as const)( + test.each(["resolve", "abort", "rejectAll", "timeout", "publish retry"] as const)( "returns pending capacity after %s", async (mode) => { const controller = new AbortController(); - let failPublish = mode === "publish failure"; + let failPublish = mode === "publish retry"; const socket: DriverRuntimeEventPort = { + currentRunId: () => runId, pushEvents: async ({ events }) => { if (failPublish) { failPublish = false; @@ -209,8 +309,9 @@ describe("DriverPermissionBroker", () => { controller.abort(); await expect(first).resolves.toBe("reject_once"); break; - case "publish failure": - await expect(first).rejects.toBeInstanceOf(PermissionEventDeliveryError); + case "publish retry": + expect(broker.resolve(permissionInput.requestId, "allow_once")).toBe(true); + await expect(first).resolves.toBe("allow_once"); break; case "rejectAll": broker.rejectAll(); @@ -241,6 +342,7 @@ describe("DriverPermissionBroker", () => { let stallRequested = true; let requestedEvents: readonly DriverEventInput[] = []; const socket: DriverRuntimeEventPort = { + currentRunId: () => runId, pushEvents: async ({ events }) => { if (stallRequested && events.some((event) => event.kind === "permission.requested")) { requestedEvents = events; @@ -303,6 +405,7 @@ describe("DriverPermissionBroker", () => { let stallResolution = true; let resolutionEvents: readonly DriverEventInput[] = []; const socket: DriverRuntimeEventPort = { + currentRunId: () => runId, pushEvents: async ({ events }) => { if (stallResolution && events.some((event) => event.kind === "permission.resolved")) { resolutionEvents = events; @@ -363,6 +466,7 @@ describe("DriverPermissionBroker", () => { requestTimeoutMs: 1, }); const socket: DriverRuntimeEventPort = { + currentRunId: () => runId, pushEvents: async ({ events }) => { if (events.some((event) => event.kind === "permission.resolved")) { await Bun.sleep(10); @@ -383,6 +487,7 @@ describe("DriverPermissionBroker", () => { maxPendingRequests: 1, }); const socket: DriverRuntimeEventPort = { + currentRunId: () => runId, pushEvents: async ({ events }) => { if (failResolution && events.some((event) => event.kind === "permission.resolved")) { throw deliveryFailure; @@ -406,7 +511,7 @@ describe("DriverPermissionBroker", () => { phase: "resolved", requestId: permissionInput.requestId, }); - expect((outcome.error as Error).cause).toBe(deliveryFailure); + expect(((outcome.error as Error).cause as Error).cause).toBe(deliveryFailure); } failResolution = false; @@ -435,6 +540,7 @@ describe("DriverPermissionBroker", () => { const releaseResolution = Promise.withResolvers(); const controller = new AbortController(); const socket: DriverRuntimeEventPort = { + currentRunId: () => runId, pushEvents: async ({ events }) => { if (events.some((event) => event.kind === "permission.resolved")) { resolutionPublishing.resolve(); @@ -493,6 +599,7 @@ describe("DriverPermissionBroker", () => { let resolvedEvents = 0; const broker = new DriverPermissionBroker(() => null, { requestTimeoutMs: 100 }); const socket: DriverRuntimeEventPort = { + currentRunId: () => runId, pushEvents: async ({ events }) => { if (events.some((event) => event.kind === "permission.resolved")) { resolvedEvents += 1; @@ -553,6 +660,7 @@ describe("DriverPermissionBroker", () => { requestTimeoutMs: 100, }); const socket: DriverRuntimeEventPort = { + currentRunId: () => runId, pushEvents: async ({ events }) => { if (events.some((event) => event.kind === "permission.resolved")) { resolutionCount += 1; @@ -581,39 +689,53 @@ describe("DriverPermissionBroker", () => { expect(broker.hasPending()).toBe(false); }); - test("wraps a rejected request delivery and releases its identity", async () => { - const deliveryFailure = new Error("event sink unavailable"); - let fail = true; + test("replays one stable lifecycle when persisted permission ACKs are lost", async () => { + const attempts: DriverEventInput[][] = []; + const persisted = new Map(); + const phaseAttempts = new Map(); const broker = new DriverPermissionBroker(() => null); const socket: DriverRuntimeEventPort = { + currentRunId: () => runId, pushEvents: async ({ events }) => { - if (fail) { - fail = false; - throw deliveryFailure; + const owned = structuredClone(events); + attempts.push(owned); + for (const event of owned) { + const sourceEventId = event.sourceEventId!; + const content = JSON.stringify(event); + const previous = persisted.get(sourceEventId); + expect(previous === undefined || previous === content).toBe(true); + persisted.set(sourceEventId, content); } - return acceptEvents(events); + const phase = owned[0]!.kind; + const attempt = (phaseAttempts.get(phase) ?? 0) + 1; + phaseAttempts.set(phase, attempt); + if (attempt === 1) { + throw new Error(`${phase} ACK lost after persistence`); + } + return acceptEvents(owned); }, }; - const outcome = await settlePromiseWithTimeout(broker.request(socket, permissionInput), { - label: "rejected permission request delivery", - timeoutMs: 100, - }); - expect(outcome.status).toBe("failed"); - if (outcome.status === "failed") { - expect(outcome.error).toBeInstanceOf(PermissionEventDeliveryError); - expect(outcome.error).toMatchObject({ - phase: "requested", - requestId: permissionInput.requestId, - }); - expect((outcome.error as Error).cause).toBe(deliveryFailure); + for (let replay = 0; replay < 2; replay += 1) { + const request = broker.request(socket, permissionInput); + await Promise.resolve(); + broker.rejectAll(); + await expect(request).resolves.toBe("reject_once"); } - const retry = broker.request(socket, permissionInput); - await Promise.resolve(); - expect(broker.resolve(permissionInput.requestId, "reject_once")).toBe(true); - await expect(retry).resolves.toBe("reject_once"); + expect(attempts.map((events) => events.map(({ kind }) => kind))).toEqual([ + ["permission.requested"], + ["permission.requested"], + ["permission.resolved", "diagnostic.reported"], + ["permission.resolved", "diagnostic.reported"], + ["permission.requested"], + ["permission.resolved", "diagnostic.reported"], + ]); + expect(persisted.size).toBe(3); + expect( + [...persisted.keys()].every((sourceEventId) => sourceEventId.startsWith("permission:")), + ).toBe(true); }); test("rejects unsupported interactive permission requests instead of allowing them", async () => { @@ -669,6 +791,7 @@ describe("DriverPermissionBroker", () => { const releaseResolution = Promise.withResolvers(); const pushedEvents: DriverEventInput[] = []; const socket: DriverRuntimeEventPort = { + currentRunId: () => runId, pushEvents: async ({ events }) => { pushedEvents.push(...events); @@ -719,10 +842,56 @@ describe("DriverPermissionBroker", () => { ]); }); + test("gives a cancellation retry a fresh wait budget", async () => { + const requestedPublishing = Promise.withResolvers(); + const releaseRequested = Promise.withResolvers(); + const socket: DriverRuntimeEventPort = { + currentRunId: () => runId, + pushEvents: async ({ events }) => { + if (events.some((event) => event.kind === "permission.requested")) { + requestedPublishing.resolve(); + await releaseRequested.promise; + } + return acceptEvents(events); + }, + }; + const broker = new DriverPermissionBroker(() => null, { + eventDeliveryTimeoutMs: 10, + }); + const request = broker.request(socket, permissionInput); + const requestOutcome = request.then( + () => null, + (error: unknown) => error, + ); + + await requestedPublishing.promise; + const first = broker.rejectAllAndWait(); + await expect(first).rejects.toThrow("Driver permission cancellation timed out"); + + const retry = broker.rejectAllAndWait(); + expect(retry).not.toBe(first); + let retrySettled = false; + void retry.then( + () => { + retrySettled = true; + }, + () => { + retrySettled = true; + }, + ); + await Promise.resolve(); + expect(retrySettled).toBe(false); + + releaseRequested.resolve(); + expect(await requestOutcome).toBeInstanceOf(PermissionEventDeliveryError); + await expect(retry).resolves.toBeUndefined(); + }); + test("bounds ordinary cancellation delivery below the active-turn grace", async () => { const requestedPublishing = Promise.withResolvers(); const pushedKinds: string[] = []; const socket: DriverRuntimeEventPort = { + currentRunId: () => runId, pushEvents: async ({ events, signal }) => { pushedKinds.push(...events.map((event) => event.kind)); @@ -759,6 +928,7 @@ describe("DriverPermissionBroker", () => { const acceptedKinds: string[] = []; const pushedKinds: string[] = []; const socket: DriverRuntimeEventPort = { + currentRunId: () => runId, pushEvents: async ({ events, signal }) => { pushedKinds.push(...events.map((event) => event.kind)); @@ -799,6 +969,7 @@ describe("DriverPermissionBroker", () => { const controller = new AbortController(); let deliveryAborted = false; const socket: DriverRuntimeEventPort = { + currentRunId: () => runId, pushEvents: async ({ events, signal }) => { if (events.some((event) => event.kind === "permission.resolved")) { resolutionPublishing.resolve(); @@ -886,6 +1057,7 @@ describe("DriverPermissionBroker", () => { let pushCount = 0; const broker = new DriverPermissionBroker(() => null, { requestTimeoutMs: 1 }); const socket: DriverRuntimeEventPort = { + currentRunId: () => runId, pushEvents: async ({ events }) => { pushCount += 1; @@ -913,6 +1085,7 @@ describe("DriverPermissionBroker", () => { const batchSizes: number[] = []; const broker = new DriverPermissionBroker(() => null); const socket: DriverRuntimeEventPort = { + currentRunId: () => runId, pushEvents: async ({ events }) => { batchSizes.push(events.length); return acceptEvents(events.slice(0, 1)); @@ -930,6 +1103,7 @@ describe("DriverPermissionBroker", () => { test("fails and releases a request when event delivery makes no progress", async () => { const broker = new DriverPermissionBroker(() => null); const socket: DriverRuntimeEventPort = { + currentRunId: () => runId, pushEvents: async () => ({ accepted: [] }), }; @@ -954,6 +1128,7 @@ describe("DriverPermissionBroker", () => { const broker = new DriverPermissionBroker(() => null); const pushedEvents: DriverEventInput[] = []; const socket: DriverRuntimeEventPort = { + currentRunId: () => runId, pushEvents: async ({ events }) => { pushedEvents.push(...events); diff --git a/tests/driver-permission-policy.test.ts b/tests/driver-permission-policy.test.ts index 2ce1325..0dadf18 100644 --- a/tests/driver-permission-policy.test.ts +++ b/tests/driver-permission-policy.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import type { DriverPermissionRequest } from "../src/core/driver-permission-broker"; +import type { DriverPermissionRequest } from "../src/host-ports"; import { createDriverPermissionRequestHandler, isDriverFullAccess, @@ -54,7 +54,7 @@ describe("driver permission policy", () => { const legacyPayload: Record = structuredClone(driverBootPayload); legacyPayload["protocolVersion"] = 1; - expect(() => parseDriverBootPayload(legacyPayload)).toThrow(/protocolVersion must be 2/); + expect(() => parseDriverBootPayload(legacyPayload)).toThrow(/protocolVersion must be 3/); }); test("isDriverFullAccess reflects the payload", () => { @@ -76,6 +76,28 @@ describe("driver permission policy", () => { expect(supervisedCalls).toBe(0); }); + test("full_access preserves a user-configured ask rule", async () => { + const seen: DriverPermissionRequest[] = []; + const handler = createDriverPermissionRequestHandler({ + payload: startInputWithPolicy("full_access"), + supervised: async (request) => { + seen.push(request); + return "reject_once"; + }, + }); + const request = { + ...SAMPLE_REQUEST, + matchedAskRule: { + ruleContent: "Bash(*)", + source: "project", + toolName: "Bash", + }, + }; + + await expect(handler(request)).resolves.toBe("reject_once"); + expect(seen).toEqual([request]); + }); + test("supervised delegates to the interactive handler", async () => { const seen: DriverPermissionRequest[] = []; const controller = new AbortController(); diff --git a/tests/driver-rpc-wire-v3.test.ts b/tests/driver-rpc-wire-v3.test.ts new file mode 100644 index 0000000..ac55cee --- /dev/null +++ b/tests/driver-rpc-wire-v3.test.ts @@ -0,0 +1,589 @@ +import { describe, expect, test } from "bun:test"; + +import { DRIVER_PROTOCOL_VERSION } from "../src/protocol/boot"; +import { driverRuntimeRpcSchemas } from "../src/protocol/orpc"; +import { + RUNTIME_COMMAND_TERMINAL_PAYLOAD_MAX_UTF8_BYTES, + measureRuntimeCommandJson, +} from "../src/runtime-command"; +import { DRIVER_TEST_IDS } from "./driver-boot-payload-fixture"; + +const invalidPositiveSafeIntegers = [ + 0, + -1, + 0.5, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + Number.MAX_SAFE_INTEGER + 1, +] as const; +const invalidNonNegativeSafeIntegers = invalidPositiveSafeIntegers.slice(1); + +function helloInput(overrides: Record = {}) { + return { + capabilities: [], + driverVersion: "test", + pid: 1, + protocolVersion: DRIVER_PROTOCOL_VERSION, + runtime: "acp-fallback", + startedAt: "2026-08-29T00:00:00.000Z", + ...overrides, + }; +} + +function helloOutput(runConfig: Record = {}, runId: string | null = null) { + return { + acceptedCapabilities: [], + connectionId: "connection-1", + driverInstanceId: "driver-1", + heartbeatIntervalMs: 250, + runConfig: { + commandLeaseMs: 0, + envPolicy: "strict", + eventBatchMaxSize: 1, + organizationPath: "/workspace", + ...runConfig, + }, + runId, + }; +} + +function heartbeatInput(pid: number, at = "2026-08-29T00:00:00.000Z") { + return { at, pid, reason: "interval" }; +} + +function readyInput(pid: number) { + return { + at: "2026-08-29T00:00:00.000Z", + driverInstanceId: "driver-1", + pid, + }; +} + +function logBatch(seq: number) { + return { + driverInstanceId: "driver-1", + logs: [{ level: "info", message: "message", seq, timestamp: "now" }], + }; +} + +function eventBatchOutput(seq: number, type = "message.delta") { + return { accepted: [{ eventId: "source-1", seq, type }] }; +} + +function diagnosticEvent(payload: Record = { message: "ok" }) { + return { + actor: "driver", + delivery: "lossless", + driverInstanceId: DRIVER_TEST_IDS.driverInstanceId, + id: "01J0000000000000000000000G", + kind: "diagnostic.reported", + occurredAt: "2026-08-29T00:00:00.000Z", + origin: "driver", + payload, + runId: DRIVER_TEST_IDS.runId, + schemaVersion: "2026-08-29", + sessionId: DRIVER_TEST_IDS.sessionId, + visibility: "owner_debug", + }; +} + +function textFieldAtJsonSize(targetBytes: number, create: (text: string) => Value): Value { + const remaining = targetBytes - measureRuntimeCommandJson(create("")); + const value = create("x".repeat(remaining)); + + expect(measureRuntimeCommandJson(value)).toBe(targetBytes); + return value; +} + +describe("Driver RPC wire v3", () => { + const rpc = driverRuntimeRpcSchemas.driver; + + test("omits explicit undefined capability details", () => { + const parsed = rpc.hello.input.parse( + helloInput({ + capabilities: [{ details: undefined, id: "input_start", status: "supported", version: 1 }], + }), + ); + + expect(Object.hasOwn(parsed.capabilities[0]!, "details")).toBeFalse(); + }); + + test.each([2, 4] as const)("rejects protocol version %d", (protocolVersion) => { + expect(rpc.hello.input.safeParse(helloInput({ protocolVersion })).success).toBeFalse(); + }); + + test.each(invalidPositiveSafeIntegers)( + "rejects %p at every positive safe-integer field", + (value) => { + expect(rpc.hello.input.safeParse(helloInput({ pid: value })).success).toBeFalse(); + expect(rpc.heartbeat.input.safeParse(heartbeatInput(value)).success).toBeFalse(); + expect(rpc.ready.input.safeParse(readyInput(value)).success).toBe(false); + expect( + rpc.hello.output.safeParse(helloOutput({ eventBatchMaxSize: value })).success, + ).toBeFalse(); + expect( + rpc.claimExternalToolEffect.output.safeParse({ + attempt: value, + effectId: "effect-1", + idempotencyKey: "idempotency-1", + kind: "claimed", + }).success, + ).toBeFalse(); + }, + ); + + test.each([ + [64, true], + [65, false], + ] as const)("validates wire-safe event batch limit %d", (eventBatchMaxSize, accepted) => { + expect(rpc.hello.output.safeParse(helloOutput({ eventBatchMaxSize })).success).toBe(accepted); + }); + + test.each([ + 0, + 249, + 250.5, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + Number.MAX_SAFE_INTEGER + 1, + ] as const)("rejects invalid heartbeat interval %p", (heartbeatIntervalMs) => { + expect( + rpc.hello.output.safeParse({ + ...helloOutput(), + heartbeatIntervalMs, + }).success, + ).toBeFalse(); + }); + + test.each(invalidNonNegativeSafeIntegers)( + "rejects %p at every non-negative safe-integer field", + (value) => { + expect( + rpc.hello.output.safeParse(helloOutput({ commandLeaseMs: value })).success, + ).toBeFalse(); + expect( + rpc.heartbeat.output.safeParse({ + heartbeatCount: value, + ok: true, + }).success, + ).toBeFalse(); + expect(rpc.pushLogs.input.safeParse(logBatch(value)).success).toBe(false); + expect(rpc.pushEvents.output.safeParse(eventBatchOutput(value)).success).toBeFalse(); + }, + ); + + test("accepts zero at every non-negative safe-integer field", () => { + expect(rpc.hello.output.safeParse(helloOutput()).success).toBeTrue(); + expect(rpc.heartbeat.output.safeParse({ heartbeatCount: 0, ok: true }).success).toBeTrue(); + expect(rpc.pushLogs.input.safeParse(logBatch(0)).success).toBeTrue(); + expect(rpc.pushEvents.output.safeParse(eventBatchOutput(0)).success).toBeTrue(); + }); + + test.each([ + ["event", "events", { event: diagnosticEvent(), eventId: "source-1" }], + ["log", "logs", logBatch(0).logs[0]], + ] as const)("bounds %s batches at 64 entries", (_label, field, entry) => { + const schema = field === "events" ? rpc.pushEvents.input : rpc.pushLogs.input; + const input = (length: number) => ({ + driverInstanceId: "driver-1", + [field]: Array.from({ length }, () => entry), + }); + + expect(schema.safeParse(input(64)).success).toBeTrue(); + expect(schema.safeParse(input(65)).success).toBeFalse(); + }); + + test.each([ + [ + "failure message", + rpc.failRun.input, + { + driverInstanceId: "driver-1", + error: { code: "failed", details: {}, message: "", retryable: false }, + runId: "run-1", + }, + ], + ["receipt type", rpc.pushEvents.output, eventBatchOutput(0, "")], + ] as const)("rejects an empty %s", (_label, schema, input) => { + expect(schema.safeParse(input).success).toBeFalse(); + }); + + test("rejects an unknown receipt event type", () => { + expect( + rpc.pushEvents.output.safeParse(eventBatchOutput(0, "future.event")).success, + ).toBeFalse(); + }); + + test.each([ + [rpc.completeRun.input, { driverInstanceId: "driver-1" }], + [rpc.completeRun.input, { driverInstanceId: "driver-1", runId: "" }], + [ + rpc.failRun.input, + { + driverInstanceId: "driver-1", + error: { code: "failed", details: {}, message: "failed", retryable: false }, + }, + ], + [ + rpc.failRun.input, + { + driverInstanceId: "driver-1", + error: { code: "failed", details: {}, message: "failed", retryable: false }, + runId: "", + }, + ], + ] as const)("requires an exact run id for every control terminal", (schema, input) => { + expect(schema.safeParse(input).success).toBeFalse(); + }); + + test.each([ + ["missing", { accepted: [{ seq: 0, type: "message.delta" }] }], + ["empty", { accepted: [{ eventId: "", seq: 0, type: "message.delta" }] }], + ["non-string", { accepted: [{ eventId: 1, seq: 0, type: "message.delta" }] }], + ] as const)("rejects %s receipt eventId", (_label, input) => { + expect(rpc.pushEvents.output.safeParse(input).success).toBeFalse(); + }); + + test("rejects empty handshake identity fields", () => { + expect(rpc.hello.input.safeParse(helloInput({ startedAt: "" })).success).toBeFalse(); + expect(rpc.heartbeat.input.safeParse(heartbeatInput(1, "")).success).toBeFalse(); + expect(rpc.hello.output.safeParse(helloOutput({}, "")).success).toBe(false); + }); + + test("bounds run and mutually exclusive command terminal payloads", () => { + const error = textFieldAtJsonSize( + RUNTIME_COMMAND_TERMINAL_PAYLOAD_MAX_UTF8_BYTES, + (message) => ({ code: "failed", details: {}, message, retryable: false }), + ); + const result = textFieldAtJsonSize( + RUNTIME_COMMAND_TERMINAL_PAYLOAD_MAX_UTF8_BYTES, + (outputText) => ({ + outputText, + requestId: "request-1", + serverId: "server-1", + toolName: "tool-1", + }), + ); + const commandUpdate = rpc.commandUpdate.input; + const failRun = rpc.failRun.input; + + expect(failRun.safeParse({ driverInstanceId: "driver-1", error, runId: "run-1" }).success).toBe( + true, + ); + expect( + failRun.safeParse({ + driverInstanceId: "driver-1", + error: { ...error, message: `${error.message}x` }, + runId: "run-1", + }).success, + ).toBeFalse(); + + expect( + commandUpdate.safeParse({ + commandId: "command-1", + driverInstanceId: "driver-1", + error, + status: "failed", + }).success, + ).toBeTrue(); + expect( + commandUpdate.safeParse({ + commandId: "command-1", + driverInstanceId: "driver-1", + error: { ...error, message: `${error.message}x` }, + status: "failed", + }).success, + ).toBeFalse(); + expect( + commandUpdate.safeParse({ + commandId: "command-1", + driverInstanceId: "driver-1", + result, + status: "completed", + }).success, + ).toBeTrue(); + expect( + commandUpdate.safeParse({ + commandId: "command-1", + driverInstanceId: "driver-1", + result: { ...result, outputText: `${result.outputText}x` }, + status: "completed", + }).success, + ).toBeFalse(); + expect( + commandUpdate.safeParse({ + commandId: "command-1", + driverInstanceId: "driver-1", + error, + result: null, + status: "failed", + }).success, + ).toBeFalse(); + }); + + test.each(["queued", "delivered", "expired"] as const)( + "rejects database-only command status %s on Driver updates", + (status) => { + expect( + rpc.commandUpdate.input.safeParse({ + commandId: "command-1", + driverInstanceId: "driver-1", + status, + }).success, + ).toBeFalse(); + }, + ); + + test("enforces command update payloads by terminal status", () => { + const schema = rpc.commandUpdate.input; + const identity = { commandId: "command-1", driverInstanceId: "driver-1" }; + const error = { code: "failed", details: {}, message: "failed", retryable: false }; + const result = { requestId: "request-1" }; + + for (const valid of [ + { ...identity, status: "accepted" }, + { ...identity, status: "cancelled" }, + { ...identity, status: "completed" }, + { ...identity, result, status: "completed" }, + { ...identity, error, status: "failed" }, + ]) { + expect(schema.safeParse(valid).success).toBeTrue(); + } + + for (const invalid of [ + { ...identity, error, status: "accepted" }, + { ...identity, result, status: "accepted" }, + { ...identity, error, status: "cancelled" }, + { ...identity, result, status: "cancelled" }, + { ...identity, error, status: "completed" }, + { ...identity, result: null, status: "completed" }, + { ...identity, status: "failed" }, + { ...identity, error, result, status: "failed" }, + ]) { + expect(schema.safeParse(invalid).success).toBeFalse(); + } + }); + + test("rejects duplicate capabilities on both sides of the handshake", () => { + const capability = { id: "text_stream", status: "supported", version: 1 }; + + expect( + rpc.hello.input.safeParse(helloInput({ capabilities: [capability, capability] })).success, + ).toBeFalse(); + expect( + rpc.hello.output.safeParse({ + ...helloOutput(), + acceptedCapabilities: [capability, capability], + }).success, + ).toBeFalse(); + }); + + test("rejects unknown keys on every fixed RPC input and output object", () => { + const failure = { code: "failed", details: {}, message: "failed", retryable: false }; + const claim = { + attempt: 1, + effectId: "effect-1", + idempotencyKey: "idempotency-1", + kind: "claimed", + }; + const cases = [ + [ + rpc.observeExternalToolEffect.input, + { commandId: "command-1", driverInstanceId: "driver-1" }, + ], + [rpc.observeExternalToolEffect.output, { effectId: "effect-1", kind: "intent" }], + [ + rpc.claimExternalToolEffect.input, + { + claimToken: "00000000-0000-4000-8000-000000000001", + commandId: "command-1", + driverInstanceId: "driver-1", + }, + ], + [rpc.claimExternalToolEffect.output, claim], + [ + rpc.commandUpdate.input, + { commandId: "command-1", driverInstanceId: "driver-1", status: "accepted" }, + ], + [rpc.commandUpdate.output, { ok: true }], + [rpc.completeRun.input, { driverInstanceId: "driver-1", runId: "run-1" }], + [rpc.completeRun.output, { ok: true }], + [rpc.failRun.input, { driverInstanceId: "driver-1", error: failure, runId: "run-1" }], + [rpc.failRun.output, { ok: true }], + [rpc.heartbeat.input, heartbeatInput(1)], + [rpc.heartbeat.output, { heartbeatCount: 0, ok: true }], + [rpc.hello.input, helloInput()], + [rpc.hello.output, helloOutput()], + [ + rpc.settleExternalToolEffect.input, + { + claimToken: "00000000-0000-4000-8000-000000000001", + commandId: "command-1", + driverInstanceId: "driver-1", + effectId: "effect-1", + settlement: { kind: "unknown" }, + }, + ], + [rpc.settleExternalToolEffect.output, { effectId: "effect-1", kind: "unknown" }], + [ + rpc.pushEvents.input, + { + driverInstanceId: "driver-1", + events: [{ event: diagnosticEvent(), eventId: "source-1" }], + }, + ], + [rpc.pushEvents.output, eventBatchOutput(0)], + [rpc.pushLogs.input, logBatch(0)], + [rpc.pushLogs.output, { ok: true }], + [rpc.ready.input, readyInput(1)], + [rpc.ready.output, { ok: true }], + [driverRuntimeRpcSchemas.driverInstance.nextCommand.input, { driverInstanceId: "driver-1" }], + [driverRuntimeRpcSchemas.driverInstance.nextCommand.output, { command: null }], + ] as const; + + for (const [schema, value] of cases) { + expect(schema.safeParse({ ...value, future: true }).success).toBeFalse(); + } + }); + + test("rejects unknown keys on nested fixed RPC objects but preserves event payload extensions", () => { + const event = diagnosticEvent({ future: { nested: true }, message: "ok" }); + const nestedCases = [ + [ + rpc.hello.input, + helloInput({ + capabilities: [{ future: true, id: "text_stream", status: "supported", version: 1 }], + }), + ], + [rpc.hello.output, helloOutput({ future: true })], + [ + rpc.failRun.input, + { + driverInstanceId: "driver-1", + error: { code: "failed", details: {}, future: true, message: "failed", retryable: false }, + runId: "run-1", + }, + ], + [ + rpc.commandUpdate.input, + { + commandId: "command-1", + driverInstanceId: "driver-1", + result: { future: true, requestId: "request-1" }, + status: "completed", + }, + ], + [ + rpc.settleExternalToolEffect.input, + { + claimToken: "00000000-0000-4000-8000-000000000001", + commandId: "command-1", + driverInstanceId: "driver-1", + effectId: "effect-1", + settlement: { future: true, kind: "unknown" }, + }, + ], + [ + rpc.pushLogs.input, + { driverInstanceId: "driver-1", logs: [{ ...logBatch(0).logs[0], future: true }] }, + ], + [ + rpc.pushLogs.input, + { + driverInstanceId: "driver-1", + logs: [{ ...logBatch(0).logs[0], context: { future: true } }], + }, + ], + [ + rpc.pushLogs.input, + { + driverInstanceId: "driver-1", + logs: [ + { ...logBatch(0).logs[0], error: { future: true, message: "failed", name: "Error" } }, + ], + }, + ], + [ + rpc.pushEvents.input, + { driverInstanceId: "driver-1", events: [{ event, eventId: "source-1", future: true }] }, + ], + [ + rpc.pushEvents.input, + { + driverInstanceId: "driver-1", + events: [{ event: { ...event, future: true }, eventId: "source-1" }], + }, + ], + [ + rpc.pushEvents.input, + { + driverInstanceId: "driver-1", + events: [ + { + event: { ...event, native: { future: true, provider: "openai" } }, + eventId: "source-1", + }, + ], + }, + ], + [rpc.pushEvents.output, { accepted: [{ ...eventBatchOutput(0).accepted[0], future: true }] }], + ] as const; + + for (const [schema, value] of nestedCases) { + expect(schema.safeParse(value).success).toBeFalse(); + } + + expect( + rpc.pushEvents.input.safeParse({ + driverInstanceId: "driver-1", + events: [{ event, eventId: "source-1" }], + }).success, + ).toBeTrue(); + }); + + test("preserves empty log and tracing strings", () => { + expect( + rpc.pushLogs.input.safeParse({ + driverInstanceId: "driver-1", + logs: [ + { + context: { spanId: "", traceId: "" }, + error: { message: "", name: "" }, + level: "error", + message: "", + seq: 0, + timestamp: "now", + }, + ], + }).success, + ).toBeTrue(); + }); + + test("rejects the previous runtime event schema", () => { + expect( + rpc.pushEvents.input.safeParse({ + driverInstanceId: DRIVER_TEST_IDS.driverInstanceId, + events: [ + { + event: { + actor: "driver", + delivery: "lossless", + driverInstanceId: DRIVER_TEST_IDS.driverInstanceId, + id: "01J0000000000000000000000G", + kind: "diagnostic.reported", + occurredAt: "2026-08-29T00:00:00.000Z", + origin: "driver", + payload: { message: "ok" }, + schemaVersion: "2026-05-26", + sessionId: DRIVER_TEST_IDS.sessionId, + visibility: "owner_debug", + }, + eventId: "source-1", + }, + ], + }).success, + ).toBeFalse(); + }); +}); diff --git a/tests/driver-runtime-boundary-dispatcher.test.ts b/tests/driver-runtime-boundary-dispatcher.test.ts index 2642f6f..b371fde 100644 --- a/tests/driver-runtime-boundary-dispatcher.test.ts +++ b/tests/driver-runtime-boundary-dispatcher.test.ts @@ -7,6 +7,7 @@ import { import { DriverRuntimeStateMachine, DriverTurnCancellationCleanupError, + DriverTurnCancelledError, } from "../src/core/driver-runtime-state"; import type { RuntimeCommand } from "../src/runtime-command"; import { settlePromiseWithTimeout } from "../src/utils/async"; @@ -15,9 +16,81 @@ import { FakeDriverRuntimeIo, createBackend, createDispatcher, + waitForUpdate, } from "./driver-runtime-boundary-fixtures"; describe("driver runtime boundary", () => { + test("parses a custom command source before acknowledgement or business side effects", async () => { + const backend = createBackend(); + const invalid = { + argumentsJson: "{}", + commandId: "invalid-custom-source", + extra: true, + kind: "mcp.execute", + requestId: "invalid-custom-source-request", + runId: DRIVER_TEST_IDS.runId, + serverId: "mcp-linear", + toolCallId: "invalid-custom-source-tool", + toolName: "createIssue", + } as unknown as RuntimeCommand; + const socket = new FakeDriverRuntimeIo([invalid], DRIVER_TEST_IDS.runId); + let preparations = 0; + const { dispatcher, logger } = createDispatcher({ + backend, + mcpPrepare: async () => { + preparations += 1; + throw new Error("invalid command reached MCP preparation"); + }, + runtimeState: new DriverRuntimeStateMachine("ready"), + }); + + await expect(dispatcher.run(socket, logger)).rejects.toThrow( + "runtime command.extra is not allowed", + ); + + expect(socket.updates).toEqual([]); + expect(preparations).toBe(0); + }); + + test("projects durable attachment provenance to provider-neutral text", async () => { + const backend = createBackend(); + const providerInputs: unknown[] = []; + backend.handleInput = async (context, input, runId) => { + providerInputs.push(structuredClone(input)); + await context.ports.eventSink.pushEvents({ + events: [ + { + kind: "run.completed", + payload: { status: "completed" }, + runId, + sourceEventId: `attachment-projection.completed:${runId}`, + }, + ], + }); + }; + const command = { + commandId: "attachment-projection", + input: { attachmentIds: ["file-1"], text: "materialized attachment" }, + kind: "input.start", + requestId: "attachment-projection-request", + runId: DRIVER_TEST_IDS.runId, + } as unknown as RuntimeCommand; + const socket = new FakeDriverRuntimeIo([command]); + const { dispatcher, logger } = createDispatcher({ + backend, + isShuttingDown: () => socket.isDrained(), + runtimeState: new DriverRuntimeStateMachine("ready"), + }); + + await dispatcher.run(socket, logger); + await waitForUpdate( + socket, + (update) => update.commandId === "attachment-projection" && update.status === "completed", + ); + + expect(providerInputs).toEqual([{ text: "materialized attachment" }]); + }); + test("delivers a session.stop terminal only after the shutdown barrier", async () => { const cleanupEntered = Promise.withResolvers(); const releaseCleanup = Promise.withResolvers(); @@ -66,7 +139,6 @@ describe("driver runtime boundary", () => { expect(socket.completedRunReasons).toEqual([]); releaseCleanup.resolve(); await run; - await logger.destroy(); expect(terminalAttempts).toBe(3); expect(order).toEqual(["cleanup", "control.completeRun"]); @@ -127,7 +199,7 @@ describe("driver runtime boundary", () => { return result; }; let permission: Promise<"allow_once" | "reject_once"> | null = null; - backend.handleInput = async () => { + backend.handleInput = async (context, _input, runId) => { permission = permissions.request(socket, { rawInput: null, requestId: "stop-permission", @@ -136,6 +208,17 @@ describe("driver runtime boundary", () => { toolKind: "test", }); await permission; + await context.ports.eventSink.pushEvents({ + events: [ + { + kind: "run.cancelled", + payload: { reason: "test.stop", status: "cancelled" }, + runId, + sourceEventId: `stop-permission.cancelled:${runId}`, + }, + ], + }); + throw new DriverTurnCancelledError("test.stop"); }; const { dispatcher, logger } = createDispatcher({ backend, @@ -152,11 +235,12 @@ describe("driver runtime boundary", () => { await Bun.sleep(5_100); releaseRequested.resolve(); await expect(Promise.all([permission!, run])).resolves.toEqual(["reject_once", undefined]); - await logger.destroy(); expect( - order.filter((kind) => - ["permission.resolved", "run.cancelled", "cleanup", "control.completeRun"].includes(kind), - ), + order + .filter((kind) => + ["permission.resolved", "run.cancelled", "cleanup", "control.completeRun"].includes(kind), + ) + .filter((kind, index, values) => index === 0 || kind !== values[index - 1]), ).toEqual(["permission.resolved", "run.cancelled", "cleanup", "control.completeRun"]); expect(socket.updates.at(-1)).toMatchObject({ commandId: "stop-with-permission", @@ -195,6 +279,7 @@ describe("driver runtime boundary", () => { commandId: "cancel-with-permission-failure", kind: "turn.cancel", reason: "test cancellation", + runId: DRIVER_TEST_IDS.runId, }, ]); const { dispatcher, logger } = createDispatcher({ @@ -204,7 +289,6 @@ describe("driver runtime boundary", () => { }); await expect(dispatcher.run(socket, logger)).resolves.toBeUndefined(); - await logger.destroy(); expect(socket.failedRuns).toHaveLength(1); expect(socket.completedRunReasons).toEqual([]); @@ -263,6 +347,7 @@ describe("driver runtime boundary", () => { commandId: "cancel-with-cleanup-failure", kind: "turn.cancel", reason: "test cancellation", + runId: DRIVER_TEST_IDS.runId, }, ]); const { dispatcher, logger } = createDispatcher({ @@ -272,7 +357,6 @@ describe("driver runtime boundary", () => { }); await expect(dispatcher.run(socket, logger)).resolves.toBeUndefined(); - await logger.destroy(); expect(socket.failedRuns).toHaveLength(1); expect( @@ -295,10 +379,22 @@ describe("driver runtime boundary", () => { let cancellationRequested = false; let sideEffects = 0; const backend = createBackend(); - backend.handleInput = async (_context, _input, _runId, signal) => { + backend.handleInput = async (context, _input, runId, signal) => { providerAdmission.resolve(); await releaseProviderAdmission.promise; - signal?.throwIfAborted(); + if (signal?.aborted) { + await context.ports.eventSink.pushEvents({ + events: [ + { + kind: "run.cancelled", + payload: { reason: "test cancellation", status: "cancelled" }, + runId, + sourceEventId: `eager-cancel.cancelled:${runId}`, + }, + ], + }); + signal.throwIfAborted(); + } sideEffects += 1; }; backend.cancelActiveTurn = async () => { @@ -318,6 +414,7 @@ describe("driver runtime boundary", () => { commandId: "eager-cancel", kind: "turn.cancel", reason: "test cancellation", + runId: DRIVER_TEST_IDS.runId, }, ]); const cancelAccepted = Promise.withResolvers(); @@ -342,7 +439,6 @@ describe("driver runtime boundary", () => { await Bun.sleep(0); releaseCancelAccepted.resolve(); await run; - await logger.destroy(); expect(cancellationRequested).toBe(true); expect(sideEffects).toBe(0); @@ -356,6 +452,831 @@ describe("driver runtime boundary", () => { }); }); + test("rejects stale run commands before acknowledgement or side effects", async () => { + const backend = createBackend(); + const permissions = new DriverPermissionBroker(() => null); + const socket = new FakeDriverRuntimeIo( + [ + { + commandId: "stale-cancel", + kind: "turn.cancel", + reason: "must not cancel", + runId: DRIVER_TEST_IDS.runId, + }, + { + commandId: "stale-permission", + decision: "allow_once", + kind: "permission.resolve", + requestId: "active-permission", + runId: DRIVER_TEST_IDS.runId, + }, + { + argumentsJson: "{}", + commandId: "stale-mcp", + kind: "mcp.execute", + requestId: "stale-mcp-request", + runId: DRIVER_TEST_IDS.runId, + serverId: "stale-server", + toolCallId: "stale-tool-call", + toolName: "mustNotRun", + }, + { + commandId: "stale-input", + input: { text: "must not run" }, + kind: "input.start", + requestId: "stale-input-request", + runId: DRIVER_TEST_IDS.runId, + }, + ], + DRIVER_TEST_IDS.secondRunId, + ); + const permission = permissions.request(socket, { + rawInput: null, + requestId: "active-permission", + title: "Allow active run?", + toolCallId: "active-tool-call", + toolKind: "test", + }); + let mcpPreparations = 0; + const runtimeState = new DriverRuntimeStateMachine("ready"); + const { dispatcher, logger } = createDispatcher({ + backend, + isShuttingDown: () => socket.isDrained(), + mcpPrepare: async () => { + mcpPreparations += 1; + throw new Error("stale MCP command reached preparation"); + }, + permissionRequests: permissions, + runtimeState, + }); + + await dispatcher.run(socket, logger); + + expect(socket.updates).toHaveLength(4); + expect(socket.updates).toEqual( + expect.arrayContaining( + ["stale-cancel", "stale-permission", "stale-mcp", "stale-input"].map((commandId) => + expect.objectContaining({ + commandId, + status: "failed", + }), + ), + ), + ); + expect(socket.updates.some((update) => update.status === "accepted")).toBe(false); + expect(socket.currentRunId()).toBe(DRIVER_TEST_IDS.secondRunId); + expect(backend.cancelledReasons).toEqual([]); + expect(backend.handledInputs).toEqual([]); + expect(mcpPreparations).toBe(0); + expect(permissions.hasPending()).toBe(false); + await expect(permission).resolves.toBe("reject_once"); + }); + + test("rechecks permission ownership after the accepted acknowledgement", async () => { + let currentRunId = DRIVER_TEST_IDS.runId; + class RunSwitchingSocket extends FakeDriverRuntimeIo { + override currentRunId() { + return currentRunId; + } + + override async commandUpdate( + update: Parameters[0], + signal: AbortSignal, + ) { + await super.commandUpdate(update, signal); + if (update.status === "accepted") { + currentRunId = DRIVER_TEST_IDS.secondRunId; + } + } + } + const command: RuntimeCommand = { + commandId: "permission-run-switch", + decision: "allow_once", + kind: "permission.resolve", + requestId: "permission-run-switch-request", + runId: DRIVER_TEST_IDS.runId, + }; + const socket = new RunSwitchingSocket([command], DRIVER_TEST_IDS.runId); + const permissions = new DriverPermissionBroker(() => null); + const pending = permissions.request(socket, { + rawInput: null, + requestId: command.requestId, + title: "Allow test tool?", + toolCallId: "permission-run-switch-tool", + toolKind: "test", + }); + const { dispatcher, logger } = createDispatcher({ + backend: createBackend(), + isShuttingDown: () => socket.isDrained(), + permissionRequests: permissions, + runtimeState: new DriverRuntimeStateMachine("ready"), + }); + + await dispatcher.run(socket, logger); + + expect(socket.updates.map(({ status }) => status)).toEqual(["accepted", "failed"]); + expect(permissions.hasPending()).toBeFalse(); + await expect(pending).resolves.toBe("reject_once"); + }); + + test.each(["session.stop"] as const)( + "rejects provider permission requests after the terminal before %s", + async (controlKind) => { + const permissionPending = Promise.withResolvers(); + const permissionSettled = Promise.withResolvers(); + const controlAccepted = Promise.withResolvers(); + const releaseControlAccepted = Promise.withResolvers(); + const backendCancelled = Promise.withResolvers(); + let cancellationRequests = 0; + let mcpPreparations = 0; + let permissionDecision: "allow_once" | "reject_once" | null = null; + let providerSignal: AbortSignal | undefined; + const permissions = new DriverPermissionBroker(() => null); + const backend = createBackend(); + backend.handleInput = async (context, _input, _runId, signal) => { + providerSignal = signal; + await context.ports.eventSink.pushEvents({ + events: [ + { + kind: "run.completed", + payload: { stopReason: "end_turn" }, + sourceEventId: "terminal-wins-cancel-race", + }, + ], + }); + const permission = permissions.request( + socket, + { + rawInput: null, + requestId: "terminal-first-permission", + title: "Allow terminal-first test?", + toolCallId: "terminal-first-permission-tool", + toolKind: "test", + }, + signal, + ); + permissionPending.resolve(); + permissionDecision = await permission; + permissionSettled.resolve(); + }; + backend.cancelActiveTurn = async () => { + cancellationRequests += 1; + backendCancelled.resolve(); + }; + const control: RuntimeCommand = + controlKind === "turn.cancel" + ? { + commandId: "terminal-first-control", + kind: "turn.cancel", + reason: "too late", + runId: DRIVER_TEST_IDS.runId, + } + : { + commandId: "terminal-first-control", + kind: "session.stop", + reason: "stop after terminal", + }; + const runtimeState = new DriverRuntimeStateMachine("ready"); + const socket = new FakeDriverRuntimeIo([ + { + commandId: "input-terminal-first", + input: { text: "finish" }, + kind: "input.start", + requestId: "request-terminal-first", + runId: DRIVER_TEST_IDS.runId, + }, + { + argumentsJson: "{}", + commandId: "mcp-after-terminal", + kind: "mcp.execute", + requestId: "request-mcp-after-terminal", + runId: DRIVER_TEST_IDS.runId, + serverId: "mcp-linear", + toolCallId: "tool-mcp-after-terminal", + toolName: "createIssue", + }, + control, + ]); + const nextCommand = socket.nextCommand.bind(socket); + let reads = 0; + socket.nextCommand = async (signal) => { + reads += 1; + if (reads === 2) { + await permissionPending.promise; + } + return nextCommand(signal); + }; + const commandUpdate = socket.commandUpdate.bind(socket); + socket.commandUpdate = async (update, signal) => { + await commandUpdate(update, signal); + if (update.commandId === control.commandId && update.status === "accepted") { + controlAccepted.resolve(); + await releaseControlAccepted.promise; + } + }; + const { dispatcher, logger } = createDispatcher({ + backend, + isShuttingDown: () => + socket.updates.some( + (update) => update.commandId === control.commandId && update.status === "completed", + ), + mcpPrepare: async () => { + mcpPreparations += 1; + throw new Error("MCP command crossed the run terminal fence"); + }, + permissionRequests: permissions, + runtimeState, + }); + + const run = dispatcher.run(socket, logger); + await controlAccepted.promise; + const pendingAtAcceptance = permissions.hasPending(); + releaseControlAccepted.resolve(); + await permissionSettled.promise; + + expect(providerSignal?.aborted).toBe(false); + expect(pendingAtAcceptance).toBe(false); + expect(mcpPreparations).toBe(0); + expect( + socket.updates.some( + (update) => update.commandId === "mcp-after-terminal" && update.status === "accepted", + ), + ).toBe(false); + + if (controlKind === "session.stop") { + await backendCancelled.promise; + expect(cancellationRequests).toBe(1); + expect(permissions.hasPending()).toBe(false); + } else { + expect(cancellationRequests).toBe(0); + } + await run; + + expect(permissionDecision).toBe("reject_once"); + expect(socket.updates).toContainEqual({ + commandId: "input-terminal-first", + result: { requestId: "request-terminal-first" }, + status: "completed", + }); + expect(socket.updates).toContainEqual( + expect.objectContaining({ + commandId: "mcp-after-terminal", + status: "failed", + }), + ); + expect(socket.updates.at(-1)).toEqual({ + commandId: control.commandId, + status: "completed", + }); + }, + ); + + test("closes a pending custom-backend permission before publishing the run terminal", async () => { + const resolutionPersisted = Promise.withResolvers(); + const releaseResolutionAck = Promise.withResolvers(); + const permissions = new DriverPermissionBroker(() => null); + let permissionOutcome: Promise<"allow_once" | "reject_once"> | null = null; + let requestedAttempts = 0; + const socket = new FakeDriverRuntimeIo([ + { + commandId: "input-with-pending-permission", + input: { text: "finish while permission is pending" }, + kind: "input.start", + requestId: "input-with-pending-permission-request", + runId: DRIVER_TEST_IDS.runId, + }, + ]); + const pushEvents = socket.pushEvents.bind(socket); + socket.pushEvents = async (input) => { + const result = await pushEvents(input); + if (input.events.some(({ kind }) => kind === "permission.requested")) { + requestedAttempts += 1; + if (requestedAttempts === 1) { + throw new Error("permission.requested ACK lost after persistence"); + } + } + if (input.events.some(({ kind }) => kind === "permission.resolved")) { + resolutionPersisted.resolve(); + await releaseResolutionAck.promise; + } + return result; + }; + const backend = createBackend(); + backend.handleInput = async (context, _input, runId) => { + const permission = context.ports.permission.request({ + rawInput: null, + requestId: "pending-custom-permission", + title: "Allow custom backend action?", + toolCallId: "pending-custom-permission-tool", + toolKind: "test", + }); + await context.ports.eventSink.pushEvents({ + events: [ + { + kind: "run.completed", + payload: { stopReason: "end_turn" }, + runId, + sourceEventId: `permission-barrier.completed:${runId}`, + }, + ], + }); + await permission; + }; + const { dispatcher, logger } = createDispatcher({ + backend, + isShuttingDown: () => + socket.updates.some( + ({ commandId, status }) => + commandId === "input-with-pending-permission" && status === "completed", + ), + permissionRequest: (input, signal) => { + permissionOutcome = permissions.request(socket, input, signal); + return permissionOutcome; + }, + permissionRequests: permissions, + runtimeState: new DriverRuntimeStateMachine("ready"), + }); + + const run = dispatcher.run(socket, logger); + await resolutionPersisted.promise; + expect(permissions.hasPending()).toBe(true); + expect( + socket.pushedEvents.flatMap(({ events }) => events.map(({ kind }) => kind)), + ).not.toContain("run.completed"); + + releaseResolutionAck.resolve(); + await run; + + const kinds = socket.pushedEvents.flatMap(({ events }) => events.map(({ kind }) => kind)); + expect(requestedAttempts).toBe(2); + expect(permissionOutcome).not.toBeNull(); + await expect(permissionOutcome!).resolves.toBe("reject_once"); + expect(kinds.indexOf("permission.resolved")).toBeLessThan(kinds.indexOf("run.completed")); + expect(kinds.slice(kinds.indexOf("run.completed") + 1)).not.toContain("permission.resolved"); + }); + + test("blocks the terminal until an orphaned permission ACK lifecycle recovers", async () => { + const permissions = new DriverPermissionBroker(() => null); + const socket = new FakeDriverRuntimeIo([], DRIVER_TEST_IDS.runId); + const pushEvents = socket.pushEvents.bind(socket); + let failPermissionDelivery = true; + let permissionAttempts = 0; + socket.pushEvents = async (input) => { + if ( + failPermissionDelivery && + input.events.some(({ kind }) => kind === "permission.requested") + ) { + permissionAttempts += 1; + if (permissionAttempts === 1) { + await pushEvents(input); + } + throw new Error("permission ACK permanently unavailable"); + } + return pushEvents(input); + }; + + await expect( + permissions.request(socket, { + rawInput: null, + requestId: "orphaned-permission", + title: "Allow unavailable action?", + toolCallId: "orphaned-permission-tool", + toolKind: "test", + }), + ).rejects.toBeInstanceOf(PermissionEventDeliveryError); + expect(permissions.hasPending()).toBe(false); + socket.registerRunTerminalBarrier((events) => + events.some(({ kind }) => kind === "run.completed") + ? permissions.rejectRunAndWait(DRIVER_TEST_IDS.runId) + : undefined, + ); + + await expect( + socket.pushEvents({ + events: [ + { + kind: "run.completed", + payload: { stopReason: "end_turn" }, + runId: DRIVER_TEST_IDS.runId, + sourceEventId: "blocked-by-permission-failure", + }, + ], + }), + ).rejects.toBeInstanceOf(PermissionEventDeliveryError); + expect(socket.runSnapshot(DRIVER_TEST_IDS.runId)?.terminal).toBeNull(); + failPermissionDelivery = false; + await expect( + socket.pushEvents({ + events: [ + { + kind: "run.completed", + payload: { stopReason: "end_turn" }, + runId: DRIVER_TEST_IDS.runId, + sourceEventId: "blocked-by-permission-failure", + }, + ], + }), + ).resolves.toMatchObject({ accepted: [{ type: "run.completed" }] }); + expect(socket.pushedEvents.flatMap(({ events }) => events.map(({ kind }) => kind))).toEqual([ + "permission.requested", + "permission.requested", + "permission.resolved", + "diagnostic.reported", + "run.completed", + ]); + }); + + test("waits for an admitted MCP command before publishing the run terminal", async () => { + const acceptedEntered = Promise.withResolvers(); + const releaseAccepted = Promise.withResolvers(); + const executionEntered = Promise.withResolvers(); + const releaseExecution = Promise.withResolvers(); + const terminalEntered = Promise.withResolvers(); + const commands: RuntimeCommand[] = [ + { + commandId: "input-with-mcp-barrier", + input: { text: "finish after MCP" }, + kind: "input.start", + requestId: "input-with-mcp-barrier-request", + runId: DRIVER_TEST_IDS.runId, + }, + { + argumentsJson: "{}", + commandId: "mcp-before-terminal-fence", + kind: "mcp.execute", + requestId: "mcp-before-terminal-fence-request", + runId: DRIVER_TEST_IDS.runId, + serverId: "mcp-linear", + toolCallId: "tool-before-terminal-fence", + toolName: "createIssue", + }, + { + argumentsJson: "{}", + commandId: "mcp-after-terminal-fence", + kind: "mcp.execute", + requestId: "mcp-after-terminal-fence-request", + runId: DRIVER_TEST_IDS.runId, + serverId: "mcp-linear", + toolCallId: "tool-after-terminal-fence", + toolName: "updateIssue", + }, + ]; + const socket = new FakeDriverRuntimeIo(commands); + const order: string[] = []; + const pushedEvents = socket.pushEvents.bind(socket); + socket.pushEvents = async (input) => { + const result = await pushedEvents(input); + order.push(...input.events.map((event) => `event:${event.kind}`)); + return result; + }; + const commandUpdate = socket.commandUpdate.bind(socket); + socket.commandUpdate = async (update, signal) => { + await commandUpdate(update, signal); + order.push(`command:${update.commandId}:${update.status}`); + if (update.commandId === "mcp-before-terminal-fence" && update.status === "accepted") { + acceptedEntered.resolve(); + await releaseAccepted.promise; + } + }; + const backend = createBackend(); + backend.handleInput = async (_context, _input, runId) => { + await acceptedEntered.promise; + terminalEntered.resolve(); + await socket.pushEvents({ + events: [ + { + kind: "run.completed", + payload: { status: "completed" }, + runId, + sourceEventId: `mcp-barrier.completed:${runId}`, + }, + ], + }); + }; + const preparedCommands: string[] = []; + const { dispatcher, logger } = createDispatcher({ + backend, + isShuttingDown: () => + socket.updates.some( + (update) => + update.commandId === "input-with-mcp-barrier" && update.status === "completed", + ), + mcpPrepare: async (command) => { + preparedCommands.push(command.commandId); + return { + execute: async () => { + executionEntered.resolve(); + await releaseExecution.promise; + return { + outputText: "created", + requestId: command.requestId, + serverId: command.serverId, + toolName: command.toolName, + }; + }, + async [Symbol.asyncDispose]() {}, + }; + }, + runtimeState: new DriverRuntimeStateMachine("ready"), + }); + + const run = dispatcher.run(socket, logger); + await terminalEntered.promise; + await Bun.sleep(0); + expect(order).not.toContain("event:run.completed"); + expect(preparedCommands).toEqual([]); + + releaseAccepted.resolve(); + await executionEntered.promise; + await waitForUpdate( + socket, + (update) => update.commandId === "mcp-after-terminal-fence" && update.status === "failed", + ); + expect( + socket.updates.some( + (update) => update.commandId === "mcp-after-terminal-fence" && update.status === "accepted", + ), + ).toBe(false); + expect(preparedCommands).toEqual(["mcp-before-terminal-fence"]); + expect(order).not.toContain("event:run.completed"); + + releaseExecution.resolve(); + await expect( + settlePromiseWithTimeout(run, { + label: "same-run MCP terminal barrier", + timeoutMs: 1_500, + }), + ).resolves.toMatchObject({ status: "completed" }); + + const mcpCompleted = order.indexOf("command:mcp-before-terminal-fence:completed"); + const runCompleted = order.indexOf("event:run.completed"); + expect(mcpCompleted).toBeGreaterThan(-1); + expect(runCompleted).toBeGreaterThan(mcpCompleted); + }); + + test("publishes run.failed without self-waiting after an MCP task fails", async () => { + const executionEntered = Promise.withResolvers(); + const releaseExecution = Promise.withResolvers(); + const terminalEntered = Promise.withResolvers(); + const shutdown = new AbortController(); + const socket = new FakeDriverRuntimeIo([ + { + commandId: "input-with-failed-mcp", + input: { text: "fail after MCP" }, + kind: "input.start", + requestId: "input-with-failed-mcp-request", + runId: DRIVER_TEST_IDS.runId, + }, + { + argumentsJson: "{}", + commandId: "mcp-terminal-delivery-failure", + kind: "mcp.execute", + requestId: "mcp-terminal-delivery-failure-request", + runId: DRIVER_TEST_IDS.runId, + serverId: "mcp-linear", + toolCallId: "tool-terminal-delivery-failure", + toolName: "createIssue", + }, + ]); + const pushEvents = socket.pushEvents.bind(socket); + socket.pushEvents = async (input) => { + if ( + input.events.some( + (event) => + event.kind === "tool.call.updated" && + event.sourceEventId === "mcp.execute.completed:mcp-terminal-delivery-failure", + ) + ) { + throw new Error("MCP terminal event unavailable"); + } + return pushEvents(input); + }; + const backend = createBackend(); + backend.handleInput = async (context, _input, runId) => { + await executionEntered.promise; + terminalEntered.resolve(); + await context.ports.eventSink.pushEvents({ + events: [ + { + kind: "run.completed", + payload: { status: "completed" }, + runId, + sourceEventId: `failed-mcp.completed:${runId}`, + }, + ], + }); + }; + let rememberedFailure: Parameters[0] | null = null; + let shutdownTask: Promise | null = null; + const { dispatcher, logger } = createDispatcher({ + backend, + isShuttingDown: () => shutdown.signal.aborted, + mcpPrepare: async (command) => ({ + execute: async () => { + executionEntered.resolve(); + await releaseExecution.promise; + return { + outputText: "created", + requestId: command.requestId, + serverId: command.serverId, + toolName: command.toolName, + }; + }, + async [Symbol.asyncDispose]() {}, + }), + rememberRunFailure: (error) => { + rememberedFailure ??= structuredClone(error); + }, + runtimeState: new DriverRuntimeStateMachine("ready"), + shutdown: async () => { + shutdownTask ??= (async () => { + const error = rememberedFailure ?? { + code: "driver.mcp_task_failed", + details: {}, + message: "Driver MCP task failed.", + retryable: false, + }; + await socket.pushEvents({ + events: [ + { + kind: "run.failed", + payload: { error, recoverable: error.retryable, status: "failed" }, + runId: DRIVER_TEST_IDS.runId, + sourceEventId: `failed-mcp.failed:${DRIVER_TEST_IDS.runId}`, + }, + ], + }); + shutdown.abort(new Error("driver.mcp_task_failed")); + })(); + await shutdownTask; + }, + shutdownSignal: shutdown.signal, + }); + + const run = dispatcher.run(socket, logger); + await terminalEntered.promise; + releaseExecution.resolve(); + const outcome = await settlePromiseWithTimeout(run, { + label: "failed MCP run terminal", + timeoutMs: 1_500, + }); + + expect(outcome.status).toBe("completed"); + expect( + socket.pushedEvents + .flatMap(({ events }) => events) + .filter((event) => event.kind === "run.failed"), + ).toHaveLength(1); + expect( + socket.pushedEvents + .flatMap(({ events }) => events) + .some((event) => event.kind === "run.completed"), + ).toBe(false); + expect(socket.updates).toContainEqual({ + commandId: "mcp-terminal-delivery-failure", + status: "accepted", + }); + expect( + socket.updates.some( + (update) => + update.commandId === "mcp-terminal-delivery-failure" && update.status !== "accepted", + ), + ).toBe(false); + }); + + test.each([ + { + events: [ + { + kind: "run.completed", + payload: { status: "completed" }, + runId: DRIVER_TEST_IDS.runId, + sourceEventId: "multiple-terminal.completed", + }, + { + kind: "run.failed", + payload: { + error: { code: "test", details: {}, message: "failed", retryable: false }, + recoverable: false, + status: "failed", + }, + runId: DRIVER_TEST_IDS.runId, + sourceEventId: "multiple-terminal.failed", + }, + ], + message: "cannot contain multiple run terminals", + name: "multiple terminals", + }, + { + events: [ + { + kind: "run.completed", + payload: { status: "completed" }, + runId: DRIVER_TEST_IDS.runId, + sourceEventId: "non-final-terminal.completed", + }, + { + kind: "diagnostic.reported", + payload: { message: "must not cross terminal" }, + runId: DRIVER_TEST_IDS.runId, + sourceEventId: "non-final-terminal.diagnostic", + }, + ], + message: "must be the only event", + name: "an event after the terminal", + }, + ] as const)("rejects $name before event delivery", async ({ events, message }) => { + const socket = new FakeDriverRuntimeIo([ + { + commandId: `input-${events[0].sourceEventId}`, + input: { text: "validate terminal batch" }, + kind: "input.start", + requestId: `request-${events[0].sourceEventId}`, + runId: DRIVER_TEST_IDS.runId, + }, + ]); + let rejection: unknown; + const backend = createBackend(); + backend.handleInput = async (_context, _input, runId) => { + try { + await socket.pushEvents({ events: structuredClone(events) }); + } catch (error) { + rejection = error; + } + await socket.pushEvents({ + events: [ + { + kind: "run.completed", + payload: { status: "completed" }, + runId, + sourceEventId: `valid-terminal:${runId}`, + }, + ], + }); + }; + const { dispatcher, logger } = createDispatcher({ + backend, + isShuttingDown: () => socket.isDrained(), + runtimeState: new DriverRuntimeStateMachine("ready"), + }); + + await dispatcher.run(socket, logger); + + expect(rejection).toBeInstanceOf(Error); + expect((rejection as Error).message).toContain(message); + expect(socket.pushedEvents).toHaveLength(1); + expect(socket.pushedEvents[0]?.events[0]?.sourceEventId).toBe( + `valid-terminal:${DRIVER_TEST_IDS.runId}`, + ); + }); + + test("keeps an ordinary late provider rejection failed after cancellation wins", async () => { + const inputEntered = Promise.withResolvers(); + const rejectInput = Promise.withResolvers(); + const backend = createBackend(); + backend.handleInput = async () => { + inputEntered.resolve(); + await rejectInput.promise; + throw new Error("ordinary late rejection"); + }; + backend.cancelActiveTurn = async () => { + await inputEntered.promise; + rejectInput.resolve(); + }; + const socket = new FakeDriverRuntimeIo([ + { + commandId: "input-late-reject", + input: { text: "wait" }, + kind: "input.start", + requestId: "request-late-reject", + runId: DRIVER_TEST_IDS.runId, + }, + { + commandId: "cancel-before-reject", + kind: "turn.cancel", + reason: "cancel", + runId: DRIVER_TEST_IDS.runId, + }, + ]); + const runtimeState = new DriverRuntimeStateMachine("ready"); + const { dispatcher, logger } = createDispatcher({ + backend, + isShuttingDown: () => socket.isDrained(), + runtimeState, + }); + + await dispatcher.run(socket, logger); + + expect(socket.updates).toContainEqual( + expect.objectContaining({ commandId: "input-late-reject", status: "failed" }), + ); + expect(socket.updates).not.toContainEqual({ + commandId: "input-late-reject", + status: "cancelled", + }); + expect(runtimeState.status()).toBe("failed"); + }); + test("does not report an input failure before a rejected shutdown barrier", async () => { const backend = createBackend(); backend.failInput = true; @@ -379,7 +1300,6 @@ describe("driver runtime boundary", () => { }); await expect(dispatcher.run(socket, logger)).resolves.toBeUndefined(); - await logger.destroy(); expect(socket.failedRuns).toEqual([]); expect(runtimeState.status()).toBe("failed"); @@ -412,7 +1332,6 @@ describe("driver runtime boundary", () => { }); await expect(dispatcher.run(socket, logger)).resolves.toBeUndefined(); - await logger.destroy(); expect(cleanupAttempts).toBe(2); expect(socket.failedRuns).toHaveLength(1); @@ -475,7 +1394,6 @@ describe("driver runtime boundary", () => { }); await dispatcher.run(socket, logger); - await logger.destroy(); expect(attempts).toBe(2); expect(socket.completedRunReasons).toHaveLength(runStatus === "completed" ? 1 : 0); @@ -527,7 +1445,6 @@ describe("driver runtime boundary", () => { label: `session stop run terminal that ${failureMode}`, timeoutMs: 1_500, }); - await logger.destroy(); expect(outcome.status).toBe("completed"); expect(attempts).toBe(3); @@ -561,6 +1478,7 @@ describe("driver runtime boundary", () => { commandId: "mcp-terminal-failure", kind: "mcp.execute", requestId: "request-terminal-failure", + runId: DRIVER_TEST_IDS.runId, serverId: "mcp-linear", toolCallId: "tool-terminal-failure", toolName: "createIssue", @@ -598,7 +1516,6 @@ describe("driver runtime boundary", () => { await expect(dispatcher.run(socket, logger)).rejects.toThrow( "terminal status could not be delivered", ); - await logger.destroy(); expect(terminalAttempts).toBe(3); expect(runtimeState.status()).toBe("failed"); @@ -646,7 +1563,6 @@ describe("driver runtime boundary", () => { await expect(dispatcher.run(socket, logger)).rejects.toThrow( "terminal status could not be delivered", ); - await logger.destroy(); expect(terminalAttempts).toBe(1); expect(socket.completedRunReasons).toHaveLength(1); @@ -655,12 +1571,22 @@ describe("driver runtime boundary", () => { }); test.each(["input", "mcp"] as const)( - "accepts a slow %s terminal response before settling later work", + "settles a slow %s terminal response without losing the later command", async (kind) => { const backend = createBackend(); let sideEffects = 0; - backend.handleInput = async () => { + backend.handleInput = async (context, _input, runId) => { sideEffects += 1; + await context.ports.eventSink.pushEvents({ + events: [ + { + kind: "run.completed", + payload: { status: "completed" }, + runId, + sourceEventId: `slow-terminal.completed:${runId}`, + }, + ], + }); }; const first: RuntimeCommand = kind === "input" @@ -676,6 +1602,7 @@ describe("driver runtime boundary", () => { commandId: "ack-blocked-mcp", kind: "mcp.execute", requestId: "ack-blocked-request", + runId: DRIVER_TEST_IDS.runId, serverId: "mcp-linear", toolCallId: "tool-ack-blocked", toolName: "createIssue", @@ -694,7 +1621,10 @@ describe("driver runtime boundary", () => { kind: "session.stop", reason: "test.stop", }; - const socket = new FakeDriverRuntimeIo([first, next]); + const socket = new FakeDriverRuntimeIo( + [first, next], + kind === "mcp" ? DRIVER_TEST_IDS.runId : undefined, + ); const recordUpdate = socket.commandUpdate.bind(socket); let terminalAttempts = 0; socket.commandUpdate = async (update, signal) => { @@ -718,12 +1648,7 @@ describe("driver runtime boundary", () => { const runtimeState = new DriverRuntimeStateMachine("ready"); const { dispatcher, logger } = createDispatcher({ backend, - isShuttingDown: () => - kind === "input" - ? socket.updates.some( - (update) => update.commandId === next.commandId && update.status === "completed", - ) - : socket.isDrained(), + isShuttingDown: () => socket.isDrained(), mcpExecute: async (command) => { sideEffects += 1; return { @@ -741,11 +1666,16 @@ describe("driver runtime boundary", () => { label: `${kind} slow terminal acknowledgement`, timeoutMs: 1_500, }); - await logger.destroy(); expect(outcome.status).toBe("completed"); expect(terminalAttempts).toBe(1); - expect(sideEffects).toBe(kind === "input" ? 2 : 1); + expect(sideEffects).toBe(1); + expect(socket.updates).toContainEqual( + expect.objectContaining({ + commandId: next.commandId, + status: kind === "input" ? "failed" : "completed", + }), + ); }, ); @@ -755,9 +1685,10 @@ describe("driver runtime boundary", () => { commandId: `cancel-${index}`, kind: "turn.cancel", reason: `reason-${index}`, + runId: DRIVER_TEST_IDS.runId, })); commands.push(structuredClone(commands[0]!)); - const socket = new FakeDriverRuntimeIo(commands); + const socket = new FakeDriverRuntimeIo(commands, DRIVER_TEST_IDS.runId); const runtimeState = new DriverRuntimeStateMachine("ready"); const { dispatcher, logger } = createDispatcher({ backend, @@ -766,7 +1697,6 @@ describe("driver runtime boundary", () => { }); await expect(dispatcher.run(socket, logger)).rejects.toThrow("history capacity"); - await logger.destroy(); expect(backend.cancelledReasons).toHaveLength(1_024); expect(backend.cancelledReasons[0]).toBe("reason-0"); @@ -790,7 +1720,6 @@ describe("driver runtime boundary", () => { }); await dispatcher.run(socket, logger); - await logger.destroy(); expect(runtimeState.status()).toBe("stopped"); expect(socket.completedRunReasons).toEqual(["completed"]); diff --git a/tests/driver-runtime-boundary-event-envelope.test.ts b/tests/driver-runtime-boundary-event-envelope.test.ts index c50bb33..df158a4 100644 --- a/tests/driver-runtime-boundary-event-envelope.test.ts +++ b/tests/driver-runtime-boundary-event-envelope.test.ts @@ -20,9 +20,19 @@ describe("driver runtime boundary", () => { const releaseAccepted = Promise.withResolvers(); let sideEffects = 0; let terminalStarted = false; - backend.handleInput = async () => { + backend.handleInput = async (context, _input, runId) => { sideEffects += 1; await releaseEffect.promise; + await context.ports.eventSink.pushEvents({ + events: [ + { + kind: "run.completed", + payload: { status: "completed" }, + runId, + sourceEventId: `serialized-replay.completed:${runId}`, + }, + ], + }); }; const command: RuntimeCommand = kind === "input" @@ -38,11 +48,15 @@ describe("driver runtime boundary", () => { commandId: "serialized-mcp-replay", kind: "mcp.execute", requestId: "serialized-request", + runId: DRIVER_TEST_IDS.runId, serverId: "mcp-linear", toolCallId: "tool-serialized", toolName: "createIssue", }; - const socket = new FakeDriverRuntimeIo([command, structuredClone(command)]); + const socket = new FakeDriverRuntimeIo( + [command, structuredClone(command)], + kind === "mcp" ? DRIVER_TEST_IDS.runId : undefined, + ); const recordUpdate = socket.commandUpdate.bind(socket); let accepted = 0; socket.commandUpdate = async (update, signal) => { @@ -84,7 +98,6 @@ describe("driver runtime boundary", () => { expect(terminalStarted).toBe(false); releaseAccepted.resolve(); await run; - await logger.destroy(); expect(sideEffects).toBe(1); expect(socket.updates.map((update) => update.status)).toEqual([ @@ -100,8 +113,18 @@ describe("driver runtime boundary", () => { async (kind) => { const backend = createBackend(); let sideEffects = 0; - backend.handleInput = async () => { + backend.handleInput = async (context, _input, runId) => { sideEffects += 1; + await context.ports.eventSink.pushEvents({ + events: [ + { + kind: "run.completed", + payload: { status: "completed" }, + runId, + sourceEventId: `joined-replay.completed:${runId}`, + }, + ], + }); }; const command: RuntimeCommand = kind === "input" @@ -117,11 +140,15 @@ describe("driver runtime boundary", () => { commandId: "joined-mcp-replay", kind: "mcp.execute", requestId: "joined-request", + runId: DRIVER_TEST_IDS.runId, serverId: "mcp-linear", toolCallId: "tool-joined", toolName: "createIssue", }; - const socket = new FakeDriverRuntimeIo([command, structuredClone(command)]); + const socket = new FakeDriverRuntimeIo( + [command, structuredClone(command)], + kind === "mcp" ? DRIVER_TEST_IDS.runId : undefined, + ); const terminalEntered = Promise.withResolvers(); const releaseTerminal = Promise.withResolvers(); const nextCommand = socket.nextCommand.bind(socket); @@ -174,7 +201,6 @@ describe("driver runtime boundary", () => { expect(socket.updates.filter((update) => update.status === "accepted")).toHaveLength(1); releaseTerminal.resolve(); await run; - await logger.destroy(); expect(sideEffects).toBe(1); expect(terminalAttempts).toBe(2); @@ -191,8 +217,18 @@ describe("driver runtime boundary", () => { async (kind) => { const backend = createBackend(); let sideEffects = 0; - backend.handleInput = async () => { + backend.handleInput = async (context, _input, runId) => { sideEffects += 1; + await context.ports.eventSink.pushEvents({ + events: [ + { + kind: "run.completed", + payload: { status: "completed" }, + runId, + sourceEventId: `failed-joined-replay.completed:${runId}`, + }, + ], + }); }; const command: RuntimeCommand = kind === "input" @@ -208,11 +244,15 @@ describe("driver runtime boundary", () => { commandId: "failed-joined-mcp-replay", kind: "mcp.execute", requestId: "failed-joined-request", + runId: DRIVER_TEST_IDS.runId, serverId: "mcp-linear", toolCallId: "tool-failed-joined", toolName: "createIssue", }; - const socket = new FakeDriverRuntimeIo([command, structuredClone(command)]); + const socket = new FakeDriverRuntimeIo( + [command, structuredClone(command)], + kind === "mcp" ? DRIVER_TEST_IDS.runId : undefined, + ); const terminalEntered = Promise.withResolvers(); const releaseTerminal = Promise.withResolvers(); const nextCommand = socket.nextCommand.bind(socket); @@ -276,7 +316,6 @@ describe("driver runtime boundary", () => { label: `${kind} shared terminal failure`, timeoutMs: 1_500, }); - await logger.destroy(); expect(attemptsBeforeRelease).toBe(1); expect(outcome).toMatchObject({ @@ -299,11 +338,15 @@ describe("driver runtime boundary", () => { commandId: `sink-mutation-${terminalStatus}`, kind: "mcp.execute", requestId: "sink-mutation-request", + runId: DRIVER_TEST_IDS.runId, serverId: "mcp-linear", toolCallId: "tool-sink-mutation", toolName: "createIssue", }; - const socket = new FakeDriverRuntimeIo([command, structuredClone(command)]); + const socket = new FakeDriverRuntimeIo( + [command, structuredClone(command)], + DRIVER_TEST_IDS.runId, + ); const terminalEntered = Promise.withResolvers(); const releaseTerminal = Promise.withResolvers(); const nextCommand = socket.nextCommand.bind(socket); @@ -326,23 +369,23 @@ describe("driver runtime boundary", () => { terminalSnapshots.push(structuredClone(update)); if (terminalSnapshots.length === 1) { - if (update.result !== undefined && update.result !== null) { + if (update.status === "completed" && update.result !== undefined) { Reflect.set(update.result, "outputText", "mutated synchronously"); } - if (update.error !== undefined) { + if (update.status === "failed") { Reflect.set(update.error, "message", "mutated synchronously"); } terminalEntered.resolve(); await releaseTerminal.promise; const debug = - update.result === undefined || update.result === null + update.status !== "completed" || update.result === undefined ? undefined : (Reflect.get(update.result, "debug") as { nested?: string } | undefined); if (debug !== undefined) { debug.nested = "mutated after await"; } - if (update.error !== undefined) { + if (update.status === "failed") { Reflect.set(update.error.details, "commandId", "mutated after await"); } } @@ -377,7 +420,6 @@ describe("driver runtime boundary", () => { await terminalEntered.promise; releaseTerminal.resolve(); await run; - await logger.destroy(); expect(executeCalls).toBe(1); expect(terminalSnapshots).toHaveLength(2); @@ -391,8 +433,18 @@ describe("driver runtime boundary", () => { async (kind) => { const backend = createBackend(); let sideEffects = 0; - backend.handleInput = async () => { + backend.handleInput = async (context, _input, runId) => { sideEffects += 1; + await context.ports.eventSink.pushEvents({ + events: [ + { + kind: "run.completed", + payload: { status: "completed" }, + runId, + sourceEventId: `cached-replay.completed:${runId}`, + }, + ], + }); }; const runtimeState = new DriverRuntimeStateMachine("ready"); const command: RuntimeCommand = @@ -409,11 +461,15 @@ describe("driver runtime boundary", () => { commandId: "mcp-report-failure", kind: "mcp.execute", requestId: "request-report-failure", + runId: DRIVER_TEST_IDS.runId, serverId: "mcp-linear", toolCallId: "tool-report-failure", toolName: "createIssue", }; - const socket = new FakeDriverRuntimeIo([command]); + const socket = new FakeDriverRuntimeIo( + [command], + kind === "mcp" ? DRIVER_TEST_IDS.runId : undefined, + ); const recordUpdate = socket.commandUpdate.bind(socket); const terminalAttempts: string[] = []; socket.commandUpdate = async (update, signal) => { @@ -446,7 +502,6 @@ describe("driver runtime boundary", () => { }); await dispatcher.run(socket, logger); - await logger.destroy(); expect(sideEffects).toBe(1); expect(terminalAttempts).toEqual(["completed", "completed", "completed"]); diff --git a/tests/driver-runtime-boundary-fixtures.ts b/tests/driver-runtime-boundary-fixtures.ts index 2f6dbc5..3192773 100644 --- a/tests/driver-runtime-boundary-fixtures.ts +++ b/tests/driver-runtime-boundary-fixtures.ts @@ -2,12 +2,27 @@ import type { AgentDriverBackend, AgentDriverContext } from "../src/core/agent-d import { createAgentDriverContext } from "../src/core/agent-driver-backend"; import { DriverCommandDispatcher } from "../src/core/driver-command-dispatcher"; import { DriverPermissionBroker } from "../src/core/driver-permission-broker"; -import type { DriverRuntimeIo } from "../src/core/driver-runtime-io"; +import { + assertIsolatedRunTerminalBatch, + withSourceEventIds, + type DriverRuntimeIo, +} from "../src/core/driver-runtime-io"; +import type { DriverRunTerminalBarrier } from "../src/core/driver-runtime-io"; import type { DriverRuntimeStateMachine } from "../src/core/driver-runtime-state"; +import { + DriverTerminalStateMachine, + type DriverRunTicket, +} from "../src/core/driver-terminal-state"; import type { AgentDriverMcpPort } from "../src/host-ports"; -import { createBufferedSinkLogger } from "../src/observability"; +import { createDisabledLogger } from "../src/observability"; import { createDriverStartInputFromBootPayload } from "../src/protocol/start"; -import type { RuntimeCommand } from "../src/runtime-command"; +import type { RunId } from "../src/protocol/id"; +import type { + McpExecuteCommand, + McpExternalToolEffectExecution, + McpExternalToolExecutionResult, + RuntimeCommand, +} from "../src/runtime-command"; import { DRIVER_TEST_IDS, driverBootPayload } from "./driver-boot-payload-fixture"; export { DRIVER_TEST_IDS }; @@ -20,18 +35,52 @@ export class FakeDriverRuntimeIo implements DriverRuntimeIo { readonly pushedEvents: Parameters[0][] = []; readonly updates: Parameters[0][] = []; readonly #commands: readonly RuntimeCommand[]; + #activeRunTicket: DriverRunTicket | null = null; #commandIndex = 0; + #runTerminalBarrier: DriverRunTerminalBarrier | null = null; + readonly #terminalState = new DriverTerminalStateMachine(); - constructor(commands: readonly RuntimeCommand[]) { + constructor(commands: readonly RuntimeCommand[], activeRunId?: RunId) { this.#commands = commands; + if (activeRunId !== undefined) { + this.beginRun(activeRunId); + } + } + + beginRun(runId: Parameters[0]): DriverRunTicket { + const ticket = this.#terminalState.beginRun(runId); + this.#activeRunTicket = ticket; + return ticket; + } + + claimRunCancellation( + ticket: DriverRunTicket, + reason: string, + source?: Parameters[2], + ): ReturnType { + return this.#terminalState.claimCancellation(ticket, reason, source); + } + + currentRunId(): ReturnType { + return this.#terminalState.currentRunId(); + } + + releaseRun(ticket: DriverRunTicket, reason: "command_acked" | "driver_failing"): void { + this.#terminalState.releaseRun(ticket, reason); + if (this.#activeRunTicket === ticket) { + this.#activeRunTicket = null; + } } - beginRun(): void { - return; + runSnapshot(runId?: Parameters[0]) { + return this.#terminalState.snapshotRun(runId); } - endRun(): void { - return; + settleRunInput( + ticket: DriverRunTicket, + outcome: Parameters[1], + ): ReturnType { + return this.#terminalState.settleInput(ticket, outcome); } async heartbeat(): ReturnType { @@ -59,10 +108,17 @@ export class FakeDriverRuntimeIo implements DriverRuntimeIo { attempt: 1, effectId: `test-effect-${input.commandId}`, idempotencyKey: `test-effect-${input.commandId}`, - kind: "execute", + kind: "claimed", }; } + async observeExternalToolEffect( + input: Parameters[0], + _signal: AbortSignal, + ): ReturnType { + return { effectId: `test-effect-${input.commandId}`, kind: "intent" }; + } + isDrained(): boolean { return this.#commandIndex >= this.#commands.length; } @@ -75,40 +131,108 @@ export class FakeDriverRuntimeIo implements DriverRuntimeIo { } async completeRun(_signal?: AbortSignal): Promise { + const runId = this.#terminalState.terminalRunId(DRIVER_TEST_IDS.runId); + if (runId === null) { + throw new Error("Driver run terminal requires an exact run ID."); + } + const terminal = { runId, status: "completed" } as const; + if (this.#terminalState.selectInstanceTerminal(terminal) === "acked") { + return; + } this.completedRunReasons.push("completed"); + this.#terminalState.ackInstanceTerminal(terminal); } - async completeExternalToolEffect( - _input: Parameters[0], + async settleExternalToolEffect( + input: Parameters[0], _signal: AbortSignal, - ): Promise { - return; + ): ReturnType { + return input.settlement.kind === "succeeded" + ? { + effectId: input.effectId, + kind: "succeeded", + result: structuredClone(input.settlement.result), + } + : { effectId: input.effectId, kind: "unknown" }; } async failRun( error: Parameters[0], _signal?: AbortSignal, ): Promise { + const runId = this.#terminalState.terminalRunId(DRIVER_TEST_IDS.runId); + if (runId === null) { + throw new Error("Driver run terminal requires an exact run ID."); + } + const terminal = { error, runId, status: "failed" } as const; + if (this.#terminalState.selectInstanceTerminal(terminal) === "acked") { + return; + } this.failedRuns.push(error); + this.#terminalState.ackInstanceTerminal(terminal); } async pushEvents( input: Parameters[0], ): ReturnType { - this.pushedEvents.push(input); + input.signal?.throwIfAborted(); + const events = structuredClone(withSourceEventIds(input.events)); + assertIsolatedRunTerminalBatch(events); + const barrier = this.#runTerminalBarrier; + if (barrier !== null) { + const pending = barrier(events); + if (pending !== undefined) { + await pending; + } + } + input.signal?.throwIfAborted(); + this.pushedEvents.push({ ...input, events }); + const ticket = this.#activeRunTicket; + const terminalEvent = events.find( + (event) => + event.kind === "run.cancelled" || + event.kind === "run.completed" || + event.kind === "run.failed", + ); + if (terminalEvent !== undefined && ticket !== null) { + const status = terminalEvent.kind.slice("run.".length) as + | "cancelled" + | "completed" + | "failed"; + const selected = this.#terminalState.selectRunTerminal(ticket, { + event: terminalEvent, + runId: ticket.runId, + sourceEventId: terminalEvent.sourceEventId!, + status, + }); + if (selected === "cancelled") { + throw new Error("Driver completed terminal lost the cancellation race."); + } + } + const accepted = events.map((event, index) => ({ + eventId: event.sourceEventId!, + seq: index + 1, + type: event.kind, + })); + if (terminalEvent !== undefined && ticket !== null) { + this.#terminalState.ackRunTerminal(ticket, accepted.at(-1)!); + } return { - accepted: input.events.map((event, index) => ({ - seq: index + 1, - type: event.kind, - })), + accepted, }; } - async markExternalToolEffectUnknown( - _input: Parameters[0], - _signal: AbortSignal, - ): Promise { - return; + registerRunTerminalBarrier(barrier: DriverRunTerminalBarrier): () => void { + if (this.#runTerminalBarrier !== null) { + throw new Error("Driver run terminal barrier is already registered."); + } + + this.#runTerminalBarrier = barrier; + return () => { + if (this.#runTerminalBarrier === barrier) { + this.#runTerminalBarrier = null; + } + }; } } @@ -127,12 +251,22 @@ export function createBackend(): RecordingBackend { async cancelActiveTurn(_context, reason) { this.cancelledReasons.push(reason); }, - async handleInput(context) { + async handleInput(context, _input, runId) { if (this.failInput) { throw new Error("backend rejected input"); } this.handledInputs.push(context.payload.execution.session); + await context.ports.eventSink.pushEvents({ + events: [ + { + kind: "run.completed", + payload: { status: "completed" }, + runId, + sourceEventId: `test.run.completed:${runId}`, + }, + ], + }); }, async start(_context, signal) { signal.throwIfAborted(); @@ -143,21 +277,41 @@ export function createBackend(): RecordingBackend { }; } +export async function settleBackendInput( + context: AgentDriverContext, + runId: RunId, + signal?: AbortSignal, +): Promise { + const status = signal?.aborted ? "cancelled" : "completed"; + await context.ports.eventSink.pushEvents({ + events: [ + { + kind: `run.${status}`, + payload: { status }, + runId, + sourceEventId: `test.run.${status}:${runId}`, + }, + ], + }); + signal?.throwIfAborted(); +} + export function createDispatcher(input: { backend: AgentDriverBackend; isShuttingDown?: () => boolean; - mcpExecute?: AgentDriverMcpPort["execute"]; + mcpExecute?: ( + command: McpExecuteCommand, + effect: McpExternalToolEffectExecution, + ) => Promise; + mcpPrepare?: AgentDriverMcpPort["prepare"]; + permissionRequest?: AgentDriverContext["ports"]["permission"]["request"]; permissionRequests?: DriverPermissionBroker; rememberRunFailure?: (error: Parameters[0]) => void; runtimeState: DriverRuntimeStateMachine; shutdownSignal?: AbortSignal; shutdown?: (socket: DriverRuntimeIo, reason: string) => Promise; }) { - const logger = createBufferedSinkLogger({ - level: "debug", - service: "driver-runtime-boundary-test", - sink: async () => {}, - }); + const logger = createDisabledLogger(); const commandReads = { count: 0, }; @@ -183,7 +337,7 @@ export function createDispatcher(input: { logger: runtimeLogger, payload: bootPayload, permission: { - request: async () => "reject_once", + request: input.permissionRequest ?? (async () => "reject_once"), }, ports: { commandSource: { @@ -193,13 +347,20 @@ export function createDispatcher(input: { }, }, mcp: { - execute: - input.mcpExecute ?? + prepare: + input.mcpPrepare ?? (async (command) => ({ - outputText: `ran ${command.toolName}`, - requestId: command.requestId, - serverId: command.serverId, - toolName: command.toolName, + execute: (effect) => + ( + input.mcpExecute ?? + (async () => ({ + outputText: `ran ${command.toolName}`, + requestId: command.requestId, + serverId: command.serverId, + toolName: command.toolName, + })) + )(command, effect), + async [Symbol.asyncDispose]() {}, })), }, }, diff --git a/tests/driver-runtime-boundary-state.test.ts b/tests/driver-runtime-boundary-state.test.ts index d1deebe..d4dc71c 100644 --- a/tests/driver-runtime-boundary-state.test.ts +++ b/tests/driver-runtime-boundary-state.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { ACTIVE_INPUT_SETTLE_GRACE_MS } from "../src/core/driver-command-dispatcher"; import { DriverRuntimeStateMachine } from "../src/core/driver-runtime-state"; import { createTimingEvent } from "../src/core/driver-runtime-timing"; import { toDriverEventEnvelopes } from "../src/infrastructure/runtime/driver-instance-socket"; @@ -12,6 +13,7 @@ import { FakeDriverRuntimeIo, createBackend, createDispatcher, + settleBackendInput, waitForUpdate, } from "./driver-runtime-boundary-fixtures"; @@ -147,6 +149,20 @@ describe("driver runtime boundary", () => { expect(event?.event.runId).toBe(DRIVER_TEST_IDS.thirdRunId); }); + test("driver socket preserves explicit session scope during an active turn", () => { + const [event] = toDriverEventEnvelopes( + driverBootPayload, + { + kind: "agent.task.updated", + payload: { active: false, status: "completed", taskId: "agent-1" }, + runId: null, + }, + DRIVER_TEST_IDS.secondRunId, + ); + + expect(event?.event.runId).toBeUndefined(); + }); + test("driver socket preserves a valid explicit run id during another active turn", () => { const draft = { kind: "run.started", @@ -273,9 +289,11 @@ describe("driver runtime boundary", () => { await dispatcher.run(socket, logger); await waitForUpdate( socket, - (update) => update.commandId === "input-1" && update.status === "completed", + (update) => + update.commandId === "input-1" && + update.status === "completed" && + runtimeState.status() === "ready", ); - await logger.destroy(); expect(runtimeState.status()).toBe("ready"); expect(commandReads.count).toBe(1); @@ -301,10 +319,20 @@ describe("driver runtime boundary", () => { const release = Promise.withResolvers(); const backend = createBackend(); let calls = 0; - backend.handleInput = async () => { + backend.handleInput = async (context, _input, runId) => { calls += 1; started.resolve(); await release.promise; + await context.ports.eventSink.pushEvents({ + events: [ + { + kind: "run.completed", + payload: { status: "completed" }, + runId, + sourceEventId: `active-replay.completed:${runId}`, + }, + ], + }); }; const command: RuntimeCommand = kind === "input" @@ -320,11 +348,15 @@ describe("driver runtime boundary", () => { commandId: "active-replay", kind: "mcp.execute", requestId: "request-replay", + runId: DRIVER_TEST_IDS.runId, serverId: "mcp-linear", toolCallId: "tool-replay", toolName: "createIssue", }; - const socket = new FakeDriverRuntimeIo([command, structuredClone(command)]); + const socket = new FakeDriverRuntimeIo( + [command, structuredClone(command)], + kind === "mcp" ? DRIVER_TEST_IDS.runId : undefined, + ); const runtimeState = new DriverRuntimeStateMachine("ready"); const { dispatcher, logger } = createDispatcher({ backend, @@ -362,7 +394,6 @@ describe("driver runtime boundary", () => { (update) => update.commandId === command.commandId && update.status === "completed", ); await runTask; - await logger.destroy(); expect(calls).toBe(1); expect(socket.updates.filter((update) => update.status === "accepted")).toHaveLength(2); @@ -370,243 +401,15 @@ describe("driver runtime boundary", () => { }, ); - test("does not replay an MCP effect after terminal receipt delivery is lost", async () => { - const command: RuntimeCommand = { - argumentsJson: '{"title":"once"}', - commandId: "persistent-effect", - kind: "mcp.execute", - requestId: "request-persistent-effect", - serverId: "mcp-linear", - toolCallId: "tool-persistent-effect", - toolName: "createIssue", - }; - let providerCalls = 0; - let effectResult: { - outputText: string; - requestId: string; - serverId: string; - toolName: string; - } | null = null; - const effectPersisted = Promise.withResolvers(); - - class LossySocket extends FakeDriverRuntimeIo { - override async claimExternalToolEffect(): Promise< - | { attempt: number; effectId: string; idempotencyKey: string; kind: "execute" } - | { effectId: string; kind: "completed"; result: NonNullable } - > { - return effectResult === null - ? { attempt: 1, effectId: "effect-1", idempotencyKey: "effect-1", kind: "execute" } - : { effectId: "effect-1", kind: "completed", result: effectResult }; - } - - override async completeExternalToolEffect(input: { - commandId: string; - result: NonNullable; - }): Promise { - effectResult = input.result; - effectPersisted.resolve(); - } - - override async commandUpdate( - input: Parameters[0], - signal: AbortSignal, - ): Promise { - if (input.status === "completed") { - throw new Error("control connection lost after effect receipt persisted"); - } - - await super.commandUpdate(input, signal); - } - } - - const firstSocket = new LossySocket([command]); - const first = createDispatcher({ - backend: createBackend(), - isShuttingDown: () => firstSocket.isDrained(), - mcpExecute: async (request) => { - providerCalls += 1; - return { - outputText: "created A-1", - requestId: request.requestId, - serverId: request.serverId, - toolName: request.toolName, - }; - }, - runtimeState: new DriverRuntimeStateMachine("ready"), - }); - - await first.dispatcher.run(firstSocket, first.logger); - await effectPersisted.promise; - await first.logger.destroy(); - - const secondSocket = new FakeDriverRuntimeIo([structuredClone(command)]); - const second = createDispatcher({ - backend: createBackend(), - isShuttingDown: () => secondSocket.isDrained(), - mcpExecute: async () => { - providerCalls += 1; - throw new Error("provider must not run during terminal redelivery"); - }, - runtimeState: new DriverRuntimeStateMachine("ready"), - }); - - // The default fixture ledger has no cross-process state, so carry the - // persisted effect receipt through the fresh Dispatcher explicitly. - secondSocket.claimExternalToolEffect = async () => ({ - effectId: "effect-1", - kind: "completed" as const, - result: effectResult!, - }); - await second.dispatcher.run(secondSocket, second.logger); - await second.logger.destroy(); - - expect(providerCalls).toBe(1); - expect(secondSocket.updates).toEqual([ - { commandId: command.commandId, status: "accepted" }, - { commandId: command.commandId, result: effectResult, status: "completed" }, - ]); - expect(secondSocket.pushedEvents).toMatchObject([ - { - events: [{ kind: "tool.call.updated", payload: { toolCallId: command.toolCallId } }], - }, - { - events: [{ kind: "tool.call.updated", payload: { toolCallId: command.toolCallId } }], - }, - ]); - }); - - test("blocks an unknown MCP effect without another provider call", async () => { - const command: RuntimeCommand = { - argumentsJson: '{"title":"do not duplicate"}', - commandId: "unknown-effect-command", - kind: "mcp.execute", - requestId: "unknown-effect-request", - serverId: "mcp-linear", - toolCallId: "tool-unknown-effect", - toolName: "createIssue", - }; - const socket = new FakeDriverRuntimeIo([command]); - socket.claimExternalToolEffect = async () => ({ - effectId: "01J0000000000000000000000Z", - kind: "unknown" as const, - }); - let providerCalls = 0; - const runtime = createDispatcher({ - backend: createBackend(), - isShuttingDown: () => socket.isDrained(), - mcpExecute: async () => { - providerCalls += 1; - throw new Error("provider must not run for an unknown effect"); - }, - runtimeState: new DriverRuntimeStateMachine("ready"), - }); - - await runtime.dispatcher.run(socket, runtime.logger); - await runtime.logger.destroy(); - - expect(providerCalls).toBe(0); - expect(socket.updates).toMatchObject([ - { commandId: command.commandId, status: "accepted" }, - { - commandId: command.commandId, - error: { - code: "driver.command_failed.mcp.execute", - message: expect.stringContaining("01J0000000000000000000000Z"), - retryable: false, - }, - status: "failed", - }, - ]); - }); - - test("fences an in-flight MCP effect with a live signal when a turn is cancelled", async () => { - const mcpCommand: RuntimeCommand = { - argumentsJson: '{"title":"cancel safely"}', - commandId: "cancelled-effect-command", - kind: "mcp.execute", - requestId: "cancelled-effect-request", - serverId: "mcp-linear", - toolCallId: "tool-cancelled-effect", - toolName: "createIssue", - }; - const cancelCommand: RuntimeCommand = { - commandId: "cancelled-effect-turn", - kind: "turn.cancel", - reason: "viewer.cancelled", - }; - const mcpStarted = Promise.withResolvers(); - - class CancellationSocket extends FakeDriverRuntimeIo { - readonly #nextCommand = Promise.withResolvers(); - #readFirstCommand = true; - #drained = false; - fenceSignalAborted: boolean | null = null; - fencedCommandId: string | null = null; - - constructor() { - super([]); - } - - override isDrained(): boolean { - return this.#drained; - } - - override async markExternalToolEffectUnknown( - input: { commandId: string }, - signal: AbortSignal, - ): Promise { - this.fencedCommandId = input.commandId; - this.fenceSignalAborted = signal.aborted; - } - - override nextCommand(_signal: AbortSignal): Promise { - if (this.#readFirstCommand) { - this.#readFirstCommand = false; - return Promise.resolve(mcpCommand); - } - - return this.#nextCommand.promise; - } - - sendCancellation(): void { - this.#drained = true; - this.#nextCommand.resolve(cancelCommand); - } - } - - const socket = new CancellationSocket(); - const runtime = createDispatcher({ - backend: createBackend(), - isShuttingDown: () => socket.isDrained(), - mcpExecute: async (_command, signal) => { - mcpStarted.resolve(); - return new Promise((_resolve, reject) => { - signal.addEventListener("abort", () => reject(signal.reason), { once: true }); - }); - }, - runtimeState: new DriverRuntimeStateMachine("ready"), - }); - const runTask = runtime.dispatcher.run(socket, runtime.logger); - - await mcpStarted.promise; - socket.sendCancellation(); - await runTask; - await runtime.logger.destroy(); - - expect(socket.fenceSignalAborted).toBe(false); - expect(socket.fencedCommandId).toBe(mcpCommand.commandId); - expect(socket.updates).toMatchObject([ - { commandId: mcpCommand.commandId, status: "accepted" }, - { commandId: cancelCommand.commandId, status: "accepted" }, - { commandId: mcpCommand.commandId, status: "cancelled" }, - { commandId: cancelCommand.commandId, status: "completed" }, - ]); - }); - test.each([ [ "changed content", - { commandId: "reused-command", kind: "turn.cancel", reason: "second reason" }, + { + commandId: "reused-command", + kind: "turn.cancel", + reason: "second reason", + runId: DRIVER_TEST_IDS.runId, + }, ], [ "changed kind", @@ -615,23 +418,31 @@ describe("driver runtime boundary", () => { decision: "reject_once", kind: "permission.resolve", requestId: "permission-1", + runId: DRIVER_TEST_IDS.runId, }, ], ] satisfies readonly (readonly [string, RuntimeCommand])[])( "rejects a completed command ID replay with %s", async (_case, replay) => { const backend = createBackend(); - const socket = new FakeDriverRuntimeIo([ - { commandId: "reused-command", kind: "turn.cancel", reason: "first reason" }, - replay, - ]); + const socket = new FakeDriverRuntimeIo( + [ + { + commandId: "reused-command", + kind: "turn.cancel", + reason: "first reason", + runId: DRIVER_TEST_IDS.runId, + }, + replay, + ], + DRIVER_TEST_IDS.runId, + ); const runtimeState = new DriverRuntimeStateMachine("ready"); const { dispatcher, logger } = createDispatcher({ backend, runtimeState }); await expect(dispatcher.run(socket, logger)).rejects.toThrow( "replayed with changed identity or content", ); - await logger.destroy(); expect(backend.cancelledReasons).toEqual(["first reason"]); expect(socket.updates).toEqual([ @@ -646,19 +457,18 @@ describe("driver runtime boundary", () => { }, ); - test("lets a queued input wait for the previous turn command to settle", async () => { + test("rejects overlapping input without blocking a following stop", async () => { const firstInputStarted = Promise.withResolvers(); - const firstInputCanFinish = Promise.withResolvers(); const backend = createBackend(); let handledInputCount = 0; - backend.handleInput = async (context) => { + backend.handleInput = async (context, _input, runId, signal) => { handledInputCount += 1; backend.handledInputs.push(context.payload.execution.session); - - if (handledInputCount === 1) { - firstInputStarted.resolve(); - await firstInputCanFinish.promise; - } + firstInputStarted.resolve(); + await new Promise((resolve) => { + signal!.addEventListener("abort", () => resolve(), { once: true }); + }); + await settleBackendInput(context, runId, signal); }; const runtimeState = new DriverRuntimeStateMachine("ready"); const socket = new FakeDriverRuntimeIo([ @@ -680,60 +490,60 @@ describe("driver runtime boundary", () => { requestId: "request-2", runId: DRIVER_TEST_IDS.secondRunId, }, + { + commandId: "stop-after-overlap", + kind: "session.stop", + reason: "test.stop-after-overlap", + }, ]); const { commandReads, dispatcher, logger } = createDispatcher({ backend, isShuttingDown: () => socket.isDrained(), runtimeState, }); - const runTask = dispatcher.run(socket, logger); + const nativeSetTimeout = globalThis.setTimeout; + let activeInputTimeouts = 0; + globalThis.setTimeout = (( + callback: (...arguments_: unknown[]) => void, + timeout?: number, + ...arguments_: unknown[] + ) => { + if (timeout === ACTIVE_INPUT_SETTLE_GRACE_MS) { + activeInputTimeouts += 1; + } + return nativeSetTimeout(callback, timeout, ...arguments_); + }) as typeof setTimeout; - await firstInputStarted.promise; - await waitForUpdate( - socket, - (update) => update.commandId === "input-1" && update.status === "accepted", - ); - firstInputCanFinish.resolve(); - await runTask; - await logger.destroy(); + try { + const runTask = dispatcher.run(socket, logger); + await firstInputStarted.promise; + await runTask; + } finally { + globalThis.setTimeout = nativeSetTimeout; + } - expect(handledInputCount).toBe(2); - expect(runtimeState.status()).toBe("ready"); - expect(commandReads.count).toBe(2); + expect(handledInputCount).toBe(1); + expect(activeInputTimeouts).toBe(1); + expect(runtimeState.status()).toBe("stopped"); + expect(commandReads.count).toBe(3); expect(socket.failedRuns).toEqual([]); + expect(backend.cancelledReasons).toEqual(["test.stop-after-overlap"]); expect(socket.updates).toEqual( expect.arrayContaining([ - { - commandId: "input-1", - status: "accepted", - }, - { - commandId: "input-2", - status: "accepted", - }, - { - commandId: "input-1", - result: { - requestId: "request-1", - }, - status: "completed", - }, - { - commandId: "input-2", - result: { - requestId: "request-2", - }, - status: "completed", - }, + { commandId: "input-1", status: "accepted" }, + { commandId: "input-1", status: "cancelled" }, + { commandId: "stop-after-overlap", status: "accepted" }, + { commandId: "stop-after-overlap", status: "completed" }, + expect.objectContaining({ commandId: "input-2", status: "failed" }), ]), ); expect( socket.updates.findIndex( - (update) => update.commandId === "input-1" && update.status === "completed", + (update) => update.commandId === "input-2" && update.status === "failed", ), ).toBeLessThan( socket.updates.findIndex( - (update) => update.commandId === "input-2" && update.status === "completed", + (update) => update.commandId === "stop-after-overlap" && update.status === "accepted", ), ); }); diff --git a/tests/driver-runtime-boundary-timing.test.ts b/tests/driver-runtime-boundary-timing.test.ts index 122122b..0706da9 100644 --- a/tests/driver-runtime-boundary-timing.test.ts +++ b/tests/driver-runtime-boundary-timing.test.ts @@ -1,24 +1,105 @@ import { describe, expect, test } from "bun:test"; +import { ACTIVE_INPUT_SETTLE_GRACE_MS } from "../src/core/driver-command-dispatcher"; +import { DRIVER_EVENT_DELIVERY_TIMEOUT_MS } from "../src/core/driver-runtime-io"; import { DriverRuntimeStateMachine } from "../src/core/driver-runtime-state"; -import type { RuntimeCommand } from "../src/runtime-command"; +import type { McpExternalToolExecutionResult, RuntimeCommand } from "../src/runtime-command"; import { settlePromiseWithTimeout } from "../src/utils/async"; import { DRIVER_TEST_IDS } from "./driver-boot-payload-fixture"; import { FakeDriverRuntimeIo, createBackend, createDispatcher, + settleBackendInput, waitForUpdate, } from "./driver-runtime-boundary-fixtures"; describe("driver runtime boundary", () => { + test("allows cancellation cleanup to consume the public event delivery deadline", async () => { + const backend = createBackend(); + const inputEntered = Promise.withResolvers(); + const runtimeState = new DriverRuntimeStateMachine("ready"); + const socket = new FakeDriverRuntimeIo([ + { + commandId: "input-with-lossless-terminal", + input: { text: "wait" }, + kind: "input.start", + requestId: "request-with-lossless-terminal", + runId: DRIVER_TEST_IDS.runId, + }, + { + commandId: "cancel-with-lossless-terminal", + kind: "turn.cancel", + reason: "test.cancel", + runId: DRIVER_TEST_IDS.runId, + }, + ]); + const nativeSetTimeout = globalThis.setTimeout; + const delay = (milliseconds: number) => + new Promise((resolve) => nativeSetTimeout(resolve, milliseconds)); + const recordEvents = socket.pushEvents.bind(socket); + socket.pushEvents = async (input) => { + await delay(90); + return recordEvents(input); + }; + backend.handleInput = async (context, _input, runId, signal) => { + inputEntered.resolve(); + await new Promise((resolve) => { + signal!.addEventListener("abort", () => nativeSetTimeout(resolve, 30), { once: true }); + }); + await settleBackendInput(context, runId, signal); + }; + const { dispatcher, logger } = createDispatcher({ + backend, + isShuttingDown: () => + socket.updates.some( + (update) => + update.commandId === "cancel-with-lossless-terminal" && update.status === "completed", + ), + runtimeState, + }); + const acceleratedSetTimeout = ( + callback: (...args: unknown[]) => void, + timeout?: number, + ...arguments_: unknown[] + ) => + nativeSetTimeout( + callback, + timeout === 5_000 + ? 20 + : timeout === DRIVER_EVENT_DELIVERY_TIMEOUT_MS + ? 100 + : timeout === ACTIVE_INPUT_SETTLE_GRACE_MS + ? 200 + : timeout, + ...arguments_, + ); + globalThis.setTimeout = acceleratedSetTimeout as typeof setTimeout; + + try { + const run = dispatcher.run(socket, logger); + await inputEntered.promise; + await expect(run).resolves.toBeUndefined(); + expect(runtimeState.status()).toBe("ready"); + expect(socket.failedRuns).toEqual([]); + expect(socket.pushedEvents).toMatchObject([{ events: [{ kind: "run.cancelled" }] }]); + expect(socket.updates).toContainEqual({ + commandId: "cancel-with-lossless-terminal", + status: "completed", + }); + } finally { + globalThis.setTimeout = nativeSetTimeout; + } + }); + test("external shutdown cancels local input and command polling immediately", async () => { const entered = Promise.withResolvers(); const release = Promise.withResolvers(); const backend = createBackend(); - backend.handleInput = async () => { + backend.handleInput = async (context, _input, runId, signal) => { entered.resolve(); await release.promise; + await settleBackendInput(context, runId, signal); }; const shutdown = new AbortController(); const runtimeState = new DriverRuntimeStateMachine("ready"); @@ -46,7 +127,6 @@ describe("driver runtime boundary", () => { label: "externally stopped command loop", timeoutMs: 100, }); - await logger.destroy(); expect(outcome.status).toBe("completed"); expect(socket.updates).toContainEqual({ @@ -75,7 +155,16 @@ describe("driver runtime boundary", () => { const runtimeState = new DriverRuntimeStateMachine("ready"); let boundarySignal: AbortSignal | undefined; const socket = new FakeDriverRuntimeIo( - boundary === "poll" ? [] : [{ commandId: "accepted-never-settles", kind: "turn.cancel" }], + boundary === "poll" + ? [] + : [ + { + commandId: "accepted-never-settles", + kind: "turn.cancel", + runId: DRIVER_TEST_IDS.runId, + }, + ], + boundary === "poll" ? undefined : DRIVER_TEST_IDS.runId, ); if (boundary === "poll") { socket.nextCommand = async (signal) => { @@ -114,7 +203,6 @@ describe("driver runtime boundary", () => { pending.reject(new Error("late transport failure")); } await Bun.sleep(0); - await logger.destroy(); expect(socket.failedRuns).toEqual([]); }, @@ -127,13 +215,17 @@ describe("driver runtime boundary", () => { const entered = Promise.withResolvers(); const late = Promise.withResolvers(); const runtimeState = new DriverRuntimeStateMachine("ready"); - const socket = new FakeDriverRuntimeIo([ - { - commandId: "accepted-timeout", - kind: "turn.cancel", - reason: "must not run", - }, - ]); + const socket = new FakeDriverRuntimeIo( + [ + { + commandId: "accepted-timeout", + kind: "turn.cancel", + reason: "must not run", + runId: DRIVER_TEST_IDS.runId, + }, + ], + DRIVER_TEST_IDS.runId, + ); let acceptedSignal: AbortSignal | undefined; socket.commandUpdate = async (update, signal) => { if (update.status === "accepted") { @@ -175,24 +267,27 @@ describe("driver runtime boundary", () => { late.reject(new Error("late accepted ACK failure")); } await Bun.sleep(0); - await logger.destroy(); }, ); test("keeps MCP commands explicit at the API boundary", async () => { const backend = createBackend(); const runtimeState = new DriverRuntimeStateMachine("ready"); - const socket = new FakeDriverRuntimeIo([ - { - argumentsJson: '{"issue":"A-1"}', - commandId: "mcp-1", - kind: "mcp.execute", - requestId: "mcp-request-1", - serverId: "mcp-linear", - toolCallId: "tool-mcp-1", - toolName: "createIssue", - }, - ]); + const socket = new FakeDriverRuntimeIo( + [ + { + argumentsJson: '{"issue":"A-1"}', + commandId: "mcp-1", + kind: "mcp.execute", + requestId: "mcp-request-1", + runId: DRIVER_TEST_IDS.runId, + serverId: "mcp-linear", + toolCallId: "tool-mcp-1", + toolName: "createIssue", + }, + ], + DRIVER_TEST_IDS.runId, + ); const { commandReads, dispatcher, logger } = createDispatcher({ backend, isShuttingDown: () => @@ -203,7 +298,6 @@ describe("driver runtime boundary", () => { }); await dispatcher.run(socket, logger); - await logger.destroy(); expect(runtimeState.status()).toBe("ready"); expect(commandReads.count).toBeGreaterThanOrEqual(1); @@ -252,20 +346,24 @@ describe("driver runtime boundary", () => { ]); }); - test("reports remote MCP execute failures as diagnostics", async () => { + test("fences remote MCP execute failures as unknown diagnostics", async () => { const backend = createBackend(); const runtimeState = new DriverRuntimeStateMachine("ready"); - const socket = new FakeDriverRuntimeIo([ - { - argumentsJson: '{"issue":"A-1"}', - commandId: "mcp-1", - kind: "mcp.execute", - requestId: "mcp-request-1", - serverId: "mcp-linear", - toolCallId: "tool-mcp-1", - toolName: "createIssue", - }, - ]); + const socket = new FakeDriverRuntimeIo( + [ + { + argumentsJson: '{"issue":"A-1"}', + commandId: "mcp-1", + kind: "mcp.execute", + requestId: "mcp-request-1", + runId: DRIVER_TEST_IDS.runId, + serverId: "mcp-linear", + toolCallId: "tool-mcp-1", + toolName: "createIssue", + }, + ], + DRIVER_TEST_IDS.runId, + ); const { dispatcher, logger } = createDispatcher({ backend, isShuttingDown: () => @@ -277,7 +375,6 @@ describe("driver runtime boundary", () => { }); await dispatcher.run(socket, logger); - await logger.destroy(); expect(socket.updates).toMatchObject([ { @@ -287,8 +384,16 @@ describe("driver runtime boundary", () => { { commandId: "mcp-1", error: { - code: "driver.command_failed.mcp.execute", - message: "MCP upstream failed", + code: "driver.external_tool_effect_unknown", + details: { + commandId: "mcp-1", + effectId: "test-effect-mcp-1", + requestId: "mcp-request-1", + serverId: "mcp-linear", + toolName: "createIssue", + }, + message: expect.stringContaining("unknown outcome"), + retryable: false, }, status: "failed", }, @@ -310,7 +415,7 @@ describe("driver runtime boundary", () => { { kind: "tool.call.updated", payload: { - rawOutput: "MCP upstream failed", + rawOutput: expect.stringContaining("unknown outcome"), status: "failed", toolCallId: "tool-mcp-1", }, @@ -329,7 +434,7 @@ describe("driver runtime boundary", () => { serverId: "mcp-linear", toolName: "createIssue", }, - message: "MCP upstream failed", + message: expect.stringContaining("unknown outcome"), severity: "error", source: "core", }, @@ -339,82 +444,144 @@ describe("driver runtime boundary", () => { ]); }); - test("lets session stop preempt a stuck MCP command", async () => { + test("lets session stop preempt polling and join a committed MCP effect", async () => { const backend = createBackend(); - const aborted = Promise.withResolvers(); - const releaseCleanup = Promise.withResolvers(); + const entered = Promise.withResolvers(); + const execution = Promise.withResolvers(); const runtimeState = new DriverRuntimeStateMachine("ready"); - const socket = new FakeDriverRuntimeIo([ - { - argumentsJson: "{}", - commandId: "mcp-stuck", - kind: "mcp.execute", - requestId: "mcp-request-stuck", - serverId: "mcp-linear", - toolCallId: "tool-mcp-stuck", - toolName: "waitForever", - }, - { - commandId: "stop-1", - kind: "session.stop", - reason: "test.stop", - }, - ]); + const socket = new FakeDriverRuntimeIo( + [ + { + argumentsJson: "{}", + commandId: "mcp-stuck", + kind: "mcp.execute", + requestId: "mcp-request-stuck", + runId: DRIVER_TEST_IDS.runId, + serverId: "mcp-linear", + toolCallId: "tool-mcp-stuck", + toolName: "waitForever", + }, + { + commandId: "stop-1", + kind: "session.stop", + reason: "test.stop", + }, + ], + DRIVER_TEST_IDS.runId, + ); const { dispatcher, logger, shutdownCalls } = createDispatcher({ backend, isShuttingDown: () => socket.isDrained(), - mcpExecute: async (_command, signal) => { - return await new Promise((_resolve, reject) => { - signal.addEventListener( - "abort", - async () => { - aborted.resolve(); - await releaseCleanup.promise; - reject(signal.reason); - }, - { once: true }, - ); - }); + mcpExecute: async () => { + entered.resolve(); + return execution.promise; }, runtimeState, }); const run = dispatcher.run(socket, logger); - await aborted.promise; + await entered.promise; + await waitForUpdate( + socket, + (update) => update.commandId === "stop-1" && update.status === "accepted", + ); expect(await Promise.race([run.then(() => true), Bun.sleep(10).then(() => false)])).toBe(false); - releaseCleanup.resolve(); + execution.reject(new Error("provider response lost")); await run; await waitForUpdate( socket, - (update) => update.commandId === "mcp-stuck" && update.status === "cancelled", + (update) => update.commandId === "mcp-stuck" && update.status === "failed", ); - await logger.destroy(); expect(runtimeState.status()).toBe("stopped"); expect(shutdownCalls).toEqual(["test.stop"]); expect(socket.updates).toEqual( expect.arrayContaining([ { commandId: "mcp-stuck", status: "accepted" }, - { commandId: "mcp-stuck", status: "cancelled" }, + { + commandId: "mcp-stuck", + error: { + code: "driver.external_tool_effect_unknown", + details: { + commandId: "mcp-stuck", + effectId: "test-effect-mcp-stuck", + requestId: "mcp-request-stuck", + runId: DRIVER_TEST_IDS.runId, + serverId: "mcp-linear", + toolName: "waitForever", + }, + message: expect.stringContaining("unknown outcome"), + retryable: false, + }, + status: "failed", + }, { commandId: "stop-1", status: "accepted" }, { commandId: "stop-1", status: "completed" }, ]), ); }); + test("does not return while a shutdown predicate has active MCP work", async () => { + const execution = Promise.withResolvers(); + const entered = Promise.withResolvers(); + let shuttingDown = false; + const command = { + argumentsJson: "{}", + commandId: "mcp-before-predicate-shutdown", + kind: "mcp.execute", + requestId: "mcp-before-predicate-shutdown-request", + runId: DRIVER_TEST_IDS.runId, + serverId: "mcp-linear", + toolCallId: "tool-before-predicate-shutdown", + toolName: "waitForShutdown", + } as const; + const socket = new FakeDriverRuntimeIo([command], DRIVER_TEST_IDS.runId); + const { dispatcher, logger } = createDispatcher({ + backend: createBackend(), + isShuttingDown: () => shuttingDown, + mcpExecute: async () => { + shuttingDown = true; + entered.resolve(); + return execution.promise; + }, + runtimeState: new DriverRuntimeStateMachine("ready"), + }); + + const run = dispatcher.run(socket, logger); + await entered.promise; + expect(await Promise.race([run.then(() => true), Bun.sleep(10).then(() => false)])).toBe(false); + + execution.resolve({ + outputText: "committed", + requestId: command.requestId, + serverId: command.serverId, + toolName: command.toolName, + }); + await run; + + expect(socket.updates.at(-1)).toMatchObject({ + commandId: command.commandId, + status: "completed", + }); + }); + test("does not report a command-loop failure when shutdown aborts an acknowledgement", async () => { const backend = createBackend(); const updateEntered = Promise.withResolvers(); const updateResult = Promise.withResolvers(); const runtimeState = new DriverRuntimeStateMachine("ready"); - const socket = new FakeDriverRuntimeIo([ - { - commandId: "cancel-during-shutdown", - kind: "turn.cancel", - reason: "test.cancel", - }, - ]); + const socket = new FakeDriverRuntimeIo( + [ + { + commandId: "cancel-during-shutdown", + kind: "turn.cancel", + reason: "test.cancel", + runId: DRIVER_TEST_IDS.runId, + }, + ], + DRIVER_TEST_IDS.runId, + ); socket.commandUpdate = async () => { updateEntered.resolve(); await updateResult.promise; @@ -433,7 +600,6 @@ describe("driver runtime boundary", () => { updateResult.reject(new Error("shutdown abort")); await expect(run).resolves.toBeUndefined(); - await logger.destroy(); expect(socket.failedRuns).toEqual([]); expect(socket.pushedEvents).toEqual([]); expect(shutdownCalls).toEqual([]); @@ -448,6 +614,7 @@ describe("driver runtime boundary", () => { commandId: `mcp-${index}`, kind: "mcp.execute" as const, requestId: `mcp-request-${index}`, + runId: DRIVER_TEST_IDS.runId, serverId: "mcp-linear", toolCallId: `tool-mcp-${index}`, toolName: "waitForever", @@ -458,26 +625,36 @@ describe("driver runtime boundary", () => { reason: "test.stop", }, ]; - const socket = new FakeDriverRuntimeIo(commands); + const socket = new FakeDriverRuntimeIo(commands, DRIVER_TEST_IDS.runId); let executeCalls = 0; + const releaseExecutions = Promise.withResolvers(); const { dispatcher, logger } = createDispatcher({ backend, isShuttingDown: () => socket.isDrained(), - mcpExecute: async (_command, signal) => { + mcpExecute: async (command) => { executeCalls += 1; - return await new Promise((_resolve, reject) => { - signal.addEventListener("abort", () => reject(signal.reason), { once: true }); - }); + await releaseExecutions.promise; + return { + outputText: "finished after stop", + requestId: command.requestId, + serverId: command.serverId, + toolName: command.toolName, + }; }, runtimeState, }); - await dispatcher.run(socket, logger); + const run = dispatcher.run(socket, logger); + await waitForUpdate( + socket, + (update) => update.commandId === "stop-after-mcp-limit" && update.status === "accepted", + ); + releaseExecutions.resolve(); + await run; await waitForUpdate( socket, (update) => update.commandId === "mcp-32" && update.status === "failed", ); - await logger.destroy(); expect(executeCalls).toBe(32); expect(runtimeState.status()).toBe("stopped"); @@ -513,7 +690,6 @@ describe("driver runtime boundary", () => { socket, (update) => update.commandId === "input-1" && update.status === "failed", ); - await logger.destroy(); expect(runtimeState.status()).toBe("failed"); expect(socket.failedRuns).toHaveLength(1); diff --git a/tests/driver-terminal-state.test.ts b/tests/driver-terminal-state.test.ts new file mode 100644 index 0000000..9cb0bad --- /dev/null +++ b/tests/driver-terminal-state.test.ts @@ -0,0 +1,236 @@ +import { describe, expect, test } from "bun:test"; + +import { + DriverTerminalStateMachine, + type DriverRunTerminalIdentity, +} from "../src/core/driver-terminal-state"; +import { DriverTurnCancelledError } from "../src/core/driver-turn-cancelled-error"; +import { DRIVER_TEST_IDS } from "./driver-boot-payload-fixture"; + +function terminal( + status: "cancelled" | "completed" | "failed", + sourceEventId = `terminal-${status}`, +): DriverRunTerminalIdentity { + return { + event: + status === "failed" + ? { + kind: "run.failed", + payload: { error: { code: "test.failed", message: "failed", retryable: false } }, + sourceEventId, + } + : status === "completed" + ? { + kind: "run.completed", + payload: { stopReason: "end_turn" }, + sourceEventId, + } + : { + kind: "run.cancelled", + payload: { stopReason: "cancelled" }, + sourceEventId, + }, + runId: DRIVER_TEST_IDS.runId, + sourceEventId, + status, + }; +} + +function receipt(identity: DriverRunTerminalIdentity) { + return { eventId: identity.sourceEventId, seq: 1, type: identity.event.kind }; +} + +describe("DriverTerminalStateMachine", () => { + test("linearizes cancellation before a completed terminal", () => { + const state = new DriverTerminalStateMachine(); + const ticket = state.beginRun(DRIVER_TEST_IDS.runId); + + expect(state.claimCancellation(ticket, "cancel first")).toBe("claimed"); + expect(ticket.signal.reason).toMatchObject({ resumeAllowed: true }); + expect(state.claimCancellation(ticket, "stop now", "shutdown")).toBe("already_claimed"); + expect(ticket.signal.reason).toBeInstanceOf(DriverTurnCancelledError); + expect(ticket.signal.reason).toMatchObject({ resumeAllowed: false }); + expect(ticket.signal.reason.resumeSignal.aborted).toBe(true); + expect(state.selectRunTerminal(ticket, terminal("completed"))).toBe("cancelled"); + }); + + test("linearizes terminal selection before cancellation and requires its exact ACK", () => { + const state = new DriverTerminalStateMachine(); + const ticket = state.beginRun(DRIVER_TEST_IDS.runId); + const selected = terminal("completed"); + + expect(state.selectRunTerminal(ticket, selected)).toBe("selected"); + expect(state.claimCancellation(ticket, "too late")).toBe("terminal_selected"); + expect(state.selectRunTerminal(ticket, structuredClone(selected))).toBe("pending"); + expect(() => + state.selectRunTerminal(ticket, { + ...selected, + event: { ...selected.event, payload: { stopReason: "other" } }, + }), + ).toThrow("conflicts"); + expect(() => state.ackRunTerminal(ticket, { ...receipt(selected), eventId: "wrong" })).toThrow( + "does not match", + ); + + state.ackRunTerminal(ticket, receipt(selected)); + expect(state.selectRunTerminal(ticket, selected)).toBe("acked"); + expect(state.snapshotRun()?.terminal?.phase).toBe("acked"); + }); + + test("fails closed when cancellation has no acknowledged run terminal", () => { + const state = new DriverTerminalStateMachine(); + const ticket = state.beginRun(DRIVER_TEST_IDS.runId); + state.claimCancellation(ticket, "cancelled"); + + const rejection = new Error("provider cleanup rejected"); + expect(state.settleInput(ticket, { error: rejection, status: "rejected" })).toEqual({ + failure: rejection, + status: "failed", + }); + expect(state.settleInput(ticket, { status: "resolved" })).toMatchObject({ + failure: { message: "Driver input settled without a run terminal." }, + status: "failed", + }); + }); + + test("keeps an acknowledged failed terminal authoritative over cancellation", () => { + const state = new DriverTerminalStateMachine(); + const ticket = state.beginRun(DRIVER_TEST_IDS.runId); + const failed = terminal("failed"); + state.claimCancellation(ticket, "cancelled"); + state.selectRunTerminal(ticket, failed); + state.ackRunTerminal(ticket, receipt(failed)); + + expect( + state.settleInput(ticket, { + error: new DriverTurnCancelledError("backend cancelled"), + status: "cancelled", + }), + ).toMatchObject({ + failure: { message: "Driver cancellation settled with a failed run terminal." }, + status: "failed", + }); + }); + + test.each([ + ["cancelled + resolved", "cancelled", { status: "resolved" }, "failed"], + [ + "cancelled + cancelled", + "cancelled", + { error: new DriverTurnCancelledError("backend cancelled"), status: "cancelled" }, + "cancelled", + ], + [ + "completed + cancelled", + "completed", + { error: new DriverTurnCancelledError("backend cancelled"), status: "cancelled" }, + "failed", + ], + ] as const)( + "settles acknowledged %s without a cancellation claim", + (_case, status, outcome, expected) => { + const state = new DriverTerminalStateMachine(); + const ticket = state.beginRun(DRIVER_TEST_IDS.runId); + const selected = terminal(status); + state.selectRunTerminal(ticket, selected); + state.ackRunTerminal(ticket, receipt(selected)); + + expect(state.settleInput(ticket, outcome).status).toBe(expected); + }, + ); + + test("requires a selected terminal to be acknowledged before normal release", () => { + const state = new DriverTerminalStateMachine(); + const ticket = state.beginRun(DRIVER_TEST_IDS.runId); + const selected = terminal("completed"); + state.selectRunTerminal(ticket, selected); + + expect(state.settleInput(ticket, { status: "resolved" }).status).toBe("failed"); + expect(() => state.releaseRun(ticket, "command_acked")).toThrow("acknowledged terminal"); + state.ackRunTerminal(ticket, receipt(selected)); + expect(state.settleInput(ticket, { status: "resolved" })).toEqual({ status: "resolved" }); + state.releaseRun(ticket, "command_acked"); + expect(() => state.claimCancellation(ticket, "stale")).toThrow("stale"); + }); + + test("cannot acknowledge a backend result without a durable run terminal", () => { + const state = new DriverTerminalStateMachine(); + const ticket = state.beginRun(DRIVER_TEST_IDS.runId); + + expect(state.settleInput(ticket, { status: "resolved" })).toMatchObject({ + failure: { message: "Driver input settled without a run terminal." }, + status: "failed", + }); + expect(() => state.releaseRun(ticket, "command_acked")).toThrow( + "without an acknowledged terminal", + ); + expect(() => state.releaseRun(ticket, "driver_failing")).not.toThrow(); + }); + + test("keeps one exact instance terminal from selection through acknowledgement", () => { + const state = new DriverTerminalStateMachine(); + const failure = { + error: { code: "driver.failed", details: {}, message: "failed", retryable: false }, + runId: DRIVER_TEST_IDS.runId, + status: "failed", + } as const; + + expect(state.selectInstanceTerminal(failure)).toBe("selected"); + expect(state.selectInstanceTerminal(structuredClone(failure))).toBe("pending"); + expect(() => + state.selectInstanceTerminal({ runId: DRIVER_TEST_IDS.runId, status: "completed" }), + ).toThrow("conflicts"); + state.ackInstanceTerminal(failure); + expect(state.selectInstanceTerminal(failure)).toBe("acked"); + expect(() => state.beginRun(DRIVER_TEST_IDS.secondRunId)).toThrow("instance terminal"); + }); + + test("freezes the exact run owner before a later run can replace it", () => { + const state = new DriverTerminalStateMachine(); + const first = state.beginRun(DRIVER_TEST_IDS.runId); + const selected = terminal("completed"); + state.selectRunTerminal(first, selected); + state.ackRunTerminal(first, receipt(selected)); + state.releaseRun(first, "command_acked"); + + expect(state.terminalRunId()).toBe(DRIVER_TEST_IDS.runId); + expect( + state.selectInstanceTerminal({ runId: state.terminalRunId()!, status: "completed" }), + ).toBe("selected"); + expect(state.terminalRunId(DRIVER_TEST_IDS.secondRunId)).toBe(DRIVER_TEST_IDS.runId); + }); + + test("retains the shutdown failure owner after its run is released", () => { + const state = new DriverTerminalStateMachine(); + const ticket = state.beginRun(DRIVER_TEST_IDS.runId); + const error = { code: "driver.failed", details: {}, message: "failed", retryable: false }; + + state.recordFailure(error); + state.releaseRun(ticket, "driver_failing"); + state.markCleanupCompleted(); + expect(state.shutdownSnapshot()).toEqual({ + cleanup: "completed", + failure: { error, runId: DRIVER_TEST_IDS.runId }, + }); + }); + + test("retains the last owned run without a provider terminal", () => { + const state = new DriverTerminalStateMachine(); + const ticket = state.beginRun(DRIVER_TEST_IDS.secondRunId); + + state.releaseRun(ticket, "driver_failing"); + + expect(state.terminalRunId(DRIVER_TEST_IDS.runId)).toBe(DRIVER_TEST_IDS.secondRunId); + }); + + test("remembers a handshake run without activating it", () => { + const state = new DriverTerminalStateMachine(); + + state.rememberOwnedRunId(DRIVER_TEST_IDS.secondRunId); + + expect(state.currentRunId()).toBeNull(); + expect(state.terminalRunId(DRIVER_TEST_IDS.runId)).toBe(DRIVER_TEST_IDS.secondRunId); + state.beginRun(DRIVER_TEST_IDS.secondRunId); + expect(() => state.rememberOwnedRunId(DRIVER_TEST_IDS.runId)).toThrow("active run"); + }); +}); diff --git a/tests/external-tool-effect-settlement.test.ts b/tests/external-tool-effect-settlement.test.ts new file mode 100644 index 0000000..003beb2 --- /dev/null +++ b/tests/external-tool-effect-settlement.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, test } from "bun:test"; +import { + createMcpUnknownEffectRunError, + createMcpUnsettledEffectRunError, +} from "@mosoo/agent-driver"; + +import { + createDurableMcpSucceededSettlement, + requireDurableMcpResultIdentity, +} from "../src/core/external-tool-effect-settlement"; +import { + measureRuntimeCommandJson, + RUNTIME_COMMAND_TERMINAL_PAYLOAD_MAX_UTF8_BYTES, + type McpExternalToolEffectSettlement, + type McpExternalToolExecutionResult, +} from "../src/runtime-command"; +import { DRIVER_TEST_IDS } from "./driver-boot-payload-fixture"; + +const RESULT = { + outputText: "", + requestId: "request-boundary", + serverId: "server-boundary", + toolName: "createIssue", +} as const; + +function executionAtSettlementSize(byteLength: number, unit = "x"): McpExternalToolExecutionResult { + const create = (outputText: string) => succeeded({ ...RESULT, outputText }); + const baseBytes = measureRuntimeCommandJson(create("")); + const unitBytes = measureRuntimeCommandJson(create(unit)) - baseBytes; + const outputBytes = byteLength - baseBytes; + + if (outputBytes < 0) { + throw new Error("Requested settlement size is smaller than its fixed fields."); + } + + return { + ...RESULT, + outputText: + unit.repeat(Math.floor(outputBytes / unitBytes)) + "x".repeat(outputBytes % unitBytes), + }; +} + +function succeeded(execution: McpExternalToolExecutionResult): McpExternalToolEffectSettlement { + const { providerReceiptJson, ...result } = execution; + return { + kind: "succeeded", + ...(providerReceiptJson === undefined ? {} : { providerReceiptJson }), + result, + }; +} + +describe("durable MCP succeeded settlement", () => { + test("defines the exact cross-process repair failures", () => { + const command = { + commandId: "command-repair", + requestId: RESULT.requestId, + runId: DRIVER_TEST_IDS.runId, + serverId: RESULT.serverId, + toolName: RESULT.toolName, + }; + + expect(createMcpUnknownEffectRunError(command, "effect-repair")).toEqual({ + code: "driver.external_tool_effect_unknown", + details: { ...command, effectId: "effect-repair" }, + message: + "External effect effect-repair for MCP tool createIssue has an unknown outcome and will not be replayed.", + retryable: false, + }); + expect(createMcpUnsettledEffectRunError(command, "effect-repair")).toEqual({ + code: "driver.command_failed.mcp.execute", + details: { commandId: command.commandId, commandKind: "mcp.execute" }, + message: + "External effect effect-repair for MCP tool createIssue requires server-side repair.", + retryable: false, + }); + }); + + test.each(["x", "界", "\0"])( + "preserves the exact %p byte limit and omits output at limit plus one", + (unit) => { + const exactExecution = executionAtSettlementSize( + RUNTIME_COMMAND_TERMINAL_PAYLOAD_MAX_UTF8_BYTES, + unit, + ); + const identity = requireDurableMcpResultIdentity(RESULT); + const exact = createDurableMcpSucceededSettlement(exactExecution, identity); + expect(measureRuntimeCommandJson(exact)).toBe( + RUNTIME_COMMAND_TERMINAL_PAYLOAD_MAX_UTF8_BYTES, + ); + expect(exact.result).toEqual(exactExecution); + + const oversized = createDurableMcpSucceededSettlement( + executionAtSettlementSize(RUNTIME_COMMAND_TERMINAL_PAYLOAD_MAX_UTF8_BYTES + 1, unit), + identity, + ); + expect(oversized).toEqual({ + kind: "succeeded", + result: { + ...RESULT, + isError: true, + outputText: + "MCP tool output was omitted because its durable settlement exceeded the 1044480-byte limit.", + }, + }); + expect(measureRuntimeCommandJson(oversized)).toBeLessThanOrEqual( + RUNTIME_COMMAND_TERMINAL_PAYLOAD_MAX_UTF8_BYTES, + ); + }, + ); + + test.each([ + ["escaped", "\0".repeat(Math.ceil(RUNTIME_COMMAND_TERMINAL_PAYLOAD_MAX_UTF8_BYTES / 6))], + ["Unicode", "界".repeat(Math.ceil(RUNTIME_COMMAND_TERMINAL_PAYLOAD_MAX_UTF8_BYTES / 3))], + ])("measures %s output after JSON escaping and UTF-8 encoding", (_label, outputText) => { + const execution = { ...RESULT, outputText }; + expect(outputText.length).toBeLessThan(RUNTIME_COMMAND_TERMINAL_PAYLOAD_MAX_UTF8_BYTES); + expect(measureRuntimeCommandJson(succeeded(execution))).toBeGreaterThan( + RUNTIME_COMMAND_TERMINAL_PAYLOAD_MAX_UTF8_BYTES, + ); + + const normalized = createDurableMcpSucceededSettlement( + execution, + requireDurableMcpResultIdentity(RESULT), + ); + expect(normalized.kind).toBe("succeeded"); + expect(normalized.result.isError).toBeTrue(); + expect(normalized.result.outputText).toContain("output was omitted"); + }); + + test("drops an oversized diagnostic receipt before changing provider output", () => { + const execution = { + ...RESULT, + outputText: "provider result", + providerReceiptJson: JSON.stringify({ + diagnostic: "r".repeat(RUNTIME_COMMAND_TERMINAL_PAYLOAD_MAX_UTF8_BYTES), + }), + }; + expect(measureRuntimeCommandJson(succeeded(execution))).toBeGreaterThan( + RUNTIME_COMMAND_TERMINAL_PAYLOAD_MAX_UTF8_BYTES, + ); + + expect( + createDurableMcpSucceededSettlement(execution, requireDurableMcpResultIdentity(RESULT)), + ).toEqual({ + kind: "succeeded", + result: { ...RESULT, outputText: "provider result" }, + }); + }); + + test("rejects an unpersistable result identity before execution", () => { + expect(() => + requireDurableMcpResultIdentity({ + ...RESULT, + requestId: "r".repeat(RUNTIME_COMMAND_TERMINAL_PAYLOAD_MAX_UTF8_BYTES), + }), + ).toThrow("MCP command identity exceeds the durable settlement byte limit."); + }); +}); diff --git a/tests/fixtures/driver/commands/input-start.json b/tests/fixtures/driver/commands/input-start.json index 5b5d68a..c754748 100644 --- a/tests/fixtures/driver/commands/input-start.json +++ b/tests/fixtures/driver/commands/input-start.json @@ -5,5 +5,5 @@ }, "kind": "input.start", "requestId": "request-1", - "runId": "run-1" + "runId": "01J00000000000000000000012" } diff --git a/tests/fixtures/driver/commands/mcp-execute.json b/tests/fixtures/driver/commands/mcp-execute.json index 8ce053d..12f0579 100644 --- a/tests/fixtures/driver/commands/mcp-execute.json +++ b/tests/fixtures/driver/commands/mcp-execute.json @@ -3,6 +3,7 @@ "commandId": "command-mcp-1", "kind": "mcp.execute", "requestId": "request-1", + "runId": "01J00000000000000000000012", "serverId": "server-1", "toolCallId": "tool-1", "toolName": "complete" diff --git a/tests/fixtures/driver/commands/permission-resolve.json b/tests/fixtures/driver/commands/permission-resolve.json index 73bd10e..49efaa7 100644 --- a/tests/fixtures/driver/commands/permission-resolve.json +++ b/tests/fixtures/driver/commands/permission-resolve.json @@ -2,5 +2,6 @@ "commandId": "command-permission-1", "decision": "allow_once", "kind": "permission.resolve", - "requestId": "permission-1" + "requestId": "permission-1", + "runId": "01J00000000000000000000012" } diff --git a/tests/fixtures/driver/commands/turn-cancel.json b/tests/fixtures/driver/commands/turn-cancel.json index e8deb47..993f17a 100644 --- a/tests/fixtures/driver/commands/turn-cancel.json +++ b/tests/fixtures/driver/commands/turn-cancel.json @@ -1,5 +1,6 @@ { "commandId": "command-cancel-1", "kind": "turn.cancel", - "reason": "user" + "reason": "user", + "runId": "01J00000000000000000000012" } diff --git a/tests/fixtures/driver/runtime-event-drafts/agent-tasks-replaced.json b/tests/fixtures/driver/runtime-event-drafts/agent-tasks-replaced.json new file mode 100644 index 0000000..f60465b --- /dev/null +++ b/tests/fixtures/driver/runtime-event-drafts/agent-tasks-replaced.json @@ -0,0 +1,14 @@ +{ + "delivery": "lossless", + "kind": "agent.tasks.replaced", + "payload": { + "tasks": [ + { + "taskId": "task-1", + "taskType": "local_agent", + "title": "Inspect the repository" + } + ] + }, + "visibility": "participant" +} diff --git a/tests/fixtures/driver/runtime-event-drafts/tool-call-deltas.json b/tests/fixtures/driver/runtime-event-drafts/tool-call-deltas.json new file mode 100644 index 0000000..b941ec1 --- /dev/null +++ b/tests/fixtures/driver/runtime-event-drafts/tool-call-deltas.json @@ -0,0 +1,13 @@ +{ + "delivery": "best_effort", + "kind": "tool.call.updated", + "payload": { + "future": { + "kept": true + }, + "rawInputDelta": "{\"path\":", + "rawOutputDelta": "chunk", + "status": "running", + "toolCallId": "tool-delta-1" + } +} diff --git a/tests/fixtures/driver/runtime-event-envelopes/agent-tasks-replaced.json b/tests/fixtures/driver/runtime-event-envelopes/agent-tasks-replaced.json new file mode 100644 index 0000000..ea285b6 --- /dev/null +++ b/tests/fixtures/driver/runtime-event-envelopes/agent-tasks-replaced.json @@ -0,0 +1,24 @@ +{ + "actor": "driver", + "delivery": "lossless", + "driverInstanceId": "01J0000000000000000000000F", + "id": "01J0000000000000000000000G", + "kind": "agent.tasks.replaced", + "occurredAt": "2026-05-26T00:00:00.000Z", + "origin": "driver", + "payload": { + "tasks": [ + { + "taskId": "task-1", + "taskType": "local_agent", + "title": "Inspect the repository" + } + ] + }, + "runId": "01J00000000000000000000012", + "runtimeId": "runtime-1", + "schemaVersion": "2026-08-29", + "sessionId": "01J00000000000000000000008", + "traceId": "trace-1", + "visibility": "participant" +} diff --git a/tests/fixtures/driver/runtime-event-envelopes/diagnostic-reported.json b/tests/fixtures/driver/runtime-event-envelopes/diagnostic-reported.json index 21c0abd..2a399c7 100644 --- a/tests/fixtures/driver/runtime-event-envelopes/diagnostic-reported.json +++ b/tests/fixtures/driver/runtime-event-envelopes/diagnostic-reported.json @@ -1,7 +1,7 @@ { "actor": "driver", "delivery": "lossless", - "driverInstanceId": "01J00000000000000000000008", + "driverInstanceId": "01J0000000000000000000000F", "id": "01J0000000000000000000000G", "kind": "diagnostic.reported", "occurredAt": "2026-05-26T00:00:00.000Z", @@ -11,10 +11,10 @@ "message": "Remote MCP execute failed.", "severity": "error" }, - "runId": "01J0000000000000000000000N", + "runId": "01J00000000000000000000012", "runtimeId": "runtime-1", - "schemaVersion": "2026-05-26", - "sessionId": "01J0000000000000000000000K", + "schemaVersion": "2026-08-29", + "sessionId": "01J00000000000000000000008", "traceId": "trace-1", "visibility": "owner_debug" } diff --git a/tests/fixtures/driver/runtime-event-envelopes/message-delta.json b/tests/fixtures/driver/runtime-event-envelopes/message-delta.json index d17ebea..08d3e51 100644 --- a/tests/fixtures/driver/runtime-event-envelopes/message-delta.json +++ b/tests/fixtures/driver/runtime-event-envelopes/message-delta.json @@ -1,7 +1,7 @@ { "actor": "driver", "delivery": "lossless", - "driverInstanceId": "01J00000000000000000000008", + "driverInstanceId": "01J0000000000000000000000F", "id": "01J0000000000000000000000G", "kind": "message.delta", "occurredAt": "2026-05-26T00:00:00.000Z", @@ -11,10 +11,10 @@ "messageId": "message-1", "role": "agent" }, - "runId": "01J0000000000000000000000N", + "runId": "01J00000000000000000000012", "runtimeId": "runtime-1", - "schemaVersion": "2026-05-26", - "sessionId": "01J0000000000000000000000K", + "schemaVersion": "2026-08-29", + "sessionId": "01J00000000000000000000008", "traceId": "trace-1", "visibility": "participant" } diff --git a/tests/fixtures/driver/runtime-event-envelopes/permission-requested.json b/tests/fixtures/driver/runtime-event-envelopes/permission-requested.json index cd4ba67..a145c1e 100644 --- a/tests/fixtures/driver/runtime-event-envelopes/permission-requested.json +++ b/tests/fixtures/driver/runtime-event-envelopes/permission-requested.json @@ -1,7 +1,7 @@ { "actor": "driver", "delivery": "lossless", - "driverInstanceId": "01J00000000000000000000008", + "driverInstanceId": "01J0000000000000000000000F", "id": "01J0000000000000000000000G", "kind": "permission.requested", "occurredAt": "2026-05-26T00:00:00.000Z", @@ -17,10 +17,10 @@ "toolCallId": "tool-1" } }, - "runId": "01J0000000000000000000000N", + "runId": "01J00000000000000000000012", "runtimeId": "runtime-1", - "schemaVersion": "2026-05-26", - "sessionId": "01J0000000000000000000000K", + "schemaVersion": "2026-08-29", + "sessionId": "01J00000000000000000000008", "traceId": "trace-1", "visibility": "participant" } diff --git a/tests/fixtures/driver/runtime-event-envelopes/run-started.json b/tests/fixtures/driver/runtime-event-envelopes/run-started.json index c774b4d..c43ee25 100644 --- a/tests/fixtures/driver/runtime-event-envelopes/run-started.json +++ b/tests/fixtures/driver/runtime-event-envelopes/run-started.json @@ -1,7 +1,7 @@ { "actor": "driver", "delivery": "lossless", - "driverInstanceId": "01J00000000000000000000008", + "driverInstanceId": "01J0000000000000000000000F", "id": "01J0000000000000000000000G", "kind": "run.started", "occurredAt": "2026-05-26T00:00:00.000Z", @@ -12,10 +12,10 @@ "startedAt": "2026-05-26T00:00:00.000Z", "status": "running" }, - "runId": "01J0000000000000000000000N", + "runId": "01J00000000000000000000012", "runtimeId": "runtime-1", - "schemaVersion": "2026-05-26", - "sessionId": "01J0000000000000000000000K", + "schemaVersion": "2026-08-29", + "sessionId": "01J00000000000000000000008", "traceId": "trace-1", "visibility": "participant" } diff --git a/tests/fixtures/driver/runtime-event-envelopes/tool-call-deltas.json b/tests/fixtures/driver/runtime-event-envelopes/tool-call-deltas.json new file mode 100644 index 0000000..16dbd92 --- /dev/null +++ b/tests/fixtures/driver/runtime-event-envelopes/tool-call-deltas.json @@ -0,0 +1,24 @@ +{ + "actor": "driver", + "delivery": "best_effort", + "driverInstanceId": "01J0000000000000000000000F", + "id": "01J0000000000000000000000G", + "kind": "tool.call.updated", + "occurredAt": "2026-05-26T00:00:00.000Z", + "origin": "driver", + "payload": { + "future": { + "kept": true + }, + "rawInputDelta": "{\"path\":", + "rawOutputDelta": "chunk", + "status": "running", + "toolCallId": "tool-delta-1" + }, + "runId": "01J00000000000000000000012", + "runtimeId": "runtime-1", + "schemaVersion": "2026-08-29", + "sessionId": "01J00000000000000000000008", + "traceId": "trace-1", + "visibility": "participant" +} diff --git a/tests/fixtures/driver/runtime-event-envelopes/tool-call-updated.json b/tests/fixtures/driver/runtime-event-envelopes/tool-call-updated.json index d0ba049..d17ae04 100644 --- a/tests/fixtures/driver/runtime-event-envelopes/tool-call-updated.json +++ b/tests/fixtures/driver/runtime-event-envelopes/tool-call-updated.json @@ -1,7 +1,7 @@ { "actor": "driver", "delivery": "lossless", - "driverInstanceId": "01J00000000000000000000008", + "driverInstanceId": "01J0000000000000000000000F", "id": "01J0000000000000000000000G", "kind": "tool.call.updated", "occurredAt": "2026-05-26T00:00:00.000Z", @@ -16,10 +16,10 @@ "title": "Run shell command", "toolCallId": "tool-1" }, - "runId": "01J0000000000000000000000N", + "runId": "01J00000000000000000000012", "runtimeId": "runtime-1", - "schemaVersion": "2026-05-26", - "sessionId": "01J0000000000000000000000K", + "schemaVersion": "2026-08-29", + "sessionId": "01J00000000000000000000008", "traceId": "trace-1", "visibility": "participant" } diff --git a/tests/fixtures/driver/runtime-event-envelopes/usage-updated.json b/tests/fixtures/driver/runtime-event-envelopes/usage-updated.json index cbcec36..09671fd 100644 --- a/tests/fixtures/driver/runtime-event-envelopes/usage-updated.json +++ b/tests/fixtures/driver/runtime-event-envelopes/usage-updated.json @@ -1,7 +1,7 @@ { "actor": "driver", "delivery": "lossless", - "driverInstanceId": "01J00000000000000000000008", + "driverInstanceId": "01J0000000000000000000000F", "id": "01J0000000000000000000000G", "kind": "usage.updated", "occurredAt": "2026-05-26T00:00:00.000Z", @@ -11,10 +11,10 @@ "model": "model-1", "outputTokens": 3 }, - "runId": "01J0000000000000000000000N", + "runId": "01J00000000000000000000012", "runtimeId": "runtime-1", - "schemaVersion": "2026-05-26", - "sessionId": "01J0000000000000000000000K", + "schemaVersion": "2026-08-29", + "sessionId": "01J00000000000000000000008", "traceId": "trace-1", "visibility": "participant" } diff --git a/tests/fixtures/providers/acp/cases/max-turn-failure.json b/tests/fixtures/providers/acp/cases/max-turn-failure.json index e6c97a0..c3510d1 100644 --- a/tests/fixtures/providers/acp/cases/max-turn-failure.json +++ b/tests/fixtures/providers/acp/cases/max-turn-failure.json @@ -1,8 +1,7 @@ { "begin": { "messageId": "message-1", - "runId": "run-1", - "sessionId": "session-1" + "runId": "run-1" }, "updates": [ { @@ -47,23 +46,27 @@ "parentMessageId": "assistant-message-1" }, "runId": "run-1", - "sourceEventId": "acp:session-1:run-1:tool-call:1" + "sourceEventId": "acp:run-1:tool-call:1" }, { - "kind": "message.completed", + "kind": "message.failed", "payload": { + "error": { + "code": "acp.max_turn_requests", + "message": "ACP prompt stopped with max_turn_requests.", + "retryable": false + }, "messageId": "assistant-message-1", - "role": "agent" + "role": "agent", + "stopReason": "max_turn_requests" }, "runId": "run-1" }, { "kind": "tool.call.updated", "payload": { - "kind": "tool", - "parentMessageId": "assistant-message-1", - "status": "completed", - "title": "Run command", + "error": "ACP prompt stopped with max_turn_requests.", + "status": "failed", "toolCallId": "tool-1" }, "runId": "run-1" @@ -71,15 +74,22 @@ { "kind": "item.completed", "payload": { + "error": "ACP prompt stopped with max_turn_requests.", "itemId": "tool-1", "itemType": "tool_call", - "status": "completed" + "status": "failed" }, "runId": "run-1" }, { - "kind": "run.completed", + "kind": "run.failed", "payload": { + "error": { + "code": "acp.max_turn_requests", + "message": "ACP prompt stopped with max_turn_requests.", + "retryable": false + }, + "recoverable": false, "stopReason": "max_turn_requests" }, "runId": "run-1" diff --git a/tests/fixtures/providers/acp/cases/permission-request.json b/tests/fixtures/providers/acp/cases/permission-request.json index 485f65f..02e0cd1 100644 --- a/tests/fixtures/providers/acp/cases/permission-request.json +++ b/tests/fixtures/providers/acp/cases/permission-request.json @@ -1,8 +1,7 @@ { "begin": { "messageId": "message-1", - "runId": "run-1", - "sessionId": "session-1" + "runId": "run-1" }, "permissionRequest": { "params": { @@ -33,6 +32,7 @@ "stopReason": "end_turn", "usage": null }, + "updates": [], "expectedEvents": [ { "kind": "message.started", @@ -63,36 +63,6 @@ }, "runId": "run-1" }, - { - "kind": "permission.requested", - "payload": { - "defaultOptionId": "allow", - "details": "{\"command\":\"pwd\"}", - "options": [ - { - "kind": "allow_once", - "name": "Allow once", - "optionId": "allow" - }, - { - "kind": "reject_once", - "name": "Reject once", - "optionId": "reject" - } - ], - "requestId": "rpc-42", - "targetItemId": "tool-1", - "title": "Run command", - "toolCall": { - "kind": "shell", - "rawInput": "{\"command\":\"pwd\"}", - "status": "running", - "title": "Run command", - "toolCallId": "tool-1" - } - }, - "runId": "run-1" - }, { "kind": "message.completed", "payload": { @@ -104,10 +74,7 @@ { "kind": "tool.call.updated", "payload": { - "kind": "shell", - "rawInput": "{\"command\":\"pwd\"}", "status": "completed", - "title": "Run command", "toolCallId": "tool-1" }, "runId": "run-1" diff --git a/tests/fixtures/providers/acp/cases/session-ready.json b/tests/fixtures/providers/acp/cases/session-ready.json index 9c27bd4..1c058cb 100644 --- a/tests/fixtures/providers/acp/cases/session-ready.json +++ b/tests/fixtures/providers/acp/cases/session-ready.json @@ -3,26 +3,24 @@ "mode": "created", "nativeSessionId": "native-session-1", "setup": { - "availableModes": [ - { - "id": "default", - "name": "Default" - } - ], - "capabilities": { - "fileSystem": true - }, "configOptions": [ { "id": "approval", "type": "select" } ], - "currentModeId": "default", - "currentModel": "sonnet", - "models": ["sonnet"] + "modes": { + "availableModes": [ + { + "id": "default", + "name": "Default" + } + ], + "currentModeId": "default" + } } }, + "updates": [], "expectedEvents": [ { "kind": "session.created", @@ -39,6 +37,7 @@ "visibility": "owner_debug" }, { + "delivery": "best_effort", "kind": "session.mode.updated", "payload": { "availableModes": [ @@ -51,13 +50,7 @@ } }, { - "kind": "session.models.updated", - "payload": { - "availableModels": ["sonnet"], - "currentModel": "sonnet" - } - }, - { + "delivery": "best_effort", "kind": "session.config.updated", "payload": { "options": [ @@ -67,15 +60,6 @@ } ] } - }, - { - "kind": "session.capabilities.updated", - "payload": { - "capabilities": { - "fileSystem": true - } - }, - "visibility": "owner_debug" } ] } diff --git a/tests/fixtures/providers/acp/cases/thought-and-unknown-update.json b/tests/fixtures/providers/acp/cases/thought-and-unknown-update.json index 0def5ba..c6f9640 100644 --- a/tests/fixtures/providers/acp/cases/thought-and-unknown-update.json +++ b/tests/fixtures/providers/acp/cases/thought-and-unknown-update.json @@ -1,8 +1,7 @@ { "begin": { "messageId": "message-1", - "runId": "run-1", - "sessionId": "session-1" + "runId": "run-1" }, "updates": [ { @@ -47,9 +46,10 @@ "thoughtId": "message-1:thought" }, "runId": "run-1", - "sourceEventId": "acp:session-1:run-1:agent-thought:1" + "sourceEventId": "acp:run-1:agent-thought:1" }, { + "delivery": "best_effort", "kind": "diagnostic.reported", "payload": { "message": "Unsupported ACP session update: mystery_update.", @@ -74,7 +74,8 @@ "payload": { "error": { "code": "acp.empty_turn", - "message": "ACP prompt ended without assistant output or tool activity." + "message": "ACP prompt ended without assistant output or tool activity.", + "retryable": true }, "recoverable": true, "stopReason": "end_turn" diff --git a/tests/fixtures/providers/acp/cases/turn-text-tool-usage.json b/tests/fixtures/providers/acp/cases/turn-text-tool-usage.json index 6413148..7f92b6f 100644 --- a/tests/fixtures/providers/acp/cases/turn-text-tool-usage.json +++ b/tests/fixtures/providers/acp/cases/turn-text-tool-usage.json @@ -1,8 +1,7 @@ { "begin": { "messageId": "message-1", - "runId": "run-1", - "sessionId": "session-1" + "runId": "run-1" }, "updates": [ { @@ -79,7 +78,7 @@ "role": "agent" }, "runId": "run-1", - "sourceEventId": "acp:session-1:run-1:agent-message:1" + "sourceEventId": "acp:run-1:agent-message:1" }, { "kind": "item.started", @@ -102,7 +101,7 @@ "parentMessageId": "assistant-message-1" }, "runId": "run-1", - "sourceEventId": "acp:session-1:run-1:tool-call:2" + "sourceEventId": "acp:run-1:tool-call:2" }, { "delivery": "lossless", @@ -117,20 +116,27 @@ "rawOutput": "{\"text\":\"/workspace\"}" }, "runId": "run-1", - "sourceEventId": "acp:session-1:run-1:tool-call-update:3" + "sourceEventId": "acp:run-1:tool-call-update:3" }, { "kind": "item.completed", "payload": { "itemId": "tool-1", "itemType": "tool_call", - "result": { - "text": "/workspace" - }, "status": "completed" }, "runId": "run-1" }, + { + "delivery": "lossless", + "kind": "message.added", + "payload": { + "content": "hello", + "messageId": "assistant-message-1", + "role": "agent" + }, + "runId": "run-1" + }, { "kind": "message.completed", "payload": { @@ -146,13 +152,6 @@ "cachedWriteTokens": 1, "inputTokens": 4, "outputTokens": 2, - "raw": { - "cachedReadTokens": 5, - "cachedWriteTokens": 1, - "inputTokens": 4, - "outputTokens": 2, - "totalTokens": 12 - }, "source": "prompt_response", "totalTokens": 12, "usageContract": "anthropic_bucketed" diff --git a/tests/fixtures/providers/claude-agent-sdk/cases/result-failure-diagnostic.json b/tests/fixtures/providers/claude-agent-sdk/cases/result-failure-diagnostic.json index b9a025b..5a41b29 100644 --- a/tests/fixtures/providers/claude-agent-sdk/cases/result-failure-diagnostic.json +++ b/tests/fixtures/providers/claude-agent-sdk/cases/result-failure-diagnostic.json @@ -7,18 +7,41 @@ "type": "rate_limit_event" }, { + "duration_api_ms": 8, + "duration_ms": 10, "errors": ["too many turns"], + "is_error": true, + "modelUsage": { + "claude-sonnet-4-6": { + "cacheCreationInputTokens": 0, + "cacheReadInputTokens": 0, + "contextWindow": 200000, + "costUSD": 0, + "inputTokens": 1, + "maxOutputTokens": 64000, + "outputTokens": 0, + "webSearchRequests": 0 + } + }, + "num_turns": 1, + "permission_denials": [], + "session_id": "claude-session-2", + "stop_reason": null, "subtype": "error_max_turns", - "total_cost_usd": null, + "terminal_reason": "max_turns", + "total_cost_usd": 0, "type": "result", "usage": { - "input_tokens": 1 - } + "input_tokens": 1, + "output_tokens": 0 + }, + "uuid": "result-1" } ], - "expectedNativeSessionIds": ["claude-session-2"], + "expectedNativeSessionIds": ["claude-session-2", "claude-session-2"], "expectedEvents": [ { + "delivery": "best_effort", "kind": "diagnostic.reported", "payload": { "message": "driver.claude.diagnostic", @@ -35,12 +58,12 @@ { "kind": "usage.updated", "payload": { - "cachedReadTokens": null, - "cachedWriteTokens": null, - "costAmount": null, - "costCurrency": null, + "cachedReadTokens": 0, + "cachedWriteTokens": 0, + "costAmount": 0, + "costCurrency": "USD", "inputTokens": 1, - "outputTokens": null, + "outputTokens": 0, "size": null, "source": "session_update", "thoughtTokens": null, @@ -49,12 +72,24 @@ "used": null } }, + { + "delivery": "lossless", + "kind": "agent.tasks.replaced", + "payload": { + "tasks": [] + }, + "visibility": "participant" + }, { "kind": "run.failed", "payload": { "error": { "code": "claude.error_max_turns", - "message": "too many turns" + "details": { + "terminalReason": "max_turns" + }, + "message": "too many turns", + "retryable": false }, "recoverable": false }, diff --git a/tests/fixtures/providers/claude-agent-sdk/cases/stream-text-thinking-tool-result.json b/tests/fixtures/providers/claude-agent-sdk/cases/stream-text-thinking-tool-result.json index cae662c..aaa7e00 100644 --- a/tests/fixtures/providers/claude-agent-sdk/cases/stream-text-thinking-tool-result.json +++ b/tests/fixtures/providers/claude-agent-sdk/cases/stream-text-thinking-tool-result.json @@ -25,9 +25,7 @@ "event": { "content_block": { "id": "tool-1", - "input": { - "command": "pwd" - }, + "input": {}, "name": "Bash", "type": "tool_use" }, @@ -39,7 +37,7 @@ { "event": { "delta": { - "partial_json": "{\"cwd\":\"/workspace\"}", + "partial_json": "{\"command\":\"pwd\",\"cwd\":\"/workspace\"}", "type": "input_json_delta" }, "index": 1, @@ -78,16 +76,38 @@ "type": "stream_event" }, { + "duration_api_ms": 8, + "duration_ms": 10, + "is_error": false, + "modelUsage": { + "claude-sonnet-4-6": { + "cacheCreationInputTokens": 0, + "cacheReadInputTokens": 0, + "contextWindow": 200000, + "costUSD": 0.02, + "inputTokens": 3, + "maxOutputTokens": 64000, + "outputTokens": 5, + "thinkingTokens": 2, + "webSearchRequests": 0 + } + }, + "num_turns": 1, + "permission_denials": [], "result": "hello", + "session_id": "claude-session-1", + "stop_reason": "end_turn", "subtype": "success", "total_cost_usd": 0.02, "type": "result", "usage": { "input_tokens": 3, "output_tokens": 5 - } + }, + "uuid": "result-1" } ], + "expectedNativeSessionIds": ["claude-session-1"], "expectedEvents": [ { "kind": "message.started", @@ -144,16 +164,7 @@ "delivery": "best_effort", "kind": "tool.call.updated", "payload": { - "rawInput": "{\"command\":\"pwd\"}", - "status": "running", - "toolCallId": "tool-1" - } - }, - { - "delivery": "best_effort", - "kind": "tool.call.updated", - "payload": { - "rawInput": "{\"cwd\":\"/workspace\"}", + "rawInputDelta": "{\"command\":\"pwd\",\"cwd\":\"/workspace\"}", "status": "running", "toolCallId": "tool-1" } @@ -163,7 +174,6 @@ "payload": { "content": "[{\"text\":\"/workspace\",\"type\":\"text\"}]", "messageId": "", - "rawOutput": "[{\"text\":\"/workspace\",\"type\":\"text\"}]", "status": "completed", "toolCallId": "tool-1" } @@ -183,30 +193,38 @@ "thoughtId": ":thought" } }, - { - "kind": "message.completed", - "payload": { - "messageId": "", - "role": "agent" - } - }, { "kind": "usage.updated", "payload": { - "cachedReadTokens": null, - "cachedWriteTokens": null, + "cachedReadTokens": 0, + "cachedWriteTokens": 0, "costAmount": 0.02, "costCurrency": "USD", "inputTokens": 3, "outputTokens": 5, "size": null, "source": "session_update", - "thoughtTokens": null, + "thoughtTokens": 2, "totalTokens": 8, "usageContract": "anthropic_bucketed", "used": null } }, + { + "kind": "message.completed", + "payload": { + "messageId": "", + "role": "agent" + } + }, + { + "delivery": "lossless", + "kind": "agent.tasks.replaced", + "payload": { + "tasks": [] + }, + "visibility": "participant" + }, { "kind": "run.completed", "payload": { diff --git a/tests/fixtures/providers/claude-agent-sdk/cases/system-files-and-session.json b/tests/fixtures/providers/claude-agent-sdk/cases/system-files-and-session.json index aad64ac..b85d684 100644 --- a/tests/fixtures/providers/claude-agent-sdk/cases/system-files-and-session.json +++ b/tests/fixtures/providers/claude-agent-sdk/cases/system-files-and-session.json @@ -28,7 +28,7 @@ "type": "system" } ], - "expectedNativeSessionIds": ["claude-session-1", "claude-session-1"], + "expectedNativeSessionIds": ["claude-session-1"], "expectedEvents": [ { "kind": "session.info.updated", @@ -51,9 +51,11 @@ } }, { + "delivery": "best_effort", "kind": "diagnostic.reported", "payload": { - "failed": ["src/b.ts"], + "failedCount": 1, + "failedUtf8Bytes": 12, "message": "Claude file persistence failed.", "severity": "warn" }, diff --git a/tests/fixtures/providers/claude-agent-sdk/cases/unknown-message-diagnostic.json b/tests/fixtures/providers/claude-agent-sdk/cases/unknown-message-diagnostic.json new file mode 100644 index 0000000..060533f --- /dev/null +++ b/tests/fixtures/providers/claude-agent-sdk/cases/unknown-message-diagnostic.json @@ -0,0 +1,24 @@ +{ + "runId": "run-1", + "messages": [ + { + "type": "future_event", + "value": 123 + } + ], + "expectedEvents": [ + { + "delivery": "best_effort", + "kind": "diagnostic.reported", + "payload": { + "message": "driver.claude.message.unknown", + "raw": { + "type": "future_event", + "value": 123 + }, + "severity": "info" + }, + "visibility": "owner_debug" + } + ] +} diff --git a/tests/fixtures/providers/claude-agent-sdk/cases/unknown-message-ignored.json b/tests/fixtures/providers/claude-agent-sdk/cases/unknown-message-ignored.json deleted file mode 100644 index e68643c..0000000 --- a/tests/fixtures/providers/claude-agent-sdk/cases/unknown-message-ignored.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "runId": "run-1", - "messages": [ - { - "type": "future_event", - "value": 123 - } - ], - "expectedEvents": [] -} diff --git a/tests/fixtures/providers/openai-app-server/cases/agent-message-completed.json b/tests/fixtures/providers/openai-app-server/cases/agent-message-completed.json index e80d0ec..70d9f6a 100644 --- a/tests/fixtures/providers/openai-app-server/cases/agent-message-completed.json +++ b/tests/fixtures/providers/openai-app-server/cases/agent-message-completed.json @@ -3,6 +3,7 @@ { "method": "item/completed", "params": { + "completedAtMs": 1700000003000, "item": { "id": "message-1", "text": "pong", @@ -19,16 +20,8 @@ "payload": { "messageId": "", "role": "agent" - } - }, - { - "delivery": "best_effort", - "kind": "message.delta", - "payload": { - "contentDelta": "pong", - "messageId": "", - "role": "agent" - } + }, + "sourceEventId": "openai.message.started:" }, { "delivery": "lossless", @@ -37,14 +30,16 @@ "content": "pong", "messageId": "", "role": "agent" - } + }, + "sourceEventId": "openai.item.completed:sid1_BN1wwFn-vJ2yK8Ax93DBy3pMPeQlPs_fMiD3xICce38:0" }, { "kind": "message.completed", "payload": { "messageId": "", "role": "agent" - } + }, + "sourceEventId": "openai.derived:sid1_BARJThUDVPe3fUNTSKJ0PBt0smINLtwyX9wLH2fXPTk" } ] } diff --git a/tests/fixtures/providers/openai-app-server/cases/command-output-stream.json b/tests/fixtures/providers/openai-app-server/cases/command-output-stream.json index f6e4a64..e0f3d17 100644 --- a/tests/fixtures/providers/openai-app-server/cases/command-output-stream.json +++ b/tests/fixtures/providers/openai-app-server/cases/command-output-stream.json @@ -4,9 +4,14 @@ "method": "item/started", "params": { "item": { + "command": "printf 'hello world'", + "commandActions": [], + "cwd": "/workspace", "id": "cmd-1", + "status": "inProgress", "type": "commandExecution" }, + "startedAtMs": 1700000003000, "threadId": "thread-1", "turnId": "turn-1" } @@ -36,7 +41,8 @@ "payload": { "messageId": "", "role": "agent" - } + }, + "sourceEventId": "openai.message.started:" }, { "kind": "item.started", @@ -45,7 +51,8 @@ "itemType": "tool_call", "parentMessageId": "", "title": "Shell" - } + }, + "sourceEventId": "openai.item.started:sid1_diOiKEZok2HcGrWKr19uxht3PPOLSj_ZVMcjnOYcZYo" }, { "kind": "tool.call.updated", @@ -55,15 +62,15 @@ "status": "running", "title": "Shell", "toolCallId": "cmd-1" - } + }, + "sourceEventId": "openai.tool.started:sid1_RWKjM-yo_eO1O7FFfDWAa7yqJPtBWMGIYPSniQQkjHs" }, { "delivery": "best_effort", "kind": "tool.call.updated", "payload": { - "content": "hello", "messageId": "", - "rawOutput": "hello", + "rawOutputDelta": "hello", "status": "running", "toolCallId": "cmd-1" } @@ -72,9 +79,8 @@ "delivery": "best_effort", "kind": "tool.call.updated", "payload": { - "content": " world", "messageId": "", - "rawOutput": " world", + "rawOutputDelta": " world", "status": "running", "toolCallId": "cmd-1" } diff --git a/tests/fixtures/providers/openai-app-server/cases/error-before-tracked-turn.json b/tests/fixtures/providers/openai-app-server/cases/error-before-tracked-turn.json index fafdeea..2c83941 100644 --- a/tests/fixtures/providers/openai-app-server/cases/error-before-tracked-turn.json +++ b/tests/fixtures/providers/openai-app-server/cases/error-before-tracked-turn.json @@ -5,6 +5,7 @@ "params": { "error": { "additionalDetails": "HTTP 502 from upstream.", + "codexErrorInfo": { "responseStreamDisconnected": { "httpStatusCode": 502 } }, "message": "Response stream disconnected." }, "threadId": "thread-1", @@ -19,9 +20,11 @@ "turn": { "error": { "additionalDetails": "HTTP 502 from upstream.", + "codexErrorInfo": { "responseStreamDisconnected": { "httpStatusCode": 502 } }, "message": "Response stream disconnected." }, "id": "turn-1", + "items": [], "status": "failed" } } @@ -44,6 +47,13 @@ }, "sourceEventId": "openai.turn.started:turn-1" }, + { + "delivery": "lossless", + "kind": "agent.tasks.replaced", + "payload": { "tasks": [] }, + "sourceEventId": "openai.derived:sid1_mw1rK-IfsZaez6IOgvoBLwbJVWO3Ou-eANcCdudVblc", + "visibility": "participant" + }, { "kind": "run.failed", "native": { @@ -54,9 +64,15 @@ "payload": { "error": { "code": "openai.turn_failed", - "message": "Response stream disconnected.\nHTTP 502 from upstream." + "details": { + "additionalDetails": "HTTP 502 from upstream.", + "codexErrorInfo": "responseStreamDisconnected", + "httpStatusCode": 502 + }, + "message": "Response stream disconnected.\nHTTP 502 from upstream.", + "retryable": true }, - "recoverable": false + "recoverable": true }, "sourceEventId": "openai.turn.failed:turn-1" } diff --git a/tests/fixtures/providers/openai-app-server/cases/reasoning-empty-summary.json b/tests/fixtures/providers/openai-app-server/cases/reasoning-empty-summary.json index e9acdb9..3ee00ba 100644 --- a/tests/fixtures/providers/openai-app-server/cases/reasoning-empty-summary.json +++ b/tests/fixtures/providers/openai-app-server/cases/reasoning-empty-summary.json @@ -3,6 +3,7 @@ { "method": "item/completed", "params": { + "completedAtMs": 1700000003000, "item": { "id": "reasoning-empty", "summary": [], diff --git a/tests/fixtures/providers/openai-app-server/cases/root-with-parallel-child-turns.json b/tests/fixtures/providers/openai-app-server/cases/root-with-parallel-child-turns.json index 98c99f7..0a07e74 100644 --- a/tests/fixtures/providers/openai-app-server/cases/root-with-parallel-child-turns.json +++ b/tests/fixtures/providers/openai-app-server/cases/root-with-parallel-child-turns.json @@ -8,7 +8,7 @@ "method": "turn/started", "params": { "threadId": "thread-1", - "turn": { "id": "turn-root", "status": "inProgress" } + "turn": { "id": "turn-root", "items": [], "status": "inProgress" } } }, { @@ -26,6 +26,7 @@ "tool": "spawnAgent", "type": "collabAgentToolCall" }, + "startedAtMs": 1700000003000, "threadId": "thread-1", "turnId": "turn-root" } @@ -34,12 +35,13 @@ "method": "turn/started", "params": { "threadId": "thread-child-a", - "turn": { "id": "turn-child-a", "status": "inProgress" } + "turn": { "id": "turn-child-a", "items": [], "status": "inProgress" } } }, { "method": "item/completed", "params": { + "completedAtMs": 1700000003000, "item": { "id": "message-child-a", "text": "CHILD_A", "type": "agentMessage" }, "threadId": "thread-child-a", "turnId": "turn-child-a" @@ -61,12 +63,13 @@ "method": "turn/started", "params": { "threadId": "thread-child-b", - "turn": { "id": "turn-child-b", "status": "inProgress" } + "turn": { "id": "turn-child-b", "items": [], "status": "inProgress" } } }, { "method": "item/completed", "params": { + "completedAtMs": 1700000003000, "item": { "id": "message-child-b", "text": "CHILD_B", "type": "agentMessage" }, "threadId": "thread-child-b", "turnId": "turn-child-b" @@ -87,6 +90,7 @@ { "method": "item/completed", "params": { + "completedAtMs": 1700000003000, "item": { "agentsStates": { "thread-child-a": { "message": "CHILD_A", "status": "completed" }, @@ -109,6 +113,7 @@ { "method": "item/completed", "params": { + "completedAtMs": 1700000003000, "item": { "id": "message-parent", "text": "PARENT_OK: CHILD_A + CHILD_B", @@ -154,6 +159,7 @@ { "method": "item/completed", "params": { + "completedAtMs": 1700000003000, "item": { "id": "message-child-late", "text": "LATE_CHILD", "type": "agentMessage" }, "threadId": "thread-child-b", "turnId": "turn-child-b" @@ -174,36 +180,50 @@ }, { "kind": "message.started", - "payload": { "messageId": "", "role": "agent" } + "payload": { "messageId": "", "role": "agent" }, + "sourceEventId": "openai.message.started:" }, { "kind": "item.started", "payload": { "itemId": "agent-call-1", "itemType": "tool_call", - "parentMessageId": "", - "title": "spawnAgent" - } + "parentMessageId": "", + "title": "Spawn agent" + }, + "sourceEventId": "openai.item.started:sid1_ZbP7VMPfxU99NO5_Ssl7dpRsfsJCg5iDDTS3opi8UyQ" }, { "kind": "tool.call.updated", "payload": { "kind": "tool", - "parentMessageId": "", + "parentMessageId": "", "status": "running", - "title": "spawnAgent", + "title": "Spawn agent", "toolCallId": "agent-call-1" - } + }, + "sourceEventId": "openai.tool.started:sid1_x5P0XypwtBPbQ87N6EdslwKLXsWaZ2Wsw5cgxVjLkKQ" }, { "kind": "tool.call.updated", "payload": { - "content": "{\"thread-child-a\":{\"message\":\"CHILD_A\",\"status\":\"completed\"},\"thread-child-b\":{\"message\":\"CHILD_B\",\"status\":\"completed\"}}", - "messageId": "", - "rawOutput": "{\"thread-child-a\":{\"message\":\"CHILD_A\",\"status\":\"completed\"},\"thread-child-b\":{\"message\":\"CHILD_B\",\"status\":\"completed\"}}", "status": "completed", + "structuredOutput": { + "agentsStates": { + "thread-child-a": { "message": "CHILD_A", "status": "completed" }, + "thread-child-b": { "message": "CHILD_B", "status": "completed" } + }, + "model": "gpt-5.5", + "prompt": "Run A and B in parallel", + "reasoningEffort": "high", + "receiverThreadIds": ["thread-child-a", "thread-child-b"], + "senderThreadId": "thread-1", + "status": "completed", + "tool": "spawnAgent" + }, "toolCallId": "agent-call-1" - } + }, + "sourceEventId": "openai.item.completed:sid1_C8cwMXkrQNCQEgerqw8tRi-Enh7ZKfj_DA9i9vH-rhU:0" }, { "kind": "item.completed", @@ -211,35 +231,48 @@ "itemId": "agent-call-1", "itemType": "tool_call", "status": "completed" - } + }, + "sourceEventId": "openai.derived:sid1_5GlNQBVpErPuMjUZcwLG6BitzcN1MSXZRXotq6VljPc" }, { - "delivery": "best_effort", - "kind": "message.delta", - "payload": { - "contentDelta": "PARENT_OK: CHILD_A + CHILD_B", - "messageId": "", - "role": "agent" - } + "kind": "message.started", + "payload": { "messageId": "", "role": "agent" }, + "sourceEventId": "openai.message.started:" }, { "delivery": "lossless", "kind": "message.added", "payload": { "content": "PARENT_OK: CHILD_A + CHILD_B", - "messageId": "", + "messageId": "", "role": "agent" - } + }, + "sourceEventId": "openai.item.completed:sid1_FjjaUjXqCeULq2o7LJ4Ia93QFJqz2B5kI5iVqKiW9uI:0" }, { "kind": "message.completed", - "payload": { "messageId": "", "role": "agent" } + "payload": { "messageId": "", "role": "agent" }, + "sourceEventId": "openai.derived:sid1_hw9aor_2uReuaZl1eJIaXyugelmcCDTogRNGzqyELbQ" }, { "kind": "runtime.resume.updated", "payload": { "resumePointer": "thread-1", "threadId": "thread-1" }, "visibility": "owner_debug" }, + { + "delivery": "lossless", + "kind": "agent.tasks.replaced", + "payload": { "tasks": [] }, + "runId": "", + "sourceEventId": "openai.derived:sid1_r5WSt7u4mCiLVpw75XQhDXCCtYBUw-StSBWHAbAdolk", + "visibility": "participant" + }, + { + "kind": "message.completed", + "payload": { "messageId": "", "role": "agent" }, + "runId": "", + "sourceEventId": "openai.derived:sid1_urrGMu3mQ34ngjJJU5uUqSjpIbhmCWsyIKYxjFeCmf4" + }, { "kind": "run.completed", "native": { @@ -248,8 +281,7 @@ "turnId": "turn-root" }, "payload": { - "finalMessageId": "", - "finalMessageText": "PARENT_OK: CHILD_A + CHILD_B", + "finalMessageId": "", "stopReason": "end_turn" }, "runId": "", diff --git a/tests/fixtures/providers/openai-app-server/cases/turn-completed-with-final-agent-message.json b/tests/fixtures/providers/openai-app-server/cases/turn-completed-with-final-agent-message.json index af2e0d8..a51b508 100644 --- a/tests/fixtures/providers/openai-app-server/cases/turn-completed-with-final-agent-message.json +++ b/tests/fixtures/providers/openai-app-server/cases/turn-completed-with-final-agent-message.json @@ -40,16 +40,8 @@ "payload": { "messageId": "", "role": "agent" - } - }, - { - "delivery": "best_effort", - "kind": "message.delta", - "payload": { - "contentDelta": "pong", - "messageId": "", - "role": "agent" - } + }, + "sourceEventId": "openai.message.started:" }, { "delivery": "lossless", @@ -58,14 +50,16 @@ "content": "pong", "messageId": "", "role": "agent" - } + }, + "sourceEventId": "openai.item.completed:sid1_BN1wwFn-vJ2yK8Ax93DBy3pMPeQlPs_fMiD3xICce38:0" }, { "kind": "message.completed", "payload": { "messageId": "", "role": "agent" - } + }, + "sourceEventId": "openai.derived:sid1_BARJThUDVPe3fUNTSKJ0PBt0smINLtwyX9wLH2fXPTk" }, { "kind": "runtime.resume.updated", @@ -75,6 +69,13 @@ }, "visibility": "owner_debug" }, + { + "delivery": "lossless", + "kind": "agent.tasks.replaced", + "payload": { "tasks": [] }, + "sourceEventId": "openai.derived:sid1_65gCZZFCtIH2m10-eUUIasKg5wmDWEVoaKGA1D26hMk", + "visibility": "participant" + }, { "kind": "run.completed", "native": { @@ -84,7 +85,6 @@ }, "payload": { "finalMessageId": "", - "finalMessageText": "pong", "stopReason": "end_turn" }, "sourceEventId": "openai.turn.completed:turn-1" diff --git a/tests/mcp-server-key.test.ts b/tests/mcp-server-key.test.ts new file mode 100644 index 0000000..2f39b28 --- /dev/null +++ b/tests/mcp-server-key.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, test } from "bun:test"; + +import type { DriverBootMcpServer } from "../src/protocol/boot"; +import type { CredentialId, McpServerId } from "../src/protocol/boot/host-ids"; +import { toMcpServerKey } from "../src/runtimes/mcp/server-key"; + +function server(name: string): DriverBootMcpServer { + return { + authorizationState: "active", + authType: "bearer", + credentialId: "01J00000000000000000000000" as CredentialId, + credentialScope: "mcp", + credentialStatus: "active", + name, + proxyGrantId: "grant", + proxyUrl: "https://mcp.test", + serverId: "01J00000000000000000000001" as McpServerId, + }; +} + +describe("toMcpServerKey", () => { + test.each(["__proto__", "constructor", "prototype"])( + "uses the stable server id for unsafe object key %s", + (name) => { + expect(toMcpServerKey(server(name), new Set())).toBe("01J00000000000000000000001"); + }, + ); +}); diff --git a/tests/openai-app-server-agent-task-events.test.ts b/tests/openai-app-server-agent-task-events.test.ts new file mode 100644 index 0000000..0e984b0 --- /dev/null +++ b/tests/openai-app-server-agent-task-events.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, test } from "bun:test"; + +import type { DriverEventInput } from "../src/protocol/events"; +import { toDriverEventEnvelopes } from "../src/infrastructure/runtime/driver-event-envelope"; +import { + OpenAiAgentTaskState, + openAiAgentTasksClosedEvent, + type OpenAiSubAgentActivity, +} from "../src/runtimes/openai/app-server-agent-task-events"; +import { DRIVER_TEST_IDS, driverBootPayload } from "./driver-boot-payload-fixture"; + +function apply(state: OpenAiAgentTaskState, activity: OpenAiSubAgentActivity) { + const update = state.prepare(activity); + + for (const event of update.events) { + toDriverEventEnvelopes(driverBootPayload, event, DRIVER_TEST_IDS.runId); + } + update.commit(); + return update.events; +} + +function activity( + agentId: string, + kind: OpenAiSubAgentActivity["kind"], + agentPath = `/root/${agentId}`, +): OpenAiSubAgentActivity { + return { agentId, agentPath, kind }; +} + +function tasks(events: readonly DriverEventInput[]) { + const snapshot = events.find((event) => event.kind === "agent.tasks.replaced"); + + if (snapshot === undefined) { + throw new Error("Expected an OpenAI agent task snapshot."); + } + + return (snapshot.payload as { tasks: unknown[] }).tasks; +} + +describe("OpenAI app-server agent task snapshots", () => { + test("projects interleaved completion-only activity as full active-set replacements", () => { + const state = new OpenAiAgentTaskState(); + + expect(tasks(apply(state, activity("agent-1", "started")))).toEqual([ + { taskId: "agent-1", taskType: "openai_subagent", title: "/root/agent-1" }, + ]); + expect(tasks(apply(state, activity("agent-2", "interacted")))).toHaveLength(2); + expect(tasks(apply(state, activity("agent-1", "started")))).toHaveLength(2); + expect(tasks(apply(state, activity("agent-1", "completed")))).toEqual([ + { taskId: "agent-2", taskType: "openai_subagent", title: "/root/agent-2" }, + ]); + expect(tasks(apply(state, activity("agent-1", "started")))).toEqual([ + { taskId: "agent-2", taskType: "openai_subagent", title: "/root/agent-2" }, + ]); + expect(tasks(apply(state, activity("agent-1", "interacted")))).toHaveLength(2); + expect(tasks(apply(state, activity("agent-1", "completed")))).toEqual([ + { taskId: "agent-2", taskType: "openai_subagent", title: "/root/agent-2" }, + ]); + expect(tasks(apply(state, activity("agent-2", "interrupted")))).toEqual([]); + + state.reset(); + expect(tasks(apply(state, activity("agent-1", "interacted")))).toHaveLength(1); + }); + + test("keeps provider data bounded without truncating active membership", () => { + const state = new OpenAiAgentTaskState(); + const privateMarker = `private-prompt-${"x".repeat(4_096)}`; + let events: DriverEventInput[] = []; + + for (let index = 0; index < 256; index += 1) { + const agentId = `agent-${String(index)}`; + events = apply(state, activity(agentId, "started", `${privateMarker}-${String(index)}`)); + } + + expect(events).toContainEqual( + expect.objectContaining({ + kind: "diagnostic.reported", + payload: expect.objectContaining({ code: "openai.agent_tasks_snapshot_too_large" }), + }), + ); + expect(tasks(events)).toEqual( + Array.from({ length: 256 }, (_, index) => ({ taskId: `agent-${String(index)}` })), + ); + expect(JSON.stringify(events)).not.toContain(privateMarker); + }); + + test("recovers an authoritative snapshot after the active set returns below 257", () => { + const state = new OpenAiAgentTaskState(); + + for (let index = 0; index < 256; index += 1) { + apply(state, activity(`agent-${String(index)}`, "started")); + } + + const overflow = apply(state, activity("agent-256", "started")); + expect(overflow).toEqual([ + expect.objectContaining({ + kind: "diagnostic.reported", + payload: expect.objectContaining({ code: "openai.visible_agent_tasks_too_many" }), + }), + ]); + + const recovered = tasks(apply(state, activity("agent-0", "completed"))); + expect(recovered).toHaveLength(256); + expect(recovered).not.toContainEqual(expect.objectContaining({ taskId: "agent-0" })); + expect(recovered).toContainEqual(expect.objectContaining({ taskId: "agent-256" })); + }); + + test("fails at the bounded active activity-state limit", () => { + const state = new OpenAiAgentTaskState(); + + for (let index = 0; index < 1_024; index += 1) { + apply(state, activity(`agent-${String(index)}`, "started")); + } + + expect(() => state.prepare(activity("agent-1024", "started", "/root/overflow"))).toThrow( + "exceeds 1024", + ); + }); + + test("bounds completed replay protection without letting late starts revive", () => { + const state = new OpenAiAgentTaskState(); + + for (let index = 0; index < 1_024; index += 1) { + apply(state, activity(`closed-${String(index)}`, "completed")); + } + for (let index = 0; index < 1_024; index += 1) { + expect(tasks(apply(state, activity(`closed-${String(index)}`, "started")))).toEqual([]); + } + + expect(() => state.prepare(activity("closed-1024", "completed"))).toThrow( + "closed sub-agent count exceeds 1024", + ); + expect(tasks(apply(state, activity("closed-0", "started")))).toEqual([]); + }); + + test("uses deterministic bounded IDs and an authoritative empty terminal snapshot", () => { + const state = new OpenAiAgentTaskState(); + const longId = "agent".repeat(100); + const first = tasks(apply(state, activity(longId, "started", `${"a".repeat(4_095)}😀`))); + + state.reset(); + const replay = tasks(apply(state, activity(longId, "started", "/root/replayed"))); + expect(first[0]).toMatchObject({ taskId: expect.stringMatching(/^rid1_[A-Za-z0-9_-]{43}$/) }); + expect(replay[0]).toMatchObject({ taskId: (first[0] as { taskId: string }).taskId }); + expect((first[0] as { title: string }).title).toHaveLength(4_095); + expect(openAiAgentTasksClosedEvent()).toEqual({ + delivery: "lossless", + kind: "agent.tasks.replaced", + payload: { tasks: [] }, + visibility: "participant", + }); + }); +}); diff --git a/tests/openai-app-server-auth-state.test.ts b/tests/openai-app-server-auth-state.test.ts index a56fc04..27afa82 100644 --- a/tests/openai-app-server-auth-state.test.ts +++ b/tests/openai-app-server-auth-state.test.ts @@ -1,19 +1,60 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { + lstat, + mkdir, + mkdtemp, + readFile, + readlink, + rename, + rm, + symlink, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { createDriverId } from "../src/protocol/id"; +import type { DriverInstanceId } from "../src/protocol/id"; import { - materializeOpenAiApiKeyAuthState, + cleanupOpenAiRuntimeHome, + createOpenAiRuntimeHome, + materializeOpenAiAuthState, materializeOpenAiModelProviderConfig, } from "../src/runtimes/openai/auth-state"; -let runtimeHomes: string[] = []; +type RuntimeHomeState = Awaited>; + +const temporaryDirectories: string[] = []; +const runtimeHomeStates: RuntimeHomeState[] = []; +const PERSISTENT_DIRECTORIES = [ + "sessions", + "archived_sessions", + "memories", + "memories_extensions", +] as const; + +function createDriverInstanceId(): DriverInstanceId { + return createDriverId() as DriverInstanceId; +} -async function createRuntimeHome(): Promise { - const runtimeHome = await mkdtemp(join(tmpdir(), "mosoo-openai-auth-")); - runtimeHomes.push(runtimeHome); - return runtimeHome; +async function createTemporaryDirectory(prefix = "mosoo-openai-auth-"): Promise { + const directory = await mkdtemp(join(tmpdir(), prefix)); + temporaryDirectories.push(directory); + return directory; +} + +async function createRuntimeHome( + persistentRuntimeHome?: string, + driverGeneration = 0, +): Promise { + const persistentHome = persistentRuntimeHome ?? (await createTemporaryDirectory()); + const state = await createOpenAiRuntimeHome({ + driverGeneration, + driverInstanceId: createDriverInstanceId(), + persistentRuntimeHome: persistentHome, + }); + runtimeHomeStates.push(state); + return state; } function requireRecord(value: unknown, label: string): Record { @@ -36,36 +77,292 @@ function expectDisabledRuntimeFeatures(config: Record): void { }); } +async function runCrossProcessAuthChild( + authState: typeof import("../src/runtimes/openai/auth-state"), +): Promise { + const root = process.env["RACE_ROOT"]!; + const role = process.env["RACE_ROLE"]!; + const driverInstanceId = process.env["DRIVER_INSTANCE_ID"]! as DriverInstanceId; + let state: Awaited> | null = null; + const marker = (name: string) => `${root}/${name}`; + const waitFor = async (name: string) => { + const deadline = Date.now() + 5_000; + while (!(await Bun.file(marker(name)).exists())) { + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for cross-process auth marker ${name}.`); + } + await Bun.sleep(2); + } + }; + + try { + if (role === "old") { + state = await authState.createOpenAiRuntimeHome({ + driverGeneration: 1, + driverInstanceId, + persistentRuntimeHome: root, + }); + await Bun.write(marker("old-home.json"), JSON.stringify(state)); + await waitFor("successor-auth.json"); + await authState.materializeOpenAiAuthState({ + env: { OPENAI_API_KEY: "old-key" }, + runtimeHome: state.runtimeHome, + }); + await Bun.write(marker("old-auth.json"), JSON.stringify(state)); + await waitFor("release-old"); + await authState.cleanupOpenAiRuntimeHome(state); + state = null; + await Bun.write(marker("old-cleaned"), ""); + return; + } + + await waitFor("old-home.json"); + state = await authState.createOpenAiRuntimeHome({ + driverGeneration: 2, + driverInstanceId, + persistentRuntimeHome: root, + }); + await authState.materializeOpenAiAuthState({ + env: { OPENAI_API_KEY: "successor-key" }, + runtimeHome: state.runtimeHome, + }); + await Bun.write(marker("successor-auth.json"), JSON.stringify(state)); + await waitFor("old-auth.json"); + await Bun.write(marker("release-old"), ""); + await waitFor("old-cleaned"); + const auth = await Bun.file(`${state.runtimeHome}/auth.json`).json(); + await Bun.write(marker("successor-observed.json"), JSON.stringify(auth)); + await authState.cleanupOpenAiRuntimeHome(state); + state = null; + } finally { + if (state !== null) { + await authState.cleanupOpenAiRuntimeHome(state).catch(() => false); + } + } +} + +async function readChildError(child: ReturnType): Promise { + const [exitCode, stderr] = await Promise.all([ + child.exited, + child.stderr instanceof ReadableStream ? new Response(child.stderr).text() : "", + ]); + expect(exitCode, stderr).toBe(0); + return stderr; +} + afterEach(async () => { await Promise.all( - runtimeHomes.map((runtimeHome) => rm(runtimeHome, { force: true, recursive: true })), + runtimeHomeStates.splice(0).map((state) => cleanupOpenAiRuntimeHome(state).catch(() => false)), + ); + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })), ); - runtimeHomes = []; }); describe("OpenAI app-server auth state", () => { - test("skips unchanged API key auth writes", async () => { - const runtimeHome = await createRuntimeHome(); - const input = { - env: { - OPENAI_API_KEY: "openai-key", - }, - runtimeHome, - }; + test("rejects runtime identities that cannot be safe path segments", async () => { + const persistentRuntimeHome = await createTemporaryDirectory(); - await expect(materializeOpenAiApiKeyAuthState(input)).resolves.toMatchObject({ - hasApiKey: true, - written: true, + await expect( + createOpenAiRuntimeHome({ + driverGeneration: -1, + driverInstanceId: createDriverInstanceId(), + persistentRuntimeHome, + }), + ).rejects.toThrow("identity is invalid"); + await expect( + createOpenAiRuntimeHome({ + driverGeneration: 0, + driverInstanceId: "../../credential" as DriverInstanceId, + persistentRuntimeHome, + }), + ).rejects.toThrow("identity is invalid"); + }); + + test("creates a private runtime home with only explicit persistent state links", async () => { + const state = await createRuntimeHome(); + + expect((await lstat(state.runtimeHome)).mode & 0o777).toBe(0o700); + for (const name of PERSISTENT_DIRECTORIES) { + const persistentPath = join(state.persistentRuntimeHome, name); + expect((await lstat(persistentPath)).isDirectory()).toBe(true); + expect((await lstat(join(state.runtimeHome, name))).isSymbolicLink()).toBe(true); + expect(await readlink(join(state.runtimeHome, name))).toBe(persistentPath); + } + }); + + test("persists rollout and memory state across isolated runtime-home generations", async () => { + const persistentRuntimeHome = await createTemporaryDirectory(); + const first = await createRuntimeHome(persistentRuntimeHome, 1); + + for (const name of PERSISTENT_DIRECTORIES) { + await writeFile(join(first.runtimeHome, name, "retained"), name); + } + await cleanupOpenAiRuntimeHome(first); + + const successor = await createRuntimeHome(persistentRuntimeHome, 2); + for (const name of PERSISTENT_DIRECTORIES) { + expect(await readFile(join(successor.runtimeHome, name, "retained"), "utf8")).toBe(name); + } + }); + + test("removes auth, config, and temporary home without following persistent links", async () => { + const state = await createRuntimeHome(); + await materializeOpenAiAuthState({ + env: { OPENAI_API_KEY: "openai-key" }, + runtimeHome: state.runtimeHome, }); - await expect(materializeOpenAiApiKeyAuthState(input)).resolves.toMatchObject({ - hasApiKey: true, - written: false, + await materializeOpenAiModelProviderConfig({ + env: { OPENAI_API_KEY: "openai-key" }, + provider: "openai", + runtimeHome: state.runtimeHome, }); + await writeFile(join(state.runtimeHome, "sessions", "retained"), "session"); + + expect(await cleanupOpenAiRuntimeHome(state)).toBe(true); + expect(await cleanupOpenAiRuntimeHome(state)).toBe(false); + await expect(lstat(state.runtimeHome)).rejects.toThrow(); + expect(await readFile(join(state.persistentRuntimeHome, "sessions", "retained"), "utf8")).toBe( + "session", + ); }); - test("writes model provider config for OpenAI-compatible credentials", async () => { - const runtimeHome = await createRuntimeHome(); + test("preserves a real directory that replaces the owned runtime-home path", async () => { + const state = await createRuntimeHome(); + const movedOwnedHome = `${state.runtimeHome}.moved`; + + try { + await materializeOpenAiAuthState({ + env: { OPENAI_API_KEY: "owned-key" }, + runtimeHome: state.runtimeHome, + }); + await rename(state.runtimeHome, movedOwnedHome); + await mkdir(state.runtimeHome); + await writeFile(join(state.runtimeHome, "replacement-marker"), "preserve"); + + await expect(cleanupOpenAiRuntimeHome(state)).rejects.toThrow( + "preserved an unexpected runtime home", + ); + expect(state.cleanupRoot).not.toBeNull(); + expect(await readFile(join(state.cleanupPath, "replacement-marker"), "utf8")).toBe( + "preserve", + ); + expect(await readFile(join(movedOwnedHome, "auth.json"), "utf8")).toContain("owned-key"); + } finally { + if (state.cleanupRoot !== null) { + await rm(state.cleanupRoot, { force: true, recursive: true }).catch(() => {}); + } + state.cleanupPath = state.runtimeHome; + state.cleanupRoot = null; + await rm(state.runtimeHome, { force: true, recursive: true }); + await rm(movedOwnedHome, { force: true, recursive: true }); + } + }); + + test("isolates late predecessor materialization and cleanup across child processes", async () => { + const root = await createTemporaryDirectory("mosoo-openai-process-race-"); + const authStateModule = join(import.meta.dir, "../src/runtimes/openai/auth-state.ts"); + const source = `import * as authState from ${JSON.stringify(authStateModule)}; +await (${runCrossProcessAuthChild.toString()})(authState)`; + const commonEnv = Object.fromEntries( + Object.entries({ + ...process.env, + DRIVER_INSTANCE_ID: createDriverInstanceId(), + RACE_ROOT: root, + }).filter((entry): entry is [string, string] => entry[1] !== undefined), + ); + const old = Bun.spawn([process.execPath, "-e", source], { + env: { ...commonEnv, RACE_ROLE: "old" }, + stderr: "pipe", + stdout: "ignore", + }); + const successor = Bun.spawn([process.execPath, "-e", source], { + env: { ...commonEnv, RACE_ROLE: "successor" }, + stderr: "pipe", + stdout: "ignore", + }); + + try { + await Promise.all([readChildError(old), readChildError(successor)]); + expect( + JSON.parse(await readFile(join(root, "successor-observed.json"), "utf8")), + ).toMatchObject({ + OPENAI_API_KEY: "successor-key", + auth_mode: "apikey", + }); + const oldState = JSON.parse(await readFile(join(root, "old-home.json"), "utf8")) as { + runtimeHome: string; + }; + const successorState = JSON.parse( + await readFile(join(root, "successor-auth.json"), "utf8"), + ) as { runtimeHome: string }; + await expect(lstat(oldState.runtimeHome)).rejects.toThrow(); + await expect(lstat(successorState.runtimeHome)).rejects.toThrow(); + } finally { + old.kill("SIGKILL"); + successor.kill("SIGKILL"); + await Promise.allSettled([old.exited, successor.exited]); + } + }); + + test("writes API-key auth as a private regular file", async () => { + const { runtimeHome } = await createRuntimeHome(); + const result = await materializeOpenAiAuthState({ + env: { OPENAI_API_KEY: "openai-key" }, + runtimeHome, + }); + + expect(result).toMatchObject({ hasApiKey: true, written: true }); + expect((await lstat(result.authJsonPath)).isFile()).toBe(true); + expect((await lstat(result.authJsonPath)).mode & 0o777).toBe(0o600); + expect(JSON.parse(await readFile(result.authJsonPath, "utf8"))).toMatchObject({ + OPENAI_API_KEY: "openai-key", + auth_mode: "apikey", + }); + }); + + test("does not create auth state without an injected API key", async () => { + const { runtimeHome } = await createRuntimeHome(); + const result = await materializeOpenAiAuthState({ env: {}, runtimeHome }); + expect(result).toMatchObject({ hasApiKey: false, written: false }); + await expect(lstat(result.authJsonPath)).rejects.toThrow(); + }); + + test("fails closed without modifying legacy workspace credentials", async () => { + for (const kind of ["regular", "external-link"] as const) { + const persistentRuntimeHome = await createTemporaryDirectory(); + const authJsonPath = join(persistentRuntimeHome, "auth.json"); + const externalTarget = join(persistentRuntimeHome, "external-auth.json"); + + if (kind === "regular") { + await writeFile(authJsonPath, "oauth-secret\n"); + } else { + await writeFile(externalTarget, "external-oauth-secret\n"); + await symlink(externalTarget, authJsonPath); + } + + await expect( + createOpenAiRuntimeHome({ + driverGeneration: 0, + driverInstanceId: createDriverInstanceId(), + persistentRuntimeHome, + }), + ).rejects.toThrow("must not contain credentials"); + + if (kind === "regular") { + expect(await readFile(authJsonPath, "utf8")).toBe("oauth-secret\n"); + } else { + expect(await readlink(authJsonPath)).toBe(externalTarget); + expect(await readFile(externalTarget, "utf8")).toBe("external-oauth-secret\n"); + } + } + }); + + test("writes model provider config for OpenAI-compatible credentials", async () => { + const runtimeHome = await createTemporaryDirectory(); const result = await materializeOpenAiModelProviderConfig({ env: { OPENAI_COMPATIBLE_API_KEY: "compat-key", @@ -73,23 +370,16 @@ describe("OpenAI app-server auth state", () => { }, provider: "openai-compatible", providerOptions: { - features: { - tool_suggest: true, - }, - model_providers: { - "openai-compatible": { - wire_api: "chat", - }, - }, + features: { tool_suggest: true }, + model_providers: { "openai-compatible": { wire_api: "chat" } }, sandbox_workspace_write: true, }, runtimeHome, }); - - expect(result.written).toBe(true); const config = await readGeneratedConfig(result.configTomlPath); const modelProviders = requireRecord(config["model_providers"], "model providers"); + expect(result.written).toBe(true); expect(config["model_provider"]).toBe("openai-compatible"); expect(modelProviders["openai-compatible"]).toEqual({ base_url: "https://compat.example/v1", @@ -106,8 +396,7 @@ describe("OpenAI app-server auth state", () => { }); test("writes generated config for built-in OpenAI auth", async () => { - const runtimeHome = await createRuntimeHome(); - + const runtimeHome = await createTemporaryDirectory(); const result = await materializeOpenAiModelProviderConfig({ env: { OPENAI_API_KEY: "openai-key", @@ -116,10 +405,9 @@ describe("OpenAI app-server auth state", () => { provider: "openai", runtimeHome, }); - - expect(result.written).toBe(true); const config = await readGeneratedConfig(result.configTomlPath); + expect(result.written).toBe(true); expect(config["model_provider"]).toBeUndefined(); expect(config["model_providers"]).toBeUndefined(); expect(config["openai_base_url"]).toBe("https://proxy.example/v1"); @@ -127,12 +415,9 @@ describe("OpenAI app-server auth state", () => { }); test("passes reasoning effort and verbosity provider options into generated config", async () => { - const runtimeHome = await createRuntimeHome(); - + const runtimeHome = await createTemporaryDirectory(); const result = await materializeOpenAiModelProviderConfig({ - env: { - OPENAI_API_KEY: "openai-key", - }, + env: { OPENAI_API_KEY: "openai-key" }, provider: "openai", providerOptions: { model_reasoning_effort: "high", @@ -140,65 +425,51 @@ describe("OpenAI app-server auth state", () => { }, runtimeHome, }); - - expect(result.written).toBe(true); const config = await readGeneratedConfig(result.configTomlPath); + expect(result.written).toBe(true); expect(config["model_reasoning_effort"]).toBe("high"); expect(config["model_verbosity"]).toBe("low"); expectDisabledRuntimeFeatures(config); }); - test("writes mcp_servers tables into the generated config", async () => { - const runtimeHome = await createRuntimeHome(); - - const result = await materializeOpenAiModelProviderConfig({ - env: { - OPENAI_API_KEY: "openai-key", - }, + test("writes only configured mcp_servers tables", async () => { + const runtimeHome = await createTemporaryDirectory(); + const input = { + env: { OPENAI_API_KEY: "openai-key" }, + provider: "openai", + runtimeHome, + }; + const withServer = await materializeOpenAiModelProviderConfig({ + ...input, mcpServers: { Linear: { bearer_token_env_var: "MOSOO_MCP_BEARER_TOKEN_0", url: "https://api.example/driver/mcp/proxy/server-1", }, }, - provider: "openai", - runtimeHome, }); + const config = await readGeneratedConfig(withServer.configTomlPath); - expect(result.written).toBe(true); - const config = await readGeneratedConfig(result.configTomlPath); - const mcpServers = requireRecord(config["mcp_servers"], "mcp servers"); - - expect(mcpServers["Linear"]).toEqual({ + expect(requireRecord(config["mcp_servers"], "mcp servers")["Linear"]).toEqual({ bearer_token_env_var: "MOSOO_MCP_BEARER_TOKEN_0", url: "https://api.example/driver/mcp/proxy/server-1", }); - expectDisabledRuntimeFeatures(config); - }); - test("omits mcp_servers when no servers are wired", async () => { - const runtimeHome = await createRuntimeHome(); - - const result = await materializeOpenAiModelProviderConfig({ - env: { - OPENAI_API_KEY: "openai-key", - }, + const withoutServers = await materializeOpenAiModelProviderConfig({ + ...input, mcpServers: {}, - provider: "openai", - runtimeHome, }); - - const config = await readGeneratedConfig(result.configTomlPath); - expect(config["mcp_servers"]).toBeUndefined(); + expect( + (await readGeneratedConfig(withoutServers.configTomlPath))["mcp_servers"], + ).toBeUndefined(); }); - test("skips unchanged generated config writes", async () => { - const runtimeHome = await createRuntimeHome(); + test("skips unchanged regular config writes but replaces an unchanged symlink", async () => { + const runtimeHome = await createTemporaryDirectory(); + const configTomlPath = join(runtimeHome, "config.toml"); const input = { - env: { - OPENAI_API_KEY: "openai-key", - }, + env: { OPENAI_API_KEY: "openai-key" }, provider: "openai", runtimeHome, }; @@ -209,16 +480,26 @@ describe("OpenAI app-server auth state", () => { await expect(materializeOpenAiModelProviderConfig(input)).resolves.toMatchObject({ written: false, }); + + const contents = await readFile(configTomlPath, "utf8"); + const target = join(runtimeHome, "config-target.toml"); + await rm(configTomlPath); + await writeFile(target, contents); + await symlink(target, configTomlPath); + + await expect(materializeOpenAiModelProviderConfig(input)).resolves.toMatchObject({ + written: true, + }); + expect((await lstat(configTomlPath)).isSymbolicLink()).toBe(false); + expect(await readFile(target, "utf8")).toBe(contents); }); test("fails OpenAI-compatible provider config when credentials are incomplete", async () => { - const runtimeHome = await createRuntimeHome(); + const runtimeHome = await createTemporaryDirectory(); await expect( materializeOpenAiModelProviderConfig({ - env: { - OPENAI_COMPATIBLE_API_KEY: "compat-key", - }, + env: { OPENAI_COMPATIBLE_API_KEY: "compat-key" }, provider: "openai-compatible", runtimeHome, }), diff --git a/tests/openai-app-server-client.test.ts b/tests/openai-app-server-client.test.ts index 1951d51..dded009 100644 --- a/tests/openai-app-server-client.test.ts +++ b/tests/openai-app-server-client.test.ts @@ -1,25 +1,105 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; import type { ChildProcess } from "node:child_process"; import { EventEmitter } from "node:events"; -import { chmod, mkdtemp, rm } from "node:fs/promises"; +import { readFileSync } from "node:fs"; +import { chmod, lstat, mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Readable } from "node:stream"; import type { AgentDriverPermissionPort } from "../src/host-ports"; -import { createBufferedSinkLogger } from "../src/observability"; +import { createDisabledLogger } from "../src/observability"; import type { DriverExecutionEnvironment } from "../src/protocol/boot"; import { createDriverStartInputFromBootPayload } from "../src/protocol/start"; import { createAgentDriverContext } from "../src/core/agent-driver-backend"; import { PermissionEventDeliveryError } from "../src/core/driver-permission-broker"; import * as childProcessHelpers from "../src/runtimes/child-process"; import { OpenAiAppServerClient, limitNdjsonLines } from "../src/runtimes/openai/app-server-client"; -import type { ServerNotificationMethod } from "../src/runtimes/openai/generated/app-server-protocol"; +import * as openAiAuthState from "../src/runtimes/openai/auth-state"; +import type { ServerNotificationMethod } from "../src/runtimes/openai/app-server-protocol"; import { settlePromiseWithTimeout } from "../src/utils/async"; import { driverBootPayload } from "./driver-boot-payload-fixture"; const originalExecutable = process.env["MOSOO_OPENAI_RUNTIME_EXECUTABLE"]; const temporaryDirectories: string[] = []; +const initializeResult = { + codexHome: "/tmp/openai-home", + platformFamily: "unix", + platformOs: "linux", + userAgent: "test-app-server/0.152.0", +} as const; +const initializeResultJson = JSON.stringify(initializeResult); +const nativeResumeResultJson = JSON.stringify({ + activePermissionProfile: null, + approvalPolicy: "on-request", + approvalsReviewer: "user", + cwd: "/workspace", + instructionSources: [], + model: "gpt-5.6", + modelProvider: "openai", + multiAgentMode: "explicitRequestOnly", + reasoningEffort: "high", + runtimeWorkspaceRoots: ["/workspace"], + sandbox: { + excludeSlashTmp: false, + excludeTmpdirEnvVar: false, + networkAccess: false, + type: "workspaceWrite", + writableRoots: ["/workspace"], + }, + serviceTier: null, + thread: { + agentNickname: null, + agentRole: null, + canAcceptDirectInput: true, + cliVersion: "0.152.0", + createdAt: 1, + cwd: "/workspace", + ephemeral: false, + extra: null, + forkedFromId: null, + gitInfo: null, + historyMode: "paginated", + id: "thread-1", + modelProvider: "openai", + name: null, + parentThreadId: null, + path: null, + preview: "retained", + projectId: null, + recencyAt: null, + section: null, + sectionEnteredAt: null, + sessionId: "thread-1", + source: "appServer", + status: { type: "idle" }, + threadSource: null, + turns: [], + updatedAt: 2, + }, +}); + +function isProcessRunning(pid: number): boolean { + try { + process.kill(pid, 0); + return ( + process.platform !== "linux" || + !/^\d+ \(.*\) Z /.test(readFileSync(`/proc/${pid}/stat`, "utf8")) + ); + } catch { + return false; + } +} + +async function expectProcessExited(pid: number, timeoutMs = 3_000): Promise { + const deadline = Date.now() + timeoutMs; + + while (isProcessRunning(pid) && Date.now() < deadline) { + await Bun.sleep(20); + } + + expect(isProcessRunning(pid)).toBe(false); +} async function createClientHarness( script: (directory: string) => string, @@ -27,6 +107,7 @@ async function createClientHarness( requestPermission: AgentDriverPermissionPort["request"] = async () => "allow_once", interpreter = "bun", environment: DriverExecutionEnvironment = driverBootPayload.execution.environment, + runtimeOptions: { driverGeneration?: number; homePath?: string } = {}, ) { const directory = await mkdtemp(join(tmpdir(), "mosoo-openai-client-")); temporaryDirectories.push(directory); @@ -37,6 +118,7 @@ async function createClientHarness( const payload = createDriverStartInputFromBootPayload({ ...driverBootPayload, + driverGeneration: runtimeOptions.driverGeneration ?? driverBootPayload.driverGeneration, execution: { ...driverBootPayload.execution, environment, @@ -44,34 +126,35 @@ async function createClientHarness( ...driverBootPayload.execution.session, context: { ...driverBootPayload.execution.session.context, - homePath: join(directory, "home"), + homePath: runtimeOptions.homePath ?? join(directory, "home"), sessionOrganizationPath: directory, }, cwd: directory, }, }, }); - const logger = createBufferedSinkLogger({ - level: "debug", - service: "openai-app-server-client-test", - sink: async () => {}, - }); const context = createAgentDriverContext({ - eventSink: { pushEvents: async () => ({ accepted: [] }) }, - logger, + eventSink: { + currentRunId: () => null, + pushEvents: async () => ({ accepted: [] }), + }, + logger: createDisabledLogger(), payload, permission: { request: requestPermission }, }); const protocolErrors: Error[] = []; + const protocolError = Promise.withResolvers(); const client = new OpenAiAppServerClient(payload, { ...context, handleNotification, handleProtocolError: async (error) => { protocolErrors.push(error); + protocolError.resolve(error); }, + mapToolCallId: (toolCallId) => toolCallId, }); - return { client, directory, logger, protocolErrors }; + return { client, directory, protocolError: protocolError.promise, protocolErrors }; } afterEach(async () => { @@ -89,6 +172,196 @@ afterEach(async () => { }); describe("OpenAi app-server client", () => { + test("keeps transient API-key auth for the fake app-server lifetime", async () => { + const harness = await createClientHarness( + (directory) => ` +await Bun.write(${JSON.stringify(join(directory, "runtime-env.json"))}, JSON.stringify({ + codexHome: process.env.CODEX_HOME, + sqliteHome: process.env.CODEX_SQLITE_HOME, +})); +await Bun.write(process.env.CODEX_SQLITE_HOME + "/state.sqlite", "sqlite-state"); +let buffer = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + buffer += chunk; + let newline; + while ((newline = buffer.indexOf("\\n")) >= 0) { + const request = JSON.parse(buffer.slice(0, newline)); + buffer = buffer.slice(newline + 1); + if (request.method === "initialize") { + process.stdout.write(JSON.stringify({ id: request.id, result: ${initializeResultJson} }) + "\\n"); + } + } +}); +setInterval(() => {}, 1000); +`, + undefined, + undefined, + "bun", + { + ...driverBootPayload.execution.environment, + variables: { OPENAI_API_KEY: "client-lifetime-key" }, + }, + ); + + try { + await harness.client.start(); + const runtimeEnv = JSON.parse( + await readFile(join(harness.directory, "runtime-env.json"), "utf8"), + ) as { codexHome: string; sqliteHome: string }; + const authJsonPath = join(runtimeEnv.codexHome, "auth.json"); + + expect(runtimeEnv.codexHome).not.toBe(join(harness.directory, "home")); + expect(runtimeEnv.sqliteHome).toBe(join(harness.directory, "home")); + expect((await lstat(authJsonPath)).isFile()).toBe(true); + expect((await lstat(authJsonPath)).mode & 0o777).toBe(0o600); + expect(JSON.parse(await readFile(authJsonPath, "utf8"))).toMatchObject({ + OPENAI_API_KEY: "client-lifetime-key", + }); + await expect(harness.client.start()).rejects.toThrow("cannot be started more than once"); + expect(await readFile(authJsonPath, "utf8")).toContain("client-lifetime-key"); + await harness.client.stop(); + await expect(lstat(runtimeEnv.codexHome)).rejects.toThrow(); + expect((await lstat(join(runtimeEnv.sqliteHome, "sessions"))).isDirectory()).toBe(true); + expect(await readFile(join(runtimeEnv.sqliteHome, "state.sqlite"), "utf8")).toBe( + "sqlite-state", + ); + await expect(lstat(join(runtimeEnv.sqliteHome, "auth.json"))).rejects.toThrow(); + } finally { + await harness.client.stop().catch(() => {}); + } + }); + + test("cleans transient API-key auth when fake app-server startup fails", async () => { + const harness = await createClientHarness( + (directory) => ` +await Bun.write(${JSON.stringify(join(directory, "failed-runtime-home"))}, process.env.CODEX_HOME); +process.stdin.once("data", (chunk) => { + const request = JSON.parse(String(chunk).trim()); + process.stdout.write(JSON.stringify({ + error: { code: -32000, message: "initialize rejected" }, + id: request.id, + }) + "\\n"); +}); +setInterval(() => {}, 1000); +`, + undefined, + undefined, + "bun", + { + ...driverBootPayload.execution.environment, + variables: { OPENAI_API_KEY: "failed-start-key" }, + }, + ); + + try { + await expect(harness.client.start()).rejects.toThrow("initialize rejected"); + const runtimeHome = await readFile(join(harness.directory, "failed-runtime-home"), "utf8"); + await expect(lstat(runtimeHome)).rejects.toThrow(); + await expect(lstat(join(harness.directory, "home", "auth.json"))).rejects.toThrow(); + } finally { + await harness.client.stop().catch(() => {}); + } + }); + + test("lists and resumes persistent native state across Driver generations", async () => { + const sharedRoot = await mkdtemp(join(tmpdir(), "mosoo-openai-native-state-")); + temporaryDirectories.push(sharedRoot); + const persistentRuntimeHome = join(sharedRoot, "home"); + const environment = { + ...driverBootPayload.execution.environment, + variables: { OPENAI_API_KEY: "native-state-key" }, + }; + const first = await createClientHarness( + (directory) => ` +await Bun.write(process.env.CODEX_HOME + "/sessions/thread-1.jsonl", "rollout"); +await Bun.write(process.env.CODEX_SQLITE_HOME + "/state.sqlite", "sqlite"); +await Bun.write(${JSON.stringify(join(directory, "runtime-home"))}, process.env.CODEX_HOME); +let buffer = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + buffer += chunk; + let newline; + while ((newline = buffer.indexOf("\\n")) >= 0) { + const request = JSON.parse(buffer.slice(0, newline)); + buffer = buffer.slice(newline + 1); + if (request.id !== undefined) { + process.stdout.write(JSON.stringify({ id: request.id, result: ${initializeResultJson} }) + "\\n"); + } + } +}); +setInterval(() => {}, 1000); +`, + undefined, + undefined, + "bun", + environment, + { driverGeneration: 1, homePath: persistentRuntimeHome }, + ); + + let firstRuntimeHome = ""; + try { + await first.client.start(); + firstRuntimeHome = await readFile(join(first.directory, "runtime-home"), "utf8"); + } finally { + await first.client.stop().catch(() => {}); + } + await expect(lstat(firstRuntimeHome)).rejects.toThrow(); + expect(await readFile(join(persistentRuntimeHome, "sessions", "thread-1.jsonl"), "utf8")).toBe( + "rollout", + ); + expect(await readFile(join(persistentRuntimeHome, "state.sqlite"), "utf8")).toBe("sqlite"); + + const successor = await createClientHarness( + (directory) => ` +import { readdirSync, readFileSync } from "node:fs"; +await Bun.write(${JSON.stringify(join(directory, "observation.json"))}, JSON.stringify({ + codexHome: process.env.CODEX_HOME, + sessions: readdirSync(process.env.CODEX_HOME + "/sessions"), + sqlite: readFileSync(process.env.CODEX_SQLITE_HOME + "/state.sqlite", "utf8"), +})); +let buffer = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + buffer += chunk; + let newline; + while ((newline = buffer.indexOf("\\n")) >= 0) { + const request = JSON.parse(buffer.slice(0, newline)); + buffer = buffer.slice(newline + 1); + if (request.id === undefined) continue; + const result = request.method === "thread/resume" + ? ${nativeResumeResultJson} + : ${initializeResultJson}; + process.stdout.write(JSON.stringify({ id: request.id, result }) + "\\n"); + } +}); +setInterval(() => {}, 1000); +`, + undefined, + undefined, + "bun", + environment, + { driverGeneration: 2, homePath: persistentRuntimeHome }, + ); + + try { + await successor.client.start(); + const observation = JSON.parse( + await readFile(join(successor.directory, "observation.json"), "utf8"), + ) as { codexHome: string; sessions: string[]; sqlite: string }; + expect(observation.codexHome).not.toBe(firstRuntimeHome); + expect(observation.sessions).toContain("thread-1.jsonl"); + expect(observation.sqlite).toBe("sqlite"); + await expect( + successor.client.request("thread/resume", { threadId: "thread-1" }), + ).resolves.toMatchObject({ thread: { id: "thread-1" } }); + await successor.client.stop(); + await expect(lstat(observation.codexHome)).rejects.toThrow(); + } finally { + await successor.client.stop().catch(() => {}); + } + }); + test("declares only initialized server-request capabilities it handles", async () => { const harness = await createClientHarness( (directory) => ` @@ -96,11 +369,17 @@ let buffer = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", async (chunk) => { buffer += chunk; - const newline = buffer.indexOf("\\n"); - if (newline < 0) return; - const request = JSON.parse(buffer.slice(0, newline)); - await Bun.write(${JSON.stringify(join(directory, "initialize.json"))}, JSON.stringify(request)); - process.stdout.write(JSON.stringify({ id: request.id, result: {} }) + "\\n"); + let newline; + while ((newline = buffer.indexOf("\\n")) >= 0) { + const request = JSON.parse(buffer.slice(0, newline)); + buffer = buffer.slice(newline + 1); + if (request.method === "initialize") { + await Bun.write(${JSON.stringify(join(directory, "initialize.json"))}, JSON.stringify(request)); + process.stdout.write(JSON.stringify({ id: request.id, result: ${initializeResultJson} }) + "\\n"); + } else if (request.method === "initialized") { + await Bun.write(${JSON.stringify(join(directory, "initialized.json"))}, JSON.stringify(request)); + } + } }); setInterval(() => {}, 1000); `, @@ -116,9 +395,64 @@ setInterval(() => {}, 1000); experimentalApi: true, requestAttestation: false, }); + for ( + let attempt = 0; + attempt < 50 && !(await Bun.file(join(harness.directory, "initialized.json")).exists()); + attempt += 1 + ) { + await Bun.sleep(5); + } + expect( + JSON.parse(await Bun.file(join(harness.directory, "initialized.json")).text()), + ).toEqual({ method: "initialized" }); + } finally { + await harness.client.stop(); + } + }); + + test("rejects a malformed approval before requesting host permission", async () => { + let permissionRequests = 0; + const harness = await createClientHarness( + () => ` +let buffer = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + buffer += chunk; + let newline; + while ((newline = buffer.indexOf("\\n")) >= 0) { + const message = JSON.parse(buffer.slice(0, newline)); + buffer = buffer.slice(newline + 1); + if (message.method === "initialize") { + process.stdout.write(JSON.stringify({ id: message.id, result: ${initializeResultJson} }) + "\\n"); + } else if (message.method === "initialized") { + process.stdout.write(JSON.stringify({ + id: 41, + method: "item/commandExecution/requestApproval", + params: {}, + }) + "\\n"); + } + } +}); +setInterval(() => {}, 1000); +`, + undefined, + async () => { + permissionRequests += 1; + return "allow_once"; + }, + ); + + try { + await harness.client.start(); + + for (let attempt = 0; attempt < 50 && harness.protocolErrors.length === 0; attempt += 1) { + await Bun.sleep(5); + } + + expect(permissionRequests).toBe(0); + expect(harness.protocolErrors).toHaveLength(1); } finally { await harness.client.stop(); - await harness.logger.destroy(); } }); @@ -142,18 +476,331 @@ setInterval(() => {}, 1000); await expect(Array.fromAsync(output)).rejects.toThrow("exceeds 4 bytes"); }); - test("rejects a child process spawn failure without an unhandled error", async () => { - const harness = await createClientHarness(() => ""); - process.env["MOSOO_OPENAI_RUNTIME_EXECUTABLE"] = join(harness.directory, "missing"); + test.each([ + ["non-JSON", "not-json", "stdout is not valid JSON"], + ["non-object", "[]", "protocol message must be an object"], + ["unframed object", "{}", "requires a valid method or id"], + ] as const)("fails the protocol for %s stdout", async (_label, line, message) => { + const harness = await createClientHarness( + () => ` +process.stdin.once("data", () => { + process.stdout.write(${JSON.stringify(`${line}\n`)}); +}); +setInterval(() => {}, 1000); +`, + ); + + try { + await expect(harness.client.start()).rejects.toThrow(message); + expect(harness.protocolErrors).toHaveLength(1); + expect(harness.protocolErrors[0]?.message).toContain(message); + } finally { + await harness.client.stop(); + } + }); + + test("handles an admitted notification before a later malformed frame", async () => { + const notificationEntered = Promise.withResolvers(); + const notificationGate = Promise.withResolvers(); + let notificationCompleted = false; + const harness = await createClientHarness( + () => ` +let buffer = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + buffer += chunk; + let newline; + while ((newline = buffer.indexOf("\\n")) >= 0) { + const request = JSON.parse(buffer.slice(0, newline)); + buffer = buffer.slice(newline + 1); + if (request.method === "initialize") { + process.stdout.write(JSON.stringify({ id: request.id, result: ${initializeResultJson} }) + "\\n"); + } else if (request.method === "initialized") { + process.stdout.write( + JSON.stringify({ method: "skills/changed", params: {} }) + "\\nnot-json\\n", + ); + } + } +}); +setInterval(() => {}, 1000); +`, + async () => { + notificationEntered.resolve(); + await notificationGate.promise; + notificationCompleted = true; + }, + ); + + try { + await harness.client.start(); + await notificationEntered.promise; + expect(harness.protocolErrors).toEqual([]); + + notificationGate.resolve(); + expect((await harness.protocolError).message).toContain("stdout is not valid JSON"); + expect(notificationCompleted).toBe(true); + } finally { + notificationGate.resolve(); + await harness.client.stop(); + } + }); + + test.each([ + [ + "both result and error", + { error: { code: -32_000, message: "failed" }, id: 1, result: initializeResult }, + ], + ["neither result nor error", { id: 1 }], + ["primitive error", { error: "failed", id: 1 }], + ["non-integer error code", { error: { code: "-32000", message: "failed" }, id: 1 }], + ["non-string error message", { error: { code: -32_000, message: 7 }, id: 1 }], + ] as const)("fails the protocol for a response with %s", async (_label, response) => { + const harness = await createClientHarness( + () => ` +process.stdin.once("data", () => { + process.stdout.write(${JSON.stringify(`${JSON.stringify(response)}\n`)}); +}); +setInterval(() => {}, 1000); +`, + ); + + try { + await expect(harness.client.start()).rejects.toThrow("response envelope is invalid"); + expect(harness.protocolErrors).toHaveLength(1); + expect(harness.protocolErrors[0]?.message).toContain("response envelope is invalid"); + } finally { + await harness.client.stop(); + } + }); + + test("rejects a response that also claims to be a server message", async () => { + const harness = await createClientHarness( + () => ` +process.stdin.once("data", () => { + process.stdout.write(JSON.stringify({ + id: 1, + method: "warning", + params: { message: "not a response", threadId: null }, + result: ${initializeResultJson}, + }) + "\\n"); +}); +setInterval(() => {}, 1000); +`, + ); + + try { + await expect(harness.client.start()).rejects.toThrow("response envelope is invalid"); + expect(harness.protocolErrors).toHaveLength(1); + } finally { + await harness.client.stop(); + } + }); + + test("stops after a malformed known notification", async () => { + const handled: ServerNotificationMethod[] = []; + const harness = await createClientHarness( + () => ` +let buffer = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + buffer += chunk; + let newline; + while ((newline = buffer.indexOf("\\n")) >= 0) { + const message = JSON.parse(buffer.slice(0, newline)); + buffer = buffer.slice(newline + 1); + if (message.method === "initialize") { + process.stdout.write(JSON.stringify({ id: message.id, result: ${initializeResultJson} }) + "\\n"); + } else if (message.method === "initialized") { + process.stdout.write(JSON.stringify({ + method: "turn/completed", + params: { threadId: "thread-1", turn: { id: "turn-1", items: [], status: "inProgress" } }, + }) + "\\n"); + process.stdout.write(JSON.stringify({ + method: "warning", + params: { message: "must not be handled", threadId: null }, + }) + "\\n"); + } + } +}); +setInterval(() => {}, 1000); +`, + async (method) => { + handled.push(method); + }, + ); + + try { + await harness.client.start(); + for (let attempt = 0; attempt < 50 && harness.protocolErrors.length === 0; attempt += 1) { + await Bun.sleep(5); + } + + expect(harness.protocolErrors).toHaveLength(1); + expect(handled).toEqual([]); + } finally { + await harness.client.stop(); + } + }); + + test("fails the protocol for a duplicate pending server-request id", async () => { + const permissionStarted = Promise.withResolvers(); + const permissionAborted = Promise.withResolvers(); + let permissionRequests = 0; + const harness = await createClientHarness( + () => ` +let buffer = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + buffer += chunk; + let newline; + while ((newline = buffer.indexOf("\\n")) >= 0) { + const message = JSON.parse(buffer.slice(0, newline)); + buffer = buffer.slice(newline + 1); + if (message.method === "initialize") { + process.stdout.write(JSON.stringify({ id: message.id, result: ${initializeResultJson} }) + "\\n"); + } else if (message.method === "initialized") { + const request = { + id: 41, + method: "item/commandExecution/requestApproval", + params: { environmentId: null, itemId: "item-1", startedAtMs: 1, threadId: "thread-1", turnId: "turn-1" }, + }; + process.stdout.write(JSON.stringify(request) + "\\n"); + process.stdout.write(JSON.stringify(request) + "\\n"); + } + } +}); +setInterval(() => {}, 1000); +`, + undefined, + async (_input, signal) => { + permissionRequests += 1; + permissionStarted.resolve(); + await new Promise((resolve) => { + if (signal?.aborted) { + resolve(); + return; + } + signal?.addEventListener("abort", () => resolve(), { once: true }); + }); + permissionAborted.resolve(); + return "reject_once"; + }, + ); + + try { + await harness.client.start(); + await permissionStarted.promise; + for (let attempt = 0; attempt < 50 && harness.protocolErrors.length === 0; attempt += 1) { + await Bun.sleep(5); + } + + expect(harness.protocolErrors).toHaveLength(1); + expect(harness.protocolErrors[0]?.message).toContain("already pending"); + expect(permissionRequests).toBe(1); + await permissionAborted.promise; + } finally { + await harness.client.stop(); + } + }); + + test("keeps a valid JSON-RPC error scoped to its request", async () => { + const harness = await createClientHarness( + () => ` +process.stdin.once("data", () => { + process.stdout.write(JSON.stringify({ + error: { code: -32600, data: { reason: "test" }, message: "initialize denied" }, + id: 1, + }) + "\\n"); +}); +setInterval(() => {}, 1000); +`, + ); + + try { + await expect(harness.client.start()).rejects.toThrow("initialize denied"); + expect(harness.protocolErrors).toEqual([]); + } finally { + await harness.client.stop(); + } + }); + + test("fails the protocol when initialize returns an invalid result", async () => { + const harness = await createClientHarness( + () => ` +process.stdin.once("data", () => { + process.stdout.write(JSON.stringify({ id: 1, result: {} }) + "\\n"); +}); +setInterval(() => {}, 1000); +`, + ); + + try { + await expect(harness.client.start()).rejects.toThrow("codexHome"); + expect(harness.protocolErrors).toHaveLength(1); + await expect( + harness.client.request("initialize", { + capabilities: { experimentalApi: true, requestAttestation: false }, + clientInfo: { name: "test", title: null, version: "1" }, + }), + ).rejects.toThrow("stopping"); + } finally { + await harness.client.stop(); + } + }); + + test("fails the protocol when turn/start returns contradictory status and error", async () => { + const harness = await createClientHarness( + () => ` +let buffer = ""; +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (chunk) => { + buffer += chunk; + let newline; + while ((newline = buffer.indexOf("\\n")) >= 0) { + const request = JSON.parse(buffer.slice(0, newline)); + buffer = buffer.slice(newline + 1); + if (request.method === "initialize") { + process.stdout.write(JSON.stringify({ id: request.id, result: ${initializeResultJson} }) + "\\n"); + } else if (request.method === "turn/start") { + process.stdout.write(JSON.stringify({ + id: request.id, + result: { turn: { error: null, id: "turn-1", items: [], status: "failed" } }, + }) + "\\n"); + } + } +}); +setInterval(() => {}, 1000); +`, + ); try { - await expect(harness.client.start()).rejects.toThrow(); + await harness.client.start(); + await expect( + harness.client.request("turn/start", { input: [], threadId: "thread-1" }), + ).rejects.toThrow("turn.error must be present exactly when the turn failed"); + expect(harness.protocolErrors).toHaveLength(1); } finally { await harness.client.stop(); - await harness.logger.destroy(); } }); + test.each(["asynchronous", "synchronous"] as const)( + "rejects a child process %s spawn failure without retaining ownership", + async (failure) => { + const harness = await createClientHarness(() => ""); + process.env["MOSOO_OPENAI_RUNTIME_EXECUTABLE"] = + failure === "asynchronous" ? join(harness.directory, "missing") : "invalid\0executable"; + + try { + await expect(harness.client.start()).rejects.toThrow(); + await expect(harness.client.stop()).resolves.toBeUndefined(); + await expect(harness.client.stop()).resolves.toBeUndefined(); + } finally { + await harness.client.stop(); + } + }, + ); + test.each(["unavailable", "error"] as const)( "fails closed when the process-tree watchdog is %s", async (failure) => { @@ -187,21 +834,7 @@ setInterval(() => {}, 1000); } await expect(start).rejects.toThrow("process-tree watchdog"); - await expect( - settlePromiseWithTimeout( - (async () => { - for (;;) { - try { - process.kill(childPid, 0); - await Bun.sleep(5); - } catch { - return; - } - } - })(), - { label: "unsupervised app-server exit", timeoutMs: 250 }, - ), - ).resolves.toMatchObject({ status: "completed" }); + await expectProcessExited(childPid, 250); expect(harness.protocolErrors.some((error) => error.message.includes("watchdog"))).toBe( true, ); @@ -213,7 +846,6 @@ setInterval(() => {}, 1000); } catch {} } await harness.client.stop().catch(() => {}); - await harness.logger.destroy(); } }, ); @@ -236,7 +868,109 @@ setInterval(() => {}, 1000); expect(await Bun.file(join(harness.directory, "spawned")).exists()).toBe(false); } finally { await harness.client.stop(); - await harness.logger.destroy(); + } + }); + + test.each(["home", "auth"] as const)( + "waits for in-flight %s setup before cleaning the private runtime home", + async (phase) => { + const entered = Promise.withResolvers(); + const release = Promise.withResolvers(); + const nativeCreate = openAiAuthState.createOpenAiRuntimeHome; + const nativeAuth = openAiAuthState.materializeOpenAiAuthState; + const createSpy = spyOn(openAiAuthState, "createOpenAiRuntimeHome"); + const authSpy = spyOn(openAiAuthState, "materializeOpenAiAuthState"); + + if (phase === "home") { + createSpy.mockImplementation(async (input) => { + const state = await nativeCreate(input); + entered.resolve(state.runtimeHome); + await release.promise; + return state; + }); + } else { + authSpy.mockImplementation(async (input) => { + entered.resolve(input.runtimeHome); + await release.promise; + return nativeAuth(input); + }); + } + + const harness = await createClientHarness( + (directory) => ` +await Bun.write(${JSON.stringify(join(directory, "setup-race-spawned"))}, "spawned"); +setInterval(() => {}, 1000); +`, + undefined, + undefined, + "bun", + { + ...driverBootPayload.execution.environment, + variables: { OPENAI_API_KEY: "setup-race-key" }, + }, + ); + + try { + const start = harness.client.start(); + void start.catch(() => {}); + const runtimeHome = await entered.promise; + const stop = harness.client.stop(); + + await expect( + settlePromiseWithTimeout(stop, { + label: "OpenAI setup-barrier stop", + timeoutMs: 25, + }), + ).resolves.toMatchObject({ status: "timed_out" }); + expect((await lstat(runtimeHome)).isDirectory()).toBe(true); + + release.resolve(); + await expect(stop).resolves.toBeUndefined(); + await expect(start).rejects.toThrow("stopped during startup"); + await expect(lstat(runtimeHome)).rejects.toThrow(); + expect(await Bun.file(join(harness.directory, "setup-race-spawned")).exists()).toBe(false); + } finally { + release.resolve(); + createSpy.mockRestore(); + authSpy.mockRestore(); + await harness.client.stop().catch(() => {}); + } + }, + ); + + test("cleans its private runtime home when startup is aborted", async () => { + const harness = await createClientHarness( + (directory) => ` +await Bun.write(${JSON.stringify(join(directory, "aborted-runtime-home"))}, process.env.CODEX_HOME); +process.stdin.resume(); +setInterval(() => {}, 1000); +`, + undefined, + undefined, + "bun", + { + ...driverBootPayload.execution.environment, + variables: { OPENAI_API_KEY: "aborted-start-key" }, + }, + ); + const controller = new AbortController(); + + try { + const start = harness.client.start(controller.signal); + void start.catch(() => {}); + const runtimeHomeFile = Bun.file(join(harness.directory, "aborted-runtime-home")); + for (let attempt = 0; attempt < 100 && !(await runtimeHomeFile.exists()); attempt += 1) { + await Bun.sleep(5); + } + const runtimeHome = await runtimeHomeFile.text(); + controller.abort(new Error("startup aborted")); + + await expect(start).rejects.toThrow("startup aborted"); + await expect(lstat(runtimeHome)).rejects.toThrow(); + await expect(lstat(join(harness.directory, "home", "auth.json"))).rejects.toThrow(); + } finally { + controller.abort(); + await harness.client.stop().catch(() => {}); } }); @@ -254,7 +988,9 @@ process.stdin.on("data", (chunk) => { const request = JSON.parse(buffer.slice(0, newline)); buffer = buffer.slice(newline + 1); if (request.id !== undefined) { - process.stdout.write(JSON.stringify({ id: request.id, result: {} }) + "\\n"); + process.stdout.write( + JSON.stringify({ id: request.id, result: ${initializeResultJson} }) + "\\n", + ); } } }); @@ -277,7 +1013,6 @@ setTimeout(() => process.exit(0), 500); ).toHaveLength(1); } finally { await harness.client.stop(); - await harness.logger.destroy(); } }); @@ -295,7 +1030,9 @@ process.stdin.on("data", (chunk) => { buffer = buffer.slice(newline + 1); requests += 1; if (requests === 1) { - process.stdout.write(JSON.stringify({ id: request.id, result: {} }) + "\\n"); + process.stdout.write( + JSON.stringify({ id: request.id, result: ${initializeResultJson} }) + "\\n", + ); } } }); @@ -307,7 +1044,7 @@ setInterval(() => {}, 1000); await harness.client.start(); const request = harness.client.request("initialize", { capabilities: { experimentalApi: true, requestAttestation: false }, - clientInfo: { name: "test", version: "1" }, + clientInfo: { name: "test", title: null, version: "1" }, }); const stop = harness.client.stop(); @@ -315,7 +1052,6 @@ setInterval(() => {}, 1000); await expect(stop).resolves.toBeUndefined(); } finally { await harness.client.stop(); - await harness.logger.destroy(); } }); @@ -331,7 +1067,7 @@ process.stdin.on("data", (chunk) => { const newline = buffer.indexOf("\\n"); if (newline < 0) return; const request = JSON.parse(buffer.slice(0, newline)); - process.stdout.write(JSON.stringify({ id: request.id, result: {} }) + "\\n"); + process.stdout.write(JSON.stringify({ id: request.id, result: ${initializeResultJson} }) + "\\n"); setTimeout(() => process.exit(17), 25); }); `, @@ -359,7 +1095,6 @@ process.stdin.on("data", (chunk) => { expect(harness.protocolErrors[0]?.message).toBe("OpenAi app-server exited with code 17."); } finally { await harness.client.stop(); - await harness.logger.destroy(); } }); @@ -378,12 +1113,12 @@ process.stdin.on("data", (chunk) => { const message = JSON.parse(buffer.slice(0, newline)); buffer = buffer.slice(newline + 1); if (message.method === "initialize") { - process.stdout.write(JSON.stringify({ id: message.id, result: {} }) + "\\n"); + process.stdout.write(JSON.stringify({ id: message.id, result: ${initializeResultJson} }) + "\\n"); } else if (message.method === "initialized") { process.stdout.write(JSON.stringify({ id: 41, method: "item/commandExecution/requestApproval", - params: { itemId: "item-1", threadId: "thread-1", turnId: "turn-1" }, + params: { environmentId: null, itemId: "item-1", startedAtMs: 1, threadId: "thread-1", turnId: "turn-1" }, }) + "\\n"); process.stdout.write(JSON.stringify({ method: "serverRequest/resolved", @@ -391,7 +1126,7 @@ process.stdin.on("data", (chunk) => { }) + "\\n"); process.stdout.write(JSON.stringify({ method: "turn/completed", - params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed" } }, + params: { threadId: "thread-1", turn: { id: "turn-1", items: [], status: "completed" } }, }) + "\\n"); } } @@ -439,7 +1174,6 @@ setInterval(() => {}, 1000); } finally { permissionGate.resolve(); await harness.client.stop(); - await harness.logger.destroy(); } }); @@ -458,12 +1192,12 @@ process.stdin.on("data", (chunk) => { const message = JSON.parse(buffer.slice(0, newline)); buffer = buffer.slice(newline + 1); if (message.method === "initialize") { - process.stdout.write(JSON.stringify({ id: message.id, result: {} }) + "\\n"); + process.stdout.write(JSON.stringify({ id: message.id, result: ${initializeResultJson} }) + "\\n"); } else if (message.method === "initialized") { process.stdout.write(JSON.stringify({ id: 41, method: "item/commandExecution/requestApproval", - params: { itemId: "item-1", threadId: "thread-1", turnId: "turn-1" }, + params: { environmentId: null, itemId: "item-1", startedAtMs: 1, threadId: "thread-1", turnId: "turn-1" }, }) + "\\n"); process.stdout.write(JSON.stringify({ method: "serverRequest/resolved", @@ -471,7 +1205,7 @@ process.stdin.on("data", (chunk) => { }) + "\\n"); process.stdout.write(JSON.stringify({ method: "turn/completed", - params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed" } }, + params: { threadId: "thread-1", turn: { id: "turn-1", items: [], status: "completed" } }, }) + "\\n"); } } @@ -495,7 +1229,7 @@ setInterval(() => {}, 1000); permissionAborted.resolve(); await permissionGate.promise; throw new PermissionEventDeliveryError( - "item/commandExecution/requestApproval:41", + "item/commandExecution/requestApproval:number:41", "resolved", new Error("event sink unavailable"), ); @@ -528,7 +1262,6 @@ setInterval(() => {}, 1000); } finally { permissionGate.resolve(); await harness.client.stop().catch(() => {}); - await harness.logger.destroy(); } }); @@ -545,12 +1278,12 @@ process.stdin.on("data", (chunk) => { const message = JSON.parse(buffer.slice(0, newline)); buffer = buffer.slice(newline + 1); if (message.method === "initialize") { - process.stdout.write(JSON.stringify({ id: message.id, result: {} }) + "\\n"); + process.stdout.write(JSON.stringify({ id: message.id, result: ${initializeResultJson} }) + "\\n"); } else if (message.method === "initialized") { process.stdout.write(JSON.stringify({ id: 41, method: "item/commandExecution/requestApproval", - params: { itemId: "item-1", threadId: "thread-1", turnId: "turn-1" }, + params: { environmentId: null, itemId: "item-1", startedAtMs: 1, threadId: "thread-1", turnId: "turn-1" }, }) + "\\n"); setTimeout(() => process.exit(17), 10); } @@ -575,26 +1308,11 @@ setInterval(() => {}, 1000); try { await harness.client.start(); - const settled = await settlePromiseWithTimeout( - (async () => { - while (harness.protocolErrors.length === 0) { - await Bun.sleep(5); - } - })(), - { label: "process close", timeoutMs: 250 }, - ); + const [protocolError] = await Promise.all([harness.protocolError, permissionAborted.promise]); - expect(settled.status).toBe("completed"); - await expect( - settlePromiseWithTimeout(permissionAborted.promise, { - label: "permission abort", - timeoutMs: 250, - }), - ).resolves.toMatchObject({ status: "completed" }); - expect(harness.protocolErrors[0]?.message).toContain("code 17"); + expect(protocolError.message).toContain("code 17"); } finally { await harness.client.stop(); - await harness.logger.destroy(); } }); @@ -613,12 +1331,12 @@ process.stdin.on("data", async (chunk) => { const message = JSON.parse(buffer.slice(0, newline)); buffer = buffer.slice(newline + 1); if (message.method === "initialize") { - process.stdout.write(JSON.stringify({ id: message.id, result: {} }) + "\\n"); + process.stdout.write(JSON.stringify({ id: message.id, result: ${initializeResultJson} }) + "\\n"); } else if (message.method === "initialized") { process.stdout.write(JSON.stringify({ id: 41, method: "item/commandExecution/requestApproval", - params: { itemId: "item-1", threadId: "thread-1", turnId: "turn-1" }, + params: { environmentId: null, itemId: "item-1", startedAtMs: 1, threadId: "thread-1", turnId: "turn-1" }, }) + "\\n"); } else if (message.id === 41) { await Bun.write(${JSON.stringify(join(directory, "late-response.json"))}, JSON.stringify(message)); @@ -639,16 +1357,15 @@ setInterval(() => {}, 1000); try { await harness.client.start(); await permissionStarted.promise; - harness.client.abortServerRequests(new Error("turn cancelled")); + const abort = harness.client.abortServerRequests(new Error("turn cancelled")); await permissionAborted.promise; permissionGate.resolve(); - await Bun.sleep(25); + await abort; expect(await Bun.file(join(harness.directory, "late-response.json")).exists()).toBe(false); } finally { permissionGate.resolve(); await harness.client.stop(); - await harness.logger.destroy(); } }); @@ -666,11 +1383,11 @@ process.stdin.on("data", (chunk) => { const message = JSON.parse(buffer.slice(0, newline)); buffer = buffer.slice(newline + 1); if (message.method === "initialize") { - process.stdout.write(JSON.stringify({ id: message.id, result: {} }) + "\\n"); + process.stdout.write(JSON.stringify({ id: message.id, result: ${initializeResultJson} }) + "\\n"); } else if (message.method === "initialized") { process.stdout.write(JSON.stringify({ method: "turn/completed", - params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed" } }, + params: { threadId: "thread-1", turn: { id: "turn-1", items: [], status: "completed" } }, }) + "\\n", () => process.exit(17)); } } @@ -699,7 +1416,6 @@ process.stdin.on("data", (chunk) => { } finally { terminalGate.resolve(); await harness.client.stop(); - await harness.logger.destroy(); } }); @@ -728,7 +1444,7 @@ process.stdin.on("data", (chunk) => { const message = JSON.parse(buffer.slice(0, newline)); buffer = buffer.slice(newline + 1); if (message.method === "initialize") { - process.stdout.write(JSON.stringify({ id: message.id, result: {} }) + "\\n"); + process.stdout.write(JSON.stringify({ id: message.id, result: ${initializeResultJson} }) + "\\n"); } else if (message.method === "initialized") { setTimeout(() => process.exit(0), 5); } @@ -742,7 +1458,6 @@ process.stdin.on("data", (chunk) => { while (!(await Bun.file(join(harness.directory, "leader-exited")).exists())) { await Bun.sleep(5); } - await Bun.sleep(25); const stop = harness.client.stop(); void stop.catch(() => {}); @@ -764,7 +1479,6 @@ process.stdin.on("data", (chunk) => { watchdogCleanup.resolve(); watchdogSpy.mockRestore(); await harness.client.stop().catch(() => {}); - await harness.logger.destroy(); } }, ); @@ -774,7 +1488,7 @@ process.stdin.on("data", (chunk) => { const harness = await createClientHarness( () => ` IFS= read -r _ -printf '%s\n' '{"id":1,"result":{}}' +printf '%s\n' '${JSON.stringify({ id: 1, result: initializeResult })}' IFS= read -r _ exec 0<&- printf '%s\n' '{"method":"warning","params":{"message":"stdin closed","threadId":null}}' @@ -790,7 +1504,7 @@ sleep 10 await closedInput.promise; const request = harness.client.request("initialize", { capabilities: { experimentalApi: true, requestAttestation: false }, - clientInfo: { name: "x".repeat(1024 * 1024), version: "1" }, + clientInfo: { name: "x".repeat(1024 * 1024), title: null, version: "1" }, }); const settled = await settlePromiseWithTimeout(request, { label: "stdin EPIPE", @@ -801,7 +1515,6 @@ sleep 10 expect(harness.protocolErrors[0]?.message).toContain("EPIPE"); } finally { await harness.client.stop(); - await harness.logger.destroy(); } }); @@ -824,7 +1537,9 @@ process.stdin.on("data", (chunk) => { buffer = buffer.slice(newline + 1); requests += 1; if (requests === 1) { - process.stdout.write(JSON.stringify({ id: request.id, result: {} }) + "\\n"); + process.stdout.write( + JSON.stringify({ id: request.id, result: ${initializeResultJson} }) + "\\n", + ); } else { ${terminate} } @@ -838,16 +1553,15 @@ setInterval(() => {}, 1000); await harness.client.start(); const request = harness.client.request("initialize", { capabilities: { experimentalApi: true, requestAttestation: false }, - clientInfo: { name: "crash-test", version: "1" }, + clientInfo: { name: "crash-test", title: null, version: "1" }, }); await expect(request).rejects.toThrow(`app-server exited with ${expectedExit}`); - await harness.client.drainServerMessages(); + await expect(harness.client.drainServerMessages()).rejects.toThrow(expectedExit); expect(harness.protocolErrors).toHaveLength(1); expect(harness.protocolErrors[0]?.message).toContain(expectedExit); } finally { await harness.client.stop(); - await harness.logger.destroy(); } }, ); @@ -872,11 +1586,15 @@ process.stdin.on("data", (chunk) => { if (request.id === undefined) continue; requests += 1; if (requests === 1) { - process.stdout.write(JSON.stringify({ id: request.id, result: {} }) + "\\n"); + process.stdout.write( + JSON.stringify({ id: request.id, result: ${initializeResultJson} }) + "\\n", + ); process.stdout.write(JSON.stringify({ method: "warning", params: { message: "first", threadId: null } }) + "\\n"); } else { process.stdout.write(JSON.stringify({ method: "warning", params: { message: "second", threadId: null } }) + "\\n"); - process.stdout.write(JSON.stringify({ id: request.id, result: {} }) + "\\n"); + process.stdout.write( + JSON.stringify({ id: request.id, result: ${initializeResultJson} }) + "\\n", + ); } } }); @@ -903,7 +1621,7 @@ setInterval(() => {}, 1000); }); await harness.client.request("initialize", { capabilities: { experimentalApi: true, requestAttestation: false }, - clientInfo: { name: "drain-test", version: "1" }, + clientInfo: { name: "drain-test", title: null, version: "1" }, }); expect(drained).toBe(false); @@ -917,7 +1635,6 @@ setInterval(() => {}, 1000); notificationGate.resolve(); secondNotificationGate.resolve(); await harness.client.stop(); - await harness.logger.destroy(); } }); @@ -931,7 +1648,7 @@ process.stdin.on("data", (chunk) => { const newline = buffer.indexOf("\\n"); if (newline < 0) return; const request = JSON.parse(buffer.slice(0, newline)); - process.stdout.write(JSON.stringify({ id: request.id, result: {} }) + "\\n"); + process.stdout.write(JSON.stringify({ id: request.id, result: ${initializeResultJson} }) + "\\n"); }); process.on("SIGTERM", async () => { await Bun.write(${JSON.stringify(join(directory, "stopped"))}, "stopped"); @@ -953,18 +1670,15 @@ setInterval(() => {}, 1000); await expect(harness.client.start()).rejects.toThrow("cannot be started more than once"); } finally { await harness.client.stop(); - await harness.logger.destroy(); } }); test("force kills the process group after the leader exits with inherited stdio open", async () => { const harness = await createClientHarness((directory) => { const descendantReadyPath = join(directory, "descendant-ready"); - const descendantTermPath = join(directory, "descendant-term"); const descendantScript = ` import { writeFileSync } from "node:fs"; writeFileSync(${JSON.stringify(descendantReadyPath)}, "ready"); -process.on("SIGTERM", () => writeFileSync(${JSON.stringify(descendantTermPath)}, "ignored")); setInterval(() => {}, 1000); `; @@ -986,7 +1700,7 @@ process.stdin.on("data", (chunk) => { const newline = buffer.indexOf("\\n"); if (newline < 0) return; const request = JSON.parse(buffer.slice(0, newline)); - process.stdout.write(JSON.stringify({ id: request.id, result: {} }) + "\\n", () => process.exit(0)); + process.stdout.write(JSON.stringify({ id: request.id, result: ${initializeResultJson} }) + "\\n", () => process.exit(0)); }); `; }); @@ -1000,7 +1714,8 @@ process.stdin.on("data", (chunk) => { } await expect(harness.client.stop()).resolves.toBeUndefined(); - expect(await Bun.file(join(harness.directory, "descendant-term")).exists()).toBe(true); + await expectProcessExited(descendantPid); + descendantPid = 0; } finally { if (descendantPid > 0) { try { @@ -1009,7 +1724,6 @@ process.stdin.on("data", (chunk) => { } await harness.client.stop().catch(() => {}); - await harness.logger.destroy(); } }, 10_000); @@ -1045,7 +1759,7 @@ process.stdin.on("data", (chunk) => { const newline = buffer.indexOf("\\n"); if (newline < 0) return; const request = JSON.parse(buffer.slice(0, newline)); - process.stdout.write(JSON.stringify({ id: request.id, result: {} }) + "\\n"); + process.stdout.write(JSON.stringify({ id: request.id, result: ${initializeResultJson} }) + "\\n"); }); process.on("SIGTERM", () => process.exit(0)); setInterval(() => {}, 1000); @@ -1072,7 +1786,6 @@ setInterval(() => {}, 1000); } await harness.client.stop().catch(() => {}); - await harness.logger.destroy(); } }, 10_000); @@ -1110,7 +1823,7 @@ process.stdin.on("data", (chunk) => { const newline = buffer.indexOf("\\n"); if (newline < 0) return; const request = JSON.parse(buffer.slice(0, newline)); - process.stdout.write(JSON.stringify({ id: request.id, result: {} }) + "\\n"); + process.stdout.write(JSON.stringify({ id: request.id, result: ${initializeResultJson} }) + "\\n"); }); process.on("SIGTERM", () => process.exit(0)); setInterval(() => {}, 1000); @@ -1129,21 +1842,7 @@ setInterval(() => {}, 1000); await harness.client.stop(); expect(await Bun.file(join(harness.directory, "nested-term")).exists()).toBe(true); - await expect( - settlePromiseWithTimeout( - (async () => { - for (;;) { - try { - process.kill(descendantPid, 0); - await Bun.sleep(5); - } catch { - return; - } - } - })(), - { label: "nested app-server descendant exit", timeoutMs: 250 }, - ), - ).resolves.toMatchObject({ status: "completed" }); + await expectProcessExited(descendantPid, 250); } finally { if (descendantPid > 0) { try { @@ -1152,7 +1851,6 @@ setInterval(() => {}, 1000); } await harness.client.stop().catch(() => {}); - await harness.logger.destroy(); } }, 10_000); @@ -1160,6 +1858,7 @@ setInterval(() => {}, 1000); const harness = await createClientHarness( (directory) => ` import { spawn } from "node:child_process"; +await Bun.write(${JSON.stringify(join(directory, "runtime-home"))}, process.env.CODEX_HOME); const descendant = spawn("/usr/bin/setsid", [ process.execPath, "-e", @@ -1176,11 +1875,18 @@ process.stdin.on("data", (chunk) => { const newline = buffer.indexOf("\\n"); if (newline < 0) return; const request = JSON.parse(buffer.slice(0, newline)); - process.stdout.write(JSON.stringify({ id: request.id, result: {} }) + "\\n"); + process.stdout.write(JSON.stringify({ id: request.id, result: ${initializeResultJson} }) + "\\n"); }); process.on("SIGTERM", () => process.exit(0)); setInterval(() => {}, 1000); `, + undefined, + undefined, + "bun", + { + ...driverBootPayload.execution.environment, + variables: { OPENAI_API_KEY: "retry-stop-key" }, + }, ); const nativeKill = process.kill; let childPid = 0; @@ -1189,6 +1895,7 @@ setInterval(() => {}, 1000); try { await harness.client.start(); + const runtimeHome = await Bun.file(join(harness.directory, "runtime-home")).text(); childPid = Number(await Bun.file(join(harness.directory, "pid")).text()); descendantPid = Number(await Bun.file(join(harness.directory, "descendant-pid")).text()); process.kill = ((pid, signal) => { @@ -1200,8 +1907,11 @@ setInterval(() => {}, 1000); }) as typeof process.kill; await expect(harness.client.stop()).rejects.toThrow("process tree did not exit"); + expect(await readFile(join(runtimeHome, "auth.json"), "utf8")).toContain("retry-stop-key"); suppressKill = false; await expect(harness.client.stop()).resolves.toBeUndefined(); + await expect(lstat(runtimeHome)).rejects.toThrow(); + expect((await lstat(join(harness.directory, "home", "sessions"))).isDirectory()).toBe(true); } finally { process.kill = nativeKill; @@ -1215,8 +1925,6 @@ setInterval(() => {}, 1000); nativeKill(descendantPid, "SIGKILL"); } catch {} } - - await harness.logger.destroy(); } }, 10_000); @@ -1231,7 +1939,9 @@ process.stdin.on("data", (chunk) => { while ((newline = buffer.indexOf("\\n")) >= 0) { const request = JSON.parse(buffer.slice(0, newline)); buffer = buffer.slice(newline + 1); - process.stdout.write(JSON.stringify({ id: request.id, result: {} }) + "\\n"); + process.stdout.write( + JSON.stringify({ id: request.id, result: ${initializeResultJson} }) + "\\n", + ); } }); process.on("SIGTERM", () => setTimeout(() => process.exit(0), 200)); @@ -1244,7 +1954,7 @@ setInterval(() => {}, 1000); const stop = harness.client.stop(); const request = harness.client.request("initialize", { capabilities: { experimentalApi: true, requestAttestation: false }, - clientInfo: { name: "shutdown-race", version: "1" }, + clientInfo: { name: "shutdown-race", title: null, version: "1" }, }); const outcome = await settlePromiseWithTimeout(request, { label: "request after client shutdown", @@ -1258,36 +1968,39 @@ setInterval(() => {}, 1000); }); } finally { await harness.client.stop(); - await harness.logger.destroy(); } }); - test("applies backpressure to a bounded multi-agent notification burst", async () => { + test.each([ + ["backpressures below", 900, false], + ["rejects above", 1_025, true], + ] as const)("%s the 1024-message queue limit", async (_label, messageCount, rejected) => { const firstNotification = Promise.withResolvers(); const notificationGate = Promise.withResolvers(); let handled = 0; const harness = await createClientHarness( - () => ` + (directory) => ` +import { writeFileSync } from "node:fs"; let buffer = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", (chunk) => { buffer += chunk; - const newline = buffer.indexOf("\\n"); - if (newline < 0) return; - const request = JSON.parse(buffer.slice(0, newline)); - buffer = buffer.slice(newline + 1); - if (request.id === undefined) return; - process.stdout.write(JSON.stringify({ id: request.id, result: {} }) + "\\n"); - for (let index = 0; index < 2048; index += 1) { - process.stdout.write(JSON.stringify({ - method: "item/agentMessage/delta", - params: { - delta: "x", - itemId: "message-" + index, - threadId: "thread-child-" + (index % 2), - turnId: "turn-child-" + (index % 2) - } - }) + "\\n"); + let newline; + while ((newline = buffer.indexOf("\\n")) >= 0) { + const request = JSON.parse(buffer.slice(0, newline)); + buffer = buffer.slice(newline + 1); + if (request.method === "initialize") { + process.stdout.write(JSON.stringify({ id: request.id, result: ${initializeResultJson} }) + "\\n"); + } else if (request.method === "initialized") { + const notification = JSON.stringify({ + method: "warning", + params: { message: "x".repeat(${rejected ? "1" : "4096"}), threadId: null }, + }); + process.stdout.write( + Array.from({ length: ${String(messageCount)} }, () => notification).join("\\n") + "\\n", + () => writeFileSync(${JSON.stringify(join(directory, "burst-flushed"))}, "flushed"), + ); + } } }); setInterval(() => {}, 1000); @@ -1304,17 +2017,30 @@ setInterval(() => {}, 1000); try { await harness.client.start(); await firstNotification.promise; - await Bun.sleep(25); + expect(handled).toBe(1); expect(harness.protocolErrors).toEqual([]); + if (!rejected) { + expect(await Bun.file(join(harness.directory, "burst-flushed")).exists()).toBe(false); + } notificationGate.resolve(); - await harness.client.drainServerMessages(); - expect(handled).toBe(2048); - expect(harness.protocolErrors).toEqual([]); + const drain = harness.client.drainServerMessages(); + if (rejected) { + await expect(drain).rejects.toThrow("message queue limit exceeded"); + } else { + await drain; + expect(await Bun.file(join(harness.directory, "burst-flushed")).exists()).toBe(true); + } + expect(handled).toBe(Math.min(messageCount, 1_024)); + if (rejected) { + expect((await harness.protocolError).message).toContain("message queue limit exceeded"); + expect(harness.protocolErrors).toHaveLength(1); + } else { + expect(harness.protocolErrors).toEqual([]); + } } finally { notificationGate.resolve(); await harness.client.stop(); - await harness.logger.destroy(); } }); }); diff --git a/tests/openai-app-server-event-bridge-fixture.ts b/tests/openai-app-server-event-bridge-fixture.ts new file mode 100644 index 0000000..6525a7a --- /dev/null +++ b/tests/openai-app-server-event-bridge-fixture.ts @@ -0,0 +1,146 @@ +import { expect } from "bun:test"; + +import type { AgentDriverContext } from "../src/core/agent-driver-backend"; +import { createAgentDriverContext } from "../src/core/agent-driver-backend"; +import { createDisabledLogger } from "../src/observability"; +import type { DriverEventInput } from "../src/protocol/events"; +import { isDriverId } from "../src/protocol/id"; +import { OpenAiAppServerEventBridge } from "../src/runtimes/openai/app-server-event-bridge"; +import { DriverCompletedTerminalSupersededError } from "../src/runtimes/driver-event-publisher"; +import { DRIVER_TEST_IDS, driverStartInput as bootPayload } from "./driver-boot-payload-fixture"; + +interface EventBatch { + events: DriverEventInput[]; + reason: string; +} + +export function readEventPayloadString(event: DriverEventInput, field: string): string | null { + const payload = event.payload; + + if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { + return null; + } + + const value = (payload as Record)[field]; + return typeof value === "string" ? value : null; +} + +export function readAssistantMessageId(events: readonly DriverEventInput[]): string { + for (const event of events) { + const messageId = + readEventPayloadString(event, "messageId") ?? + readEventPayloadString(event, "parentMessageId"); + + if (messageId !== null) { + expect(isDriverId(messageId)).toBe(true); + return messageId; + } + } + + throw new Error("Expected a platform assistant message ID."); +} + +export function createOpenAiBridgeHarness( + options: { + failNativeResumePublish?: boolean; + failReasonOnce?: string; + failTerminalOnce?: boolean; + holdCompletedTerminalOnce?: boolean; + holdFailedTerminalOnce?: boolean; + holdReason?: string; + } = {}, +) { + const batches: EventBatch[] = []; + const attempts: EventBatch[] = []; + const terminalAttempts: Array<{ + cancellationSignal: AbortSignal | null; + closures: readonly DriverEventInput[]; + terminal: DriverEventInput; + }> = []; + let failedReason = false; + let failedTerminal = false; + let heldCompletedTerminal = false; + const heldPush = Promise.withResolvers(); + const releasePush = Promise.withResolvers(); + const terminalHeld = Promise.withResolvers(); + const releaseTerminal = Promise.withResolvers(); + const context: AgentDriverContext = createAgentDriverContext({ + eventSink: { + currentRunId: () => DRIVER_TEST_IDS.runId, + pushEvents: async () => ({ accepted: [] }), + }, + logger: createDisabledLogger(), + payload: bootPayload, + permission: { + request: async () => "allow_once", + }, + }); + const push = async (_context: AgentDriverContext, reason: string, events: DriverEventInput[]) => { + attempts.push({ events, reason }); + if (!failedReason && reason === options.failReasonOnce) { + failedReason = true; + throw new Error("event sink rejected the first attempt"); + } + batches.push({ events, reason }); + if (reason === options.holdReason) { + heldPush.resolve(); + await releasePush.promise; + } + if ( + options.failNativeResumePublish === true && + reason === "driver.openai.native_resume_ref.updated" + ) { + throw new Error("event sink unavailable"); + } + }; + const bridge = new OpenAiAppServerEventBridge({ + push, + pushSession: push, + pushTerminal: async (pushContext, reason, closures, terminal, cancellationSignal) => { + terminalAttempts.push({ cancellationSignal: cancellationSignal ?? null, closures, terminal }); + if ( + !heldCompletedTerminal && + options.holdCompletedTerminalOnce === true && + terminal.kind === "run.completed" + ) { + heldCompletedTerminal = true; + for (const closure of closures) { + await push(pushContext, `${reason}.items`, [closure]); + } + terminalHeld.resolve(); + await releaseTerminal.promise; + if (cancellationSignal?.aborted === true) { + throw new DriverCompletedTerminalSupersededError(cancellationSignal.reason); + } + await push(pushContext, reason, [terminal]); + return; + } + if (!failedTerminal && options.failTerminalOnce === true) { + failedTerminal = true; + if (options.holdFailedTerminalOnce === true) { + terminalHeld.resolve(); + await releaseTerminal.promise; + } + throw new Error("terminal sink rejected the first attempt"); + } + for (const closure of closures) { + await push(pushContext, `${reason}.items`, [closure]); + } + await push(pushContext, reason, [terminal]); + }, + requireThreadId: () => "thread-1", + }); + + return { + attempts, + batches, + bridge, + context, + events: () => batches.flatMap((batch) => batch.events), + heldPush: heldPush.promise, + releasePush: releasePush.resolve, + releaseTerminal: releaseTerminal.resolve, + terminalHeld: terminalHeld.promise, + terminalAttempts, + }; +} diff --git a/tests/openai-app-server-event-bridge-items.test.ts b/tests/openai-app-server-event-bridge-items.test.ts index b078d74..e8d5a01 100644 --- a/tests/openai-app-server-event-bridge-items.test.ts +++ b/tests/openai-app-server-event-bridge-items.test.ts @@ -1,78 +1,185 @@ import { describe, expect, test } from "bun:test"; -import { createBufferedSinkLogger } from "../src/observability"; -import type { DriverEventInput } from "../src/protocol/events"; import type { AgentDriverContext } from "../src/core/agent-driver-backend"; -import { createAgentDriverContext } from "../src/core/agent-driver-backend"; +import type { DriverEventInput } from "../src/protocol/events"; import { OpenAiAppServerEventBridge } from "../src/runtimes/openai/app-server-event-bridge"; +import { parseServerNotification } from "../src/runtimes/openai/app-server-protocol-server"; import { OpenAiTurnTracker } from "../src/runtimes/openai/app-server-turn-tracker"; import { DRIVER_TEST_IDS } from "./driver-boot-payload-fixture"; -import { driverStartInput as bootPayload } from "./driver-boot-payload-fixture"; - -interface EventBatch { - events: DriverEventInput[]; - reason: string; -} +import { + createOpenAiBridgeHarness as createHarness, + readEventPayloadString, +} from "./openai-app-server-event-bridge-fixture"; -function readEventPayloadString(event: DriverEventInput, field: string): string | null { - const payload = event.payload; +async function handleOfficialNotification( + bridge: OpenAiAppServerEventBridge, + context: AgentDriverContext, + method: string, + params: Record, +): Promise { + const notification = parseServerNotification({ method, params }); - if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { - return null; + expect(notification).not.toBeNull(); + if (notification === null) { + throw new Error(`Expected ${method} to be an official OpenAI notification.`); } - const value = (payload as Record)[field]; - return typeof value === "string" ? value : null; + await bridge.handleNotification(context, notification.method, notification.params); } -function createHarness(options: { failNativeResumePublish?: boolean; holdReason?: string } = {}) { - const batches: EventBatch[] = []; - const heldPush = Promise.withResolvers(); - const releasePush = Promise.withResolvers(); - const logger = createBufferedSinkLogger({ - level: "debug", - service: "openai-app-server-event-bridge-test", - sink: async () => {}, - }); - const context: AgentDriverContext = createAgentDriverContext({ - eventSink: { - pushEvents: async () => ({ accepted: [] }), - }, - logger, - payload: bootPayload, - permission: { - request: async () => "allow_once", - }, - }); - const bridge = new OpenAiAppServerEventBridge({ - push: async (_context, reason, events) => { - batches.push({ events, reason }); - if (reason === options.holdReason) { - heldPush.resolve(); - await releasePush.promise; - } - if ( - options.failNativeResumePublish === true && - reason === "driver.openai.native_resume_ref.updated" - ) { - throw new Error("event sink unavailable"); +describe("OpenAi app-server event bridge", () => { + test("keeps bounded provider identities stable across lifecycle boundaries", () => { + const { bridge } = createHarness(); + const nativeId = "native".repeat(100); + const publicItemId = bridge.mapToolCallId(nativeId); + const publicTurnId = bridge.publicTurnId(nativeId); + + bridge.clearActiveTurns(); + + for (const candidate of [bridge, createHarness().bridge]) { + expect(candidate.mapToolCallId(nativeId)).toBe(publicItemId); + expect(candidate.publicTurnId(nativeId)).toBe(publicTurnId); + } + expect(publicItemId).not.toBe(publicTurnId); + expect(publicItemId).toBe("rid1_KnwXRN9srTgnCleHSt-EmI8jC7h9g2foozL7GxrsP9o"); + expect(publicItemId.length).toBeLessThan(nativeId.length); + expect(publicTurnId.length).toBeLessThan(nativeId.length); + expect(bridge.mapToolCallId(`rid1_${"x".repeat(43)}`)).not.toBe(`rid1_${"x".repeat(43)}`); + expect(bridge.mapToolCallId("\ud800".repeat(86))).not.toBe( + bridge.mapToolCallId("\ud801".repeat(86)), + ); + }); + + test.each([ + [ + "agent message", + async (harness: ReturnType, itemId: string, turnId: string) => + harness.bridge.handleNotification(harness.context, "item/completed", { + item: { id: itemId, text: "stable reply", type: "agentMessage" }, + threadId: "thread-1", + turnId, + }), + ], + [ + "reasoning", + async (harness: ReturnType, itemId: string, turnId: string) => + harness.bridge.handleNotification(harness.context, "item/reasoning/summaryTextDelta", { + delta: "stable thought", + itemId, + summaryIndex: 0, + threadId: "thread-1", + turnId, + }), + ], + [ + "tool", + async (harness: ReturnType, itemId: string, turnId: string) => + harness.bridge.handleNotification(harness.context, "item/started", { + item: { id: itemId, status: "inProgress", type: "commandExecution" }, + threadId: "thread-1", + turnId, + }), + ], + ] as const)("keeps complete oversized %s replay events stable", async (_kind, replay) => { + const itemId = `item-${"i".repeat(300)}`; + const turnId = `turn-${"t".repeat(300)}`; + const attempts: DriverEventInput[][] = []; + + for (let generation = 0; generation < 2; generation += 1) { + const harness = createHarness(); + await replay(harness, itemId, turnId); + attempts.push(harness.events()); + } + + expect(attempts[0]?.length).toBeGreaterThan(0); + expect(attempts[1]).toEqual(attempts[0]); + }); + + test("keeps item messages and synthetic tool parents independent of notification order", async () => { + const project = async (order: readonly ("message" | "tool")[]) => { + const harness = createHarness(); + + for (const kind of order) { + if (kind === "message") { + await harness.bridge.handleNotification(harness.context, "item/completed", { + item: { id: "message-1", text: "reply", type: "agentMessage" }, + threadId: "thread-1", + turnId: "turn-1", + }); + } else { + await harness.bridge.handleNotification(harness.context, "item/started", { + item: { id: "tool-1", status: "inProgress", type: "commandExecution" }, + threadId: "thread-1", + turnId: "turn-1", + }); + } } - }, - requireThreadId: () => "thread-1", - }); - - return { - batches, - bridge, - context, - events: () => batches.flatMap((batch) => batch.events), - heldPush: heldPush.promise, - logger, - releasePush: releasePush.resolve, - }; -} -describe("OpenAi app-server event bridge", () => { + const message = harness.events().find((event) => event.kind === "message.added"); + const tool = harness + .events() + .find( + (event) => + event.kind === "item.started" && + readEventPayloadString(event, "itemType") === "tool_call", + ); + return { + messageId: message === undefined ? null : readEventPayloadString(message, "messageId"), + toolParentId: tool === undefined ? null : readEventPayloadString(tool, "parentMessageId"), + }; + }; + + const messageFirst = await project(["message", "tool"]); + const toolFirst = await project(["tool", "message"]); + + expect(toolFirst).toEqual(messageFirst); + expect(toolFirst.messageId).not.toBe(toolFirst.toolParentId); + }); + + test("separates provider tuples and lone-surrogate identities", async () => { + const identities: Array = []; + + for (const [turnId, itemId] of [ + ["a", "b:c"], + ["a:b", "c"], + ["unicode", "\ud800".repeat(86)], + ["unicode", "\ud801".repeat(86)], + ]) { + const harness = createHarness(); + await harness.bridge.handleNotification(harness.context, "item/completed", { + item: { id: itemId, text: "reply", type: "agentMessage" }, + threadId: "thread-1", + turnId, + }); + const started = harness.events().find((event) => event.kind === "message.started"); + const added = harness.events().find((event) => event.kind === "message.added"); + const toolHarness = createHarness(); + await toolHarness.bridge.handleNotification(toolHarness.context, "item/started", { + item: { id: itemId, status: "inProgress", type: "commandExecution" }, + threadId: "thread-1", + turnId, + }); + identities.push([ + started === undefined ? null : readEventPayloadString(started, "messageId"), + added?.sourceEventId, + toolHarness.events().find((event) => event.kind === "item.started")?.sourceEventId, + ]); + } + + expect(new Set(identities.map(([messageId]) => messageId)).size).toBe(identities.length); + expect(new Set(identities.map(([, sourceEventId]) => sourceEventId)).size).toBe( + identities.length, + ); + expect(new Set(identities.map(([, , sourceEventId]) => sourceEventId)).size).toBe( + identities.length, + ); + expect(identities[0]).toEqual([ + "10DF94GHQDTF5928TMBEZSWHQ4", + "openai.item.completed:sid1_U4oWh4UGZEw808sI5zy0_aOCd0-dwfUwV07Mia3XjNA:0", + "openai.item.started:sid1_0MMp18iqK51t_lCY7b8CZMB4CuwCFtDacjitEs2EkVE", + ]); + }); + test("bounds terminal turn deduplication and clears it on shutdown", () => { const tracker = new OpenAiTurnTracker(); @@ -112,6 +219,19 @@ describe("OpenAi app-server event bridge", () => { expect(tracker.hasTerminal("turn-1")).toBe(true); }); + test("rejects settling turns with bounded retained identities", async () => { + const tracker = new OpenAiTurnTracker(); + const turnId = "turn".repeat(150_000); + const tracked = tracker.track(turnId, DRIVER_TEST_IDS.runId); + + expect(tracker.beginSettlement(turnId)).toBe(true); + tracker.rejectActiveTurns(new Error("driver stopped")); + + await expect(tracked).rejects.toThrow("driver stopped"); + expect(tracker.activeTurnIds()).toEqual([]); + expect(tracker.hasTerminal(turnId)).toBe(true); + }); + test("shares duplicate turn tracking without orphaning the first waiter", async () => { const tracker = new OpenAiTurnTracker(); const first = tracker.track("turn-1", DRIVER_TEST_IDS.runId); @@ -124,13 +244,36 @@ describe("OpenAi app-server event bridge", () => { await expect(Promise.all([first, duplicate])).resolves.toEqual([undefined, undefined]); }); + test("keeps one exact root-turn admission until its matching response claims it", async () => { + const tracker = new OpenAiTurnTracker(); + const controller = new AbortController(); + const admission = tracker.admitRootTurn(DRIVER_TEST_IDS.runId, controller.signal); + + expect(() => tracker.admitRootTurn(DRIVER_TEST_IDS.secondRunId)).toThrow("already pending"); + tracker.armRootTurn(admission); + tracker.bindRootTurn(admission, "turn-1"); + expect(() => + tracker.claimRootTurn(admission, "turn-mismatch", DRIVER_TEST_IDS.runId, controller.signal), + ).toThrow("was not bound"); + + tracker.releaseRootTurn(admission); + const next = tracker.admitRootTurn(DRIVER_TEST_IDS.secondRunId); + tracker.releaseRootTurn(admission); + tracker.armRootTurn(next); + tracker.bindRootTurn(next, "turn-2"); + const tracked = tracker.claimRootTurn(next, "turn-2", DRIVER_TEST_IDS.secondRunId); + tracker.settle("turn-2", { kind: "completed" }); + await expect(tracked).resolves.toBeUndefined(); + }); + test("closes visible message, tool, and run state when a turn is cancelled", async () => { - const { bridge, context, events, logger } = createHarness(); + const { bridge, context, events } = createHarness(); const trackedTurn = bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); await bridge.handleNotification(context, "item/started", { item: { id: "tool-1", + status: "inProgress", type: "commandExecution", }, threadId: "thread-1", @@ -143,10 +286,11 @@ describe("OpenAi app-server event bridge", () => { { kind: "message.started" }, { kind: "item.started" }, { kind: "tool.call.updated", payload: { status: "running", toolCallId: "tool-1" } }, + { kind: "agent.tasks.replaced", payload: { tasks: [] } }, { kind: "run.cancel.requested", runId: DRIVER_TEST_IDS.runId }, - { kind: "message.completed" }, - { kind: "tool.call.updated", payload: { status: "failed", toolCallId: "tool-1" } }, - { kind: "item.completed", payload: { itemId: "tool-1", status: "failed" } }, + { kind: "message.cancelled" }, + { kind: "tool.call.updated", payload: { status: "cancelled", toolCallId: "tool-1" } }, + { kind: "item.completed", payload: { itemId: "tool-1", status: "cancelled" } }, { kind: "run.cancelled", runId: DRIVER_TEST_IDS.runId }, ]); const terminalEventCount = events().length; @@ -161,15 +305,61 @@ describe("OpenAi app-server event bridge", () => { turnId: "turn-1", }); expect(events()).toHaveLength(terminalEventCount); - await logger.destroy(); + }); + + test("accepts only a completed sub-agent activity after its parent turn terminal", async () => { + const { bridge, context, events } = createHarness(); + const trackedTurn = bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); + + await bridge.handleNotification(context, "turn/completed", { + threadId: "thread-1", + turn: { id: "turn-1", items: [], itemsView: "notLoaded", status: "completed" }, + }); + await trackedTurn; + const terminalEventCount = events().length; + + await bridge.handleNotification(context, "item/completed", { + item: { + agentPath: "/root/worker", + agentThreadId: "agent-1", + id: "activity-1", + kind: "completed", + type: "subAgentActivity", + }, + threadId: "thread-1", + turnId: "turn-1", + }); + expect(events().slice(terminalEventCount)).toEqual([ + { + delivery: "lossless", + kind: "agent.task.updated", + payload: { + active: false, + activityKind: "completed", + agentId: "agent-1", + agentPath: "/root/worker", + status: "completed", + taskId: "agent-1", + title: "Sub-agent completed", + }, + sourceEventId: "openai.derived:sid1_kECA0eVWvzfAvIr0LN7vWra6VGTB8rTQoAgf7EHr7Rg", + }, + ]); + + await bridge.handleNotification(context, "item/completed", { + item: { id: "late-message", text: "late", type: "agentMessage" }, + threadId: "thread-1", + turnId: "turn-1", + }); + expect(events()).toHaveLength(terminalEventCount + 1); }); test("closes open item state when the provider interrupts a turn", async () => { - const { bridge, context, events, logger } = createHarness(); + const { bridge, context, events } = createHarness(); const trackedTurn = bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); await bridge.handleNotification(context, "item/started", { - item: { id: "tool-1", type: "commandExecution" }, + item: { id: "tool-1", status: "inProgress", type: "commandExecution" }, threadId: "thread-1", turnId: "turn-1", }); @@ -180,31 +370,30 @@ describe("OpenAi app-server event bridge", () => { await expect(trackedTurn).rejects.toThrow("interrupted"); expect(events().slice(-4)).toMatchObject([ - { kind: "message.completed" }, - { kind: "tool.call.updated", payload: { status: "failed", toolCallId: "tool-1" } }, - { kind: "item.completed", payload: { itemId: "tool-1", status: "failed" } }, + { kind: "message.cancelled" }, + { kind: "tool.call.updated", payload: { status: "cancelled", toolCallId: "tool-1" } }, + { kind: "item.completed", payload: { itemId: "tool-1", status: "cancelled" } }, { kind: "run.cancelled", payload: { requestedBy: "provider", stopReason: "cancelled" }, runId: DRIVER_TEST_IDS.runId, }, ]); - await logger.destroy(); }); test("closes open item state before a failed turn", async () => { - const { bridge, context, events, logger } = createHarness(); + const { bridge, context, events } = createHarness(); const trackedTurn = bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); await bridge.handleNotification(context, "item/started", { - item: { id: "tool-1", type: "commandExecution" }, + item: { id: "tool-1", status: "inProgress", type: "commandExecution" }, threadId: "thread-1", turnId: "turn-1", }); await bridge.handleNotification(context, "turn/completed", { threadId: "thread-1", turn: { - error: { additionalDetails: null, message: "command failed" }, + error: { additionalDetails: null, codexErrorInfo: null, message: "command failed" }, id: "turn-1", status: "failed", }, @@ -212,20 +401,24 @@ describe("OpenAi app-server event bridge", () => { await expect(trackedTurn).rejects.toThrow("command failed"); expect(events().slice(-4)).toMatchObject([ - { kind: "message.completed" }, + { + kind: "message.failed", + payload: { + error: { code: "openai.turn_failed", message: "command failed", retryable: false }, + }, + }, { kind: "tool.call.updated", payload: { status: "failed", toolCallId: "tool-1" } }, { kind: "item.completed", payload: { itemId: "tool-1", status: "failed" } }, { kind: "run.failed", runId: DRIVER_TEST_IDS.runId }, ]); - await logger.destroy(); }); test("closes open item state before a successful turn without loaded items", async () => { - const { bridge, context, events, logger } = createHarness(); + const { bridge, context, events } = createHarness(); const trackedTurn = bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); await bridge.handleNotification(context, "item/started", { - item: { id: "tool-1", type: "commandExecution" }, + item: { id: "tool-1", status: "inProgress", type: "commandExecution" }, threadId: "thread-1", turnId: "turn-1", }); @@ -238,35 +431,46 @@ describe("OpenAi app-server event bridge", () => { const terminalIndex = events().findIndex((event) => event.kind === "run.completed"); expect(events().slice(terminalIndex - 3, terminalIndex + 1)).toMatchObject([ { kind: "message.completed" }, - { kind: "tool.call.updated", payload: { status: "failed", toolCallId: "tool-1" } }, - { kind: "item.completed", payload: { itemId: "tool-1", status: "failed" } }, + { kind: "tool.call.updated", payload: { status: "completed", toolCallId: "tool-1" } }, + { kind: "item.completed", payload: { itemId: "tool-1", status: "completed" } }, { kind: "run.completed", runId: DRIVER_TEST_IDS.runId }, ]); - await logger.destroy(); }); - test("keeps cancellation terminal while turn completion is awaiting item delivery", async () => { + test("drains pending item delivery before cancellation and leaves the next turn clean", async () => { const harness = createHarness({ holdReason: "driver.openai.item.completed" }); const trackedTurn = harness.bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); await harness.bridge.handleNotification(harness.context, "item/started", { - item: { id: "tool-1", type: "commandExecution" }, + item: { id: "tool-1", status: "inProgress", type: "commandExecution" }, threadId: "thread-1", turnId: "turn-1", }); - const completion = harness.bridge.handleNotification(harness.context, "turn/completed", { - threadId: "thread-1", - turn: { - id: "turn-1", - items: [{ aggregatedOutput: "done", id: "tool-1", type: "commandExecution" }], - itemsView: "full", + const completion = harness.bridge.handleNotification(harness.context, "item/completed", { + item: { + aggregatedOutput: "done", + id: "tool-1", status: "completed", + type: "commandExecution", }, + threadId: "thread-1", + turnId: "turn-1", }); await harness.heldPush; - await harness.bridge.cancelTurn(harness.context, "turn-1", "test.cancel"); + const cancellation = harness.bridge.cancelTurn( + harness.context, + "turn-1", + "test.cancel", + async () => completion, + ); + let cancellationSettled = false; + void cancellation.finally(() => { + cancellationSettled = true; + }); + await Promise.resolve(); + expect(cancellationSettled).toBe(false); harness.releasePush(); - await completion; + await cancellation; await expect(trackedTurn).rejects.toThrow("test.cancel"); await expect(harness.bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId)).rejects.toThrow( @@ -277,7 +481,26 @@ describe("OpenAi app-server event bridge", () => { .events() .filter((event) => ["run.cancelled", "run.completed", "run.failed"].includes(event.kind)), ).toMatchObject([{ kind: "run.cancelled", runId: DRIVER_TEST_IDS.runId }]); - await harness.logger.destroy(); + + const nextTurn = harness.bridge.trackTurn("turn-2", DRIVER_TEST_IDS.secondRunId); + await harness.bridge.handleNotification(harness.context, "item/started", { + item: { id: "tool-1", status: "inProgress", type: "commandExecution" }, + threadId: "thread-1", + turnId: "turn-2", + }); + await harness.bridge.handleNotification(harness.context, "turn/completed", { + threadId: "thread-1", + turn: { id: "turn-2", items: [], itemsView: "notLoaded", status: "completed" }, + }); + await expect(nextTurn).resolves.toBeUndefined(); + expect( + harness + .events() + .filter( + (event) => + event.kind === "item.started" && readEventPayloadString(event, "itemId") === "tool-1", + ), + ).toHaveLength(2); }); test.each([ @@ -285,7 +508,7 @@ describe("OpenAi app-server event bridge", () => { ["failed", "failed"], ["declined", "failed"], ] as const)("maps completed native tool status %s to %s", async (nativeStatus, status) => { - const { bridge, context, events, logger } = createHarness(); + const { bridge, context, events } = createHarness(); await bridge.handleNotification(context, "item/completed", { item: { @@ -306,17 +529,319 @@ describe("OpenAi app-server event bridge", () => { expect(events().find((event) => event.kind === "item.completed")?.payload).toMatchObject({ status, }); - await logger.destroy(); + }); + + test("replays an item completion after delivery rejection before committing state", async () => { + const harness = createHarness({ failReasonOnce: "driver.openai.item.completed" }); + const notification = { + item: { + id: "message-1", + phase: "final_answer", + text: "authoritative answer", + type: "agentMessage", + }, + threadId: "thread-1", + turnId: "turn-1", + } as const; + + await expect( + harness.bridge.handleNotification(harness.context, "item/completed", notification), + ).rejects.toThrow("first attempt"); + await harness.bridge.handleNotification(harness.context, "item/completed", notification); + await harness.bridge.handleNotification(harness.context, "item/completed", notification); + + const attempts = harness.attempts.filter( + ({ reason }) => reason === "driver.openai.item.completed", + ); + expect(attempts).toHaveLength(2); + expect(attempts[0]!.events.map(({ sourceEventId }) => sourceEventId)).toEqual( + attempts[1]!.events.map(({ sourceEventId }) => sourceEventId), + ); + expect( + harness + .events() + .filter((event) => event.kind === "message.added" || event.kind === "message.completed"), + ).toHaveLength(2); + }); + + test("replays a full task snapshot before committing its active set", async () => { + const harness = createHarness({ failReasonOnce: "driver.openai.item.completed" }); + const nativeTaskId = "agent".repeat(100); + const notification = { + item: { + agentPath: "/root/worker", + agentThreadId: nativeTaskId, + id: "activity-1", + kind: "started", + type: "subAgentActivity", + }, + threadId: "thread-1", + turnId: "turn-1", + } as const; + + await expect( + harness.bridge.handleNotification(harness.context, "item/completed", notification), + ).rejects.toThrow("first attempt"); + await harness.bridge.handleNotification(harness.context, "item/completed", notification); + await harness.bridge.handleNotification(harness.context, "item/completed", notification); + + const attempts = harness.attempts.filter( + ({ reason }) => reason === "driver.openai.item.completed", + ); + expect(attempts).toHaveLength(2); + expect(attempts[0]!.events).toEqual(attempts[1]!.events); + const taskUpdate = harness.events().find((event) => event.kind === "agent.task.updated"); + const taskSnapshot = harness.events().find((event) => event.kind === "agent.tasks.replaced"); + const taskId = taskUpdate === undefined ? null : readEventPayloadString(taskUpdate, "taskId"); + expect(taskId).toMatch(/^rid1_[A-Za-z0-9_-]{43}$/); + expect(taskUpdate === undefined ? null : readEventPayloadString(taskUpdate, "agentId")).toBe( + taskId, + ); + expect(taskSnapshot).toMatchObject({ + payload: { tasks: [{ taskId }] }, + }); + }); + + test("bounds collaboration thread identities consistently", async () => { + const harness = createHarness(); + const nativeThreadId = `agent-${"a".repeat(300)}`; + + await harness.bridge.handleNotification(harness.context, "item/completed", { + item: { + agentsStates: { [nativeThreadId]: { message: "done", status: "completed" } }, + id: "collab-1", + model: null, + prompt: null, + reasoningEffort: null, + receiverThreadIds: [nativeThreadId], + senderThreadId: nativeThreadId, + status: "completed", + tool: "wait", + type: "collabAgentToolCall", + }, + threadId: "thread-1", + turnId: "turn-1", + }); + + const terminal = harness + .events() + .find( + (event) => + event.kind === "tool.call.updated" && + readEventPayloadString(event, "status") === "completed", + ); + const agentId = terminal === undefined ? null : readEventPayloadString(terminal, "agentId"); + const structuredOutput = + terminal?.payload !== null && + typeof terminal?.payload === "object" && + !Array.isArray(terminal.payload) + ? (terminal.payload as Record)["structuredOutput"] + : null; + + expect(agentId).toMatch(/^rid1_[A-Za-z0-9_-]{43}$/); + expect(JSON.stringify(structuredOutput)).not.toContain(nativeThreadId); + expect(structuredOutput).toMatchObject({ + agentsStates: { [agentId!]: { message: "done", status: "completed" } }, + receiverThreadIds: [agentId], + senderThreadId: agentId, + }); + }); + + test("replays a plan delta after delivery rejection without duplicating content", async () => { + const harness = createHarness({ failReasonOnce: "driver.openai.plan.delta" }); + const notification = { + delta: "step", + itemId: "plan-1", + threadId: "thread-1", + turnId: "turn-1", + } as const; + + await expect( + harness.bridge.handleNotification(harness.context, "item/plan/delta", notification), + ).rejects.toThrow("first attempt"); + await harness.bridge.handleNotification(harness.context, "item/plan/delta", notification); + + await harness.bridge.handleNotification(harness.context, "item/plan/delta", { + ...notification, + delta: " two", + }); + + const accepted = harness.batches.filter((batch) => batch.reason === "driver.openai.plan.delta"); + expect(accepted.at(-1)?.events[0]?.payload).toMatchObject({ + entries: [{ content: "step two", status: "in_progress" }], + }); + expect(accepted.every((batch) => batch.events[0]?.sourceEventId === undefined)).toBe(true); + expect(JSON.stringify(accepted)).not.toContain("stepstep"); + }); + + test("does not invent cross-process occurrence IDs for unsequenced provider updates", async () => { + const planHarness = createHarness(); + for (const step of ["A", "B", "A"]) { + await planHarness.bridge.handleNotification(planHarness.context, "turn/plan/updated", { + explanation: null, + plan: [{ status: "inProgress", step }], + threadId: "thread-1", + turnId: "turn-1", + }); + } + const planEvents = planHarness.events().filter((event) => event.kind === "plan.updated"); + expect(planEvents.map((event) => event.sourceEventId)).toEqual([ + undefined, + undefined, + undefined, + ]); + + const reasoningHarness = createHarness(); + for (const delta of ["same", "same"]) { + await reasoningHarness.bridge.handleNotification( + reasoningHarness.context, + "item/reasoning/summaryTextDelta", + { + delta, + itemId: "reasoning-1", + summaryIndex: 0, + threadId: "thread-1", + turnId: "turn-1", + }, + ); + } + const reasoningDeltas = reasoningHarness + .events() + .filter((event) => event.kind === "thought.delta"); + expect(reasoningDeltas.map((event) => event.sourceEventId)).toEqual([undefined, undefined]); + expect(reasoningDeltas.map((event) => readEventPayloadString(event, "contentDelta"))).toEqual([ + "same", + "same", + ]); + }); + + test("retries message start with the same identity after delivery rejection", async () => { + const harness = createHarness({ failReasonOnce: "driver.openai.message.started" }); + const notification = { + delta: "hello", + itemId: "message-1", + threadId: "thread-1", + turnId: "turn-1", + } as const; + + await expect( + harness.bridge.handleNotification(harness.context, "item/agentMessage/delta", notification), + ).rejects.toThrow("first attempt"); + await harness.bridge.handleNotification( + harness.context, + "item/agentMessage/delta", + notification, + ); + + const attempts = harness.attempts.filter( + ({ reason }) => reason === "driver.openai.message.started", + ); + expect(attempts).toHaveLength(2); + expect(attempts[0]!.events[0]?.sourceEventId).toBe(attempts[1]!.events[0]?.sourceEventId); + expect(harness.events().filter((event) => event.kind === "message.started")).toHaveLength(1); + expect( + harness + .events() + .filter((event) => event.kind === "message.delta") + .map((event) => readEventPayloadString(event, "contentDelta")), + ).toEqual(["hello"]); + }); + + test("replays terminal closures after rejection before settling the turn", async () => { + const harness = createHarness({ failTerminalOnce: true }); + const trackedTurn = harness.bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); + void trackedTurn.catch(() => {}); + await harness.bridge.handleNotification(harness.context, "item/started", { + item: { id: "tool-1", status: "inProgress", type: "commandExecution" }, + threadId: "thread-1", + turnId: "turn-1", + }); + const completion = { + threadId: "thread-1", + turn: { id: "turn-1", items: [], itemsView: "notLoaded", status: "completed" }, + } as const; + + await expect( + harness.bridge.handleNotification(harness.context, "turn/completed", completion), + ).rejects.toThrow("first attempt"); + await harness.bridge.handleNotification(harness.context, "turn/completed", completion); + await expect(trackedTurn).resolves.toBeUndefined(); + + expect(harness.terminalAttempts).toHaveLength(2); + expect(harness.terminalAttempts[0]!.closures).toEqual(harness.terminalAttempts[1]!.closures); + expect( + harness + .events() + .filter((event) => event.kind === "tool.call.updated") + .map((event) => readEventPayloadString(event, "status")), + ).toEqual(["running", "completed"]); + expect(harness.events().filter((event) => event.kind === "run.completed")).toHaveLength(1); + }); + + test("replays cancellation closures after rejection before rejecting the turn", async () => { + const harness = createHarness({ failTerminalOnce: true }); + const trackedTurn = harness.bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); + void trackedTurn.catch(() => {}); + await harness.bridge.handleNotification(harness.context, "item/started", { + item: { id: "tool-1", status: "inProgress", type: "commandExecution" }, + threadId: "thread-1", + turnId: "turn-1", + }); + + await expect( + harness.bridge.cancelTurn(harness.context, "turn-1", "test.cancel"), + ).rejects.toThrow("first attempt"); + await harness.bridge.cancelTurn(harness.context, "turn-1", "test.cancel"); + await expect(trackedTurn).rejects.toThrow("test.cancel"); + + expect(harness.terminalAttempts).toHaveLength(2); + expect(harness.terminalAttempts[0]!.closures).toEqual(harness.terminalAttempts[1]!.closures); + expect(harness.events().filter((event) => event.kind === "run.cancelled")).toHaveLength(1); + }); + + test("waits for a failed concurrent settlement before claiming cancellation", async () => { + const harness = createHarness({ failTerminalOnce: true, holdFailedTerminalOnce: true }); + const trackedTurn = harness.bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); + void trackedTurn.catch(() => {}); + const completion = harness.bridge.handleNotification(harness.context, "turn/completed", { + threadId: "thread-1", + turn: { id: "turn-1", items: [], itemsView: "notLoaded", status: "completed" }, + }); + void completion.catch(() => {}); + await harness.terminalHeld; + let drained = false; + const cancellation = harness.bridge.cancelTurn( + harness.context, + "turn-1", + "test.cancel", + async () => { + await completion.catch(() => {}); + drained = true; + }, + ); + + await Promise.resolve(); + expect(drained).toBe(false); + harness.releaseTerminal(); + await expect(completion).rejects.toThrow("first attempt"); + await cancellation; + await expect(trackedTurn).rejects.toThrow("test.cancel"); + expect(drained).toBe(true); + expect(harness.terminalAttempts.map(({ terminal }) => terminal.kind)).toEqual([ + "run.completed", + "run.cancelled", + ]); }); test("orders cancellation after an in-flight item update", async () => { - const { bridge, context, events, heldPush, logger, releasePush } = createHarness({ + const { bridge, context, events, heldPush, releasePush } = createHarness({ holdReason: "driver.openai.message.started", }); const trackedTurn = bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); const itemUpdate = bridge.handleNotification(context, "item/started", { item: { id: "tool-1", + status: "inProgress", type: "commandExecution", }, threadId: "thread-1", @@ -341,6 +866,351 @@ describe("OpenAi app-server event bridge", () => { readEventPayloadString(event, "status") === "running", ), ).toBeLessThan(events().findLastIndex((event) => event.kind === "run.cancelled")); - await logger.destroy(); + }); + + test("maps hooks and automatic approval reviews to their exact lifecycle events", async () => { + const { bridge, context, events } = createHarness(); + + const hookRun = { + displayOrder: 0, + entries: [], + eventName: "interrupt", + executionMode: "sync", + handlerType: "command", + id: "hook-1", + scope: "turn", + sourcePath: "/tmp/hook", + startedAt: 1, + }; + const action = { command: "echo ok", cwd: "/tmp", source: "shell", type: "command" }; + + await handleOfficialNotification(bridge, context, "hook/started", { + run: { ...hookRun, status: "running" }, + threadId: "thread-1", + turnId: "turn-1", + }); + await handleOfficialNotification(bridge, context, "hook/completed", { + run: { ...hookRun, completedAt: 2, durationMs: 1, status: "completed" }, + threadId: "thread-1", + turnId: "turn-1", + }); + await handleOfficialNotification(bridge, context, "item/autoApprovalReview/started", { + action, + review: { status: "inProgress" }, + reviewId: "review-1", + startedAtMs: 1, + targetItemId: "tool-1", + threadId: "thread-1", + turnId: "turn-1", + }); + await handleOfficialNotification(bridge, context, "item/autoApprovalReview/completed", { + action, + completedAtMs: 2, + decisionSource: "agent", + review: { riskLevel: "low", status: "approved", userAuthorization: "high" }, + reviewId: "review-1", + startedAtMs: 1, + targetItemId: "tool-1", + threadId: "thread-1", + turnId: "turn-1", + }); + + expect(events().map((event) => event.kind)).toEqual([ + "hook.started", + "hook.completed", + "permission.review.started", + "permission.review.completed", + ]); + expect(events()[2]?.payload).toMatchObject({ reviewId: "review-1", targetItemId: "tool-1" }); + }); + + test("preserves bounded permission profiles in automatic approval reviews", async () => { + const { bridge, context, events } = createHarness(); + const permissions = { + fileSystem: { read: ["/workspace"], write: ["/tmp"] }, + network: { enabled: true }, + }; + + await handleOfficialNotification(bridge, context, "item/autoApprovalReview/started", { + action: { permissions, reason: "Inspect generated files", type: "requestPermissions" }, + review: { status: "inProgress" }, + reviewId: "review-permissions", + startedAtMs: 1, + targetItemId: null, + threadId: "thread-1", + turnId: "turn-1", + }); + + expect(events()[0]?.payload).toMatchObject({ + action: { + permissions, + reason: "Inspect generated files", + type: "requestPermissions", + }, + }); + + await handleOfficialNotification(bridge, context, "item/autoApprovalReview/started", { + action: { + approvalId: "approval-1", + cwd: "/workspace", + processId: "process-1", + stdin: "secret input\n", + type: "writeStdin", + }, + review: { status: "inProgress" }, + reviewId: "review-stdin", + startedAtMs: 2, + targetItemId: "command-1", + threadId: "thread-1", + turnId: "turn-1", + }); + expect(events()[1]?.payload).toMatchObject({ + action: { + approvalId: "approval-1", + cwd: "/workspace", + processId: "process-1", + stdinUtf8Bytes: 13, + type: "writeStdin", + }, + }); + await handleOfficialNotification(bridge, context, "item/autoApprovalReview/completed", { + action: { + approvalId: "approval-1", + cwd: "/workspace", + processId: "process-1", + stdin: "secret input\n", + type: "writeStdin", + }, + completedAtMs: 3, + decisionSource: "agent", + review: { + rationale: "The terminal input contains secret input", + riskLevel: "high", + status: "denied", + userAuthorization: "high", + }, + reviewId: "review-stdin", + startedAtMs: 2, + targetItemId: "command-1", + threadId: "thread-1", + turnId: "turn-1", + }); + expect(events()[2]?.payload).toMatchObject({ + review: { riskLevel: "high", status: "denied", userAuthorization: "high" }, + }); + expect(JSON.stringify(events().slice(1))).not.toContain("secret input"); + }); + + test("keeps mapped MCP progress and terminal interaction visible", async () => { + const { bridge, context, events } = createHarness(); + const nativeItemId = `mcp-${"x".repeat(300)}`; + const nativeCommandId = `command-${"y".repeat(300)}`; + + await handleOfficialNotification(bridge, context, "item/mcpToolCall/progress", { + itemId: "unknown-mcp", + message: "ignored", + threadId: "thread-1", + turnId: "turn-1", + }); + expect(events()).toEqual([]); + await handleOfficialNotification(bridge, context, "item/started", { + item: { + arguments: { path: "src" }, + id: nativeItemId, + server: "filesystem", + status: "inProgress", + tool: "inspect", + type: "mcpToolCall", + }, + startedAtMs: 1, + threadId: "thread-1", + turnId: "turn-1", + }); + const publicToolCallId = readEventPayloadString( + events().find((event) => event.kind === "item.started")!, + "itemId", + ); + expect(publicToolCallId).not.toBeNull(); + expect(publicToolCallId).not.toBe(nativeItemId); + + await handleOfficialNotification(bridge, context, "item/mcpToolCall/progress", { + itemId: nativeItemId, + message: "Reading project files", + threadId: "thread-1", + turnId: "turn-1", + }); + await handleOfficialNotification(bridge, context, "item/commandExecution/terminalInteraction", { + itemId: "unknown-command", + processId: "process-1", + stdin: "ignored\n", + threadId: "thread-1", + turnId: "turn-1", + }); + expect(events().some((event) => event.kind === "shell.command.updated")).toBe(false); + await handleOfficialNotification(bridge, context, "item/started", { + item: { + aggregatedOutput: null, + command: "printf done", + commandActions: [], + cwd: "/workspace", + durationMs: null, + exitCode: null, + id: nativeCommandId, + pluginId: null, + processId: null, + scriptPath: null, + source: "agent", + status: "inProgress", + type: "commandExecution", + }, + startedAtMs: 1, + threadId: "thread-1", + turnId: "turn-1", + }); + const publicCommandId = readEventPayloadString( + events() + .filter((event) => event.kind === "item.started") + .at(-1)!, + "itemId", + ); + expect(publicCommandId).not.toBe(nativeCommandId); + await handleOfficialNotification(bridge, context, "item/commandExecution/terminalInteraction", { + itemId: nativeCommandId, + processId: "process-1", + stdin: "y\n", + threadId: "thread-1", + turnId: "turn-1", + }); + + expect( + events().find( + (event) => + event.kind === "tool.call.updated" && + readEventPayloadString(event, "rawOutput") === "Reading project files", + ), + ).toMatchObject({ + delivery: "best_effort", + payload: { + status: "running", + toolCallId: publicToolCallId, + }, + }); + expect(events().at(-1)).toMatchObject({ + delivery: "best_effort", + kind: "shell.command.updated", + payload: { + itemId: publicCommandId, + processId: "process-1", + status: "running", + threadId: "thread-1", + turnId: "turn-1", + }, + }); + expect(JSON.stringify(events())).not.toContain("y\\n"); + }); + + test.each([ + ["modelProvider/authRecoveryStarted", "Refreshing Amazon Bedrock credentials.", "started"], + ["modelProvider/authRecoveryCompleted", "Amazon Bedrock credentials refreshed.", "completed"], + ] as const)("publishes %s independently", async (method, message, status) => { + const { bridge, context, events } = createHarness(); + + await handleOfficialNotification(bridge, context, method, { + message, + provider: "Amazon Bedrock", + threadId: "thread-1", + turnId: "turn-1", + }); + + const published = events(); + expect(published).toMatchObject([ + { + delivery: "best_effort", + kind: "message.added", + payload: { + content: message, + level: "info", + provider: "Amazon Bedrock", + subtype: `model_provider_auth_recovery_${status}`, + }, + }, + ]); + expect(published[0]?.payload).not.toHaveProperty("threadId"); + expect(published[0]?.payload).not.toHaveProperty("turnId"); + }); + + test("publishes model, MCP server, and user-facing warning notifications", async () => { + const { bridge, context, events } = createHarness(); + + await handleOfficialNotification(bridge, context, "mcpServer/startupStatus/updated", { + error: null, + failureReason: null, + name: "filesystem", + status: "ready", + threadId: "thread-1", + }); + await handleOfficialNotification(bridge, context, "model/rerouted", { + fromModel: "gpt-a", + reason: "highRiskCyberActivity", + threadId: "thread-1", + toModel: "gpt-b", + turnId: "turn-1", + }); + await handleOfficialNotification(bridge, context, "model/verification", { + threadId: "thread-1", + turnId: "turn-1", + verifications: [], + }); + await handleOfficialNotification(bridge, context, "model/safetyBuffering/updated", { + fasterModel: null, + model: "gpt-b", + reasons: ["policy"], + showBufferingUi: true, + threadId: "thread-1", + turnId: "turn-1", + useCases: ["agent"], + }); + await handleOfficialNotification(bridge, context, "warning", { + message: "Provider warning", + threadId: "thread-1", + }); + await handleOfficialNotification(bridge, context, "guardianWarning", { + message: "Guardian warning", + threadId: "thread-1", + }); + await handleOfficialNotification(bridge, context, "windows/worldWritableWarning", { + extraCount: 2, + failedScan: true, + samplePaths: ["C:\\unsafe-one", "C:\\unsafe-two"], + }); + + expect(events().map((event) => event.kind)).toEqual([ + "mcp.server.updated", + "model.routing.updated", + "model.verification.updated", + "model.routing.updated", + "message.added", + "message.added", + "message.added", + ]); + expect( + events() + .slice(-3) + .map((event) => event.payload), + ).toMatchObject([ + { content: "Provider warning", level: "warning" }, + { content: "Guardian warning", level: "warning" }, + { + extraCount: 2, + failedScan: true, + level: "warning", + samplePaths: ["C:\\unsafe-one", "C:\\unsafe-two"], + subtype: "windows_world_writable_warning", + }, + ]); + const worldWritableContent = readEventPayloadString(events().at(-1)!, "content"); + expect(worldWritableContent).toContain("C:\\unsafe-one"); + expect(worldWritableContent).toContain("2 additional affected paths"); + expect(worldWritableContent).toContain("scan did not complete"); }); }); diff --git a/tests/openai-app-server-event-bridge-run-lifecycle.test.ts b/tests/openai-app-server-event-bridge-run-lifecycle.test.ts index 4c22871..86acd0a 100644 --- a/tests/openai-app-server-event-bridge-run-lifecycle.test.ts +++ b/tests/openai-app-server-event-bridge-run-lifecycle.test.ts @@ -1,94 +1,15 @@ import { describe, expect, test } from "bun:test"; -import type { AgentDriverContext } from "../src/core/agent-driver-backend"; -import { createAgentDriverContext } from "../src/core/agent-driver-backend"; -import { createBufferedSinkLogger } from "../src/observability"; -import type { DriverEventInput } from "../src/protocol/events"; -import { isDriverId } from "../src/protocol/id"; -import { OpenAiAppServerEventBridge } from "../src/runtimes/openai/app-server-event-bridge"; -import { DRIVER_TEST_IDS, driverStartInput as bootPayload } from "./driver-boot-payload-fixture"; - -interface EventBatch { - events: DriverEventInput[]; - reason: string; -} - -function readEventPayloadString(event: DriverEventInput, field: string): string | null { - const payload = event.payload; - - if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { - return null; - } - - const value = (payload as Record)[field]; - return typeof value === "string" ? value : null; -} - -function readAssistantMessageId(events: readonly DriverEventInput[]): string { - for (const event of events) { - const messageId = - readEventPayloadString(event, "messageId") ?? - readEventPayloadString(event, "parentMessageId"); - - if (messageId !== null) { - expect(isDriverId(messageId)).toBe(true); - return messageId; - } - } - - throw new Error("Expected a platform assistant message ID."); -} - -function createHarness(options: { failNativeResumePublish?: boolean; holdReason?: string } = {}) { - const batches: EventBatch[] = []; - const heldPush = Promise.withResolvers(); - const releasePush = Promise.withResolvers(); - const logger = createBufferedSinkLogger({ - level: "debug", - service: "openai-app-server-event-bridge-test", - sink: async () => {}, - }); - const context: AgentDriverContext = createAgentDriverContext({ - eventSink: { - pushEvents: async () => ({ accepted: [] }), - }, - logger, - payload: bootPayload, - permission: { - request: async () => "allow_once", - }, - }); - const bridge = new OpenAiAppServerEventBridge({ - push: async (_context, reason, events) => { - batches.push({ events, reason }); - if (reason === options.holdReason) { - heldPush.resolve(); - await releasePush.promise; - } - if ( - options.failNativeResumePublish === true && - reason === "driver.openai.native_resume_ref.updated" - ) { - throw new Error("event sink unavailable"); - } - }, - requireThreadId: () => "thread-1", - }); - - return { - batches, - bridge, - context, - events: () => batches.flatMap((batch) => batch.events), - heldPush: heldPush.promise, - logger, - releasePush: releasePush.resolve, - }; -} +import { DRIVER_TEST_IDS } from "./driver-boot-payload-fixture"; +import { + createOpenAiBridgeHarness as createHarness, + readAssistantMessageId, + readEventPayloadString, +} from "./openai-app-server-event-bridge-fixture"; describe("OpenAi app-server event bridge", () => { test("publishes a lossless completed assistant snapshot", async () => { - const { bridge, context, events, logger } = createHarness(); + const { bridge, context, events } = createHarness(); const finalText = "I will inspect the files."; await bridge.handleNotification(context, "item/agentMessage/delta", { @@ -106,7 +27,6 @@ describe("OpenAi app-server event bridge", () => { threadId: "thread-1", turnId: "turn-1", }); - await logger.destroy(); const assistantMessageId = readAssistantMessageId(events()); const snapshot = events().find((event) => event.kind === "message.added"); @@ -122,8 +42,163 @@ describe("OpenAi app-server event bridge", () => { expect(snapshot?.delivery).toBe("lossless"); }); + test("preserves final phase and memory citations on completed snapshots", async () => { + const { bridge, context, events } = createHarness(); + const memoryCitation = { + entries: [{ lineEnd: 8, lineStart: 4, note: "Relevant context", path: "MEMORY.md" }], + threadIds: ["thread-memory"], + }; + + await bridge.handleNotification(context, "item/completed", { + item: { + id: "message-final", + memoryCitation, + phase: "final_answer", + text: "Final answer", + type: "agentMessage", + }, + threadId: "thread-1", + turnId: "turn-1", + }); + + expect(events().find((event) => event.kind === "message.added")).toMatchObject({ + payload: { memoryCitation, phase: "final" }, + }); + expect(events().find((event) => event.kind === "message.delta")?.payload).not.toHaveProperty( + "phase", + ); + }); + + test("selects only final-phase messages once a turn uses explicit phases", async () => { + const { bridge, context, events } = createHarness(); + + await bridge.handleNotification(context, "turn/completed", { + threadId: "thread-1", + turn: { + id: "turn-1", + items: [ + { id: "legacy", memoryCitation: null, phase: null, text: "Legacy", type: "agentMessage" }, + { + id: "final", + memoryCitation: null, + phase: "final_answer", + text: "Authoritative final", + type: "agentMessage", + }, + { + id: "commentary", + memoryCitation: null, + phase: "commentary", + text: "Later commentary", + type: "agentMessage", + }, + { + delivery: "async", + id: "async-progress", + memoryCitation: null, + phase: "final_answer", + text: "Asynchronous progress", + type: "agentMessage", + }, + ], + status: "completed", + }, + }); + + const allEvents = events(); + const finalSnapshot = allEvents.find( + (event) => + event.kind === "message.added" && + readEventPayloadString(event, "content") === "Authoritative final", + ); + const runCompleted = allEvents.find((event) => event.kind === "run.completed"); + + expect(finalSnapshot).toBeDefined(); + expect(runCompleted).toBeDefined(); + expect(readEventPayloadString(runCompleted!, "finalMessageId")).toBe( + readEventPayloadString(finalSnapshot!, "messageId"), + ); + expect(runCompleted!.payload).not.toHaveProperty("finalMessageText"); + }); + + test("keeps asynchronous progress out of legacy final selection", async () => { + const { bridge, context, events } = createHarness(); + + await bridge.handleNotification(context, "turn/completed", { + threadId: "thread-1", + turn: { + id: "turn-1", + items: [ + { + id: "legacy", + memoryCitation: null, + phase: null, + text: "Legacy final", + type: "agentMessage", + }, + { + delivery: "async", + id: "async-progress", + memoryCitation: null, + phase: "final_answer", + text: "Asynchronous progress", + type: "agentMessage", + }, + ], + status: "completed", + }, + }); + + const allEvents = events(); + const finalSnapshot = allEvents.find( + (event) => + event.kind === "message.added" && + readEventPayloadString(event, "content") === "Legacy final", + ); + const asyncSnapshot = allEvents.find( + (event) => + event.kind === "message.added" && + readEventPayloadString(event, "content") === "Asynchronous progress", + ); + const runCompleted = allEvents.find((event) => event.kind === "run.completed"); + + expect(readEventPayloadString(runCompleted!, "finalMessageId")).toBe( + readEventPayloadString(finalSnapshot!, "messageId"), + ); + expect(asyncSnapshot?.payload).toMatchObject({ phase: "commentary" }); + }); + + test("does not fall back to a legacy snapshot when explicit commentary exists", async () => { + const { bridge, context, events } = createHarness(); + + for (const item of [ + { id: "legacy", memoryCitation: null, phase: null, text: "Legacy", type: "agentMessage" }, + { + id: "commentary", + memoryCitation: null, + phase: "commentary", + text: "Commentary", + type: "agentMessage", + }, + ]) { + await bridge.handleNotification(context, "item/completed", { + item, + threadId: "thread-1", + turnId: "turn-1", + }); + } + await bridge.handleNotification(context, "turn/completed", { + threadId: "thread-1", + turn: { id: "turn-1", items: [], itemsView: "notLoaded", status: "completed" }, + }); + + const terminalPayload = events().find((event) => event.kind === "run.completed")?.payload; + expect(terminalPayload).not.toHaveProperty("finalMessageId"); + expect(terminalPayload).not.toHaveProperty("finalMessageText"); + }); + test("fails an active turn exactly once when the provider exits", async () => { - const { bridge, context, events, logger } = createHarness(); + const { bridge, context, events } = createHarness(); const failure = new Error("app-server exited"); const completion = bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); void completion.catch(() => {}); @@ -142,9 +217,8 @@ describe("OpenAi app-server event bridge", () => { }); await expect(bridge.failActiveTurns(context, failure)).resolves.toBe(true); - await expect(completion).rejects.toBe(failure); + await expect(completion).rejects.toThrow("app-server exited"); await expect(bridge.failActiveTurns(context, failure)).resolves.toBe(false); - await logger.destroy(); expect( events().filter((event) => @@ -176,8 +250,24 @@ describe("OpenAi app-server event bridge", () => { ]); }); + test("replays run start after delivery rejection before committing deduplication", async () => { + const { attempts, bridge, context } = createHarness({ + failReasonOnce: "driver.openai.turn.started", + }); + const started = { runId: DRIVER_TEST_IDS.runId, turnId: "turn-1" } as const; + + await expect(bridge.publishRunStarted(context, started)).rejects.toThrow("first attempt"); + await bridge.publishRunStarted(context, started); + + const starts = attempts.flatMap(({ events }) => + events.filter((event) => event.kind === "run.started"), + ); + expect(starts).toHaveLength(2); + expect(starts[0]!.sourceEventId).toBe(starts[1]!.sourceEventId); + }); + test("final turn items do not duplicate already completed messages", async () => { - const { bridge, context, events, logger } = createHarness(); + const { bridge, context, events } = createHarness(); await bridge.handleNotification(context, "item/completed", { item: { @@ -202,7 +292,6 @@ describe("OpenAi app-server event bridge", () => { status: "completed", }, }); - await logger.destroy(); const assistantMessageId = readAssistantMessageId(events()); expect(events()).toMatchObject([ @@ -213,15 +302,6 @@ describe("OpenAi app-server event bridge", () => { role: "agent", }, }, - { - delivery: "best_effort", - kind: "message.delta", - payload: { - contentDelta: "pong", - messageId: assistantMessageId, - role: "agent", - }, - }, { delivery: "lossless", kind: "message.added", @@ -251,6 +331,10 @@ describe("OpenAi app-server event bridge", () => { threadId: "thread-1", }, }, + { + kind: "agent.tasks.replaced", + payload: { tasks: [] }, + }, { kind: "run.completed", payload: { @@ -260,8 +344,8 @@ describe("OpenAi app-server event bridge", () => { ]); }); - test("rotates assistant message identity after each completed agent item", async () => { - const { bridge, context, events, logger } = createHarness(); + test("isolates assistant items from the turn-scoped tool parent", async () => { + const { bridge, context, events } = createHarness(); const progressMessages = [ "进度 1:已完成读取。", @@ -289,6 +373,7 @@ describe("OpenAi app-server event bridge", () => { await bridge.handleNotification(context, "item/started", { item: { id: "artifact-tool", + status: "inProgress", type: "commandExecution", }, threadId: "thread-1", @@ -298,6 +383,7 @@ describe("OpenAi app-server event bridge", () => { item: { aggregatedOutput: "artifact 已创建。", id: "artifact-tool", + status: "completed", type: "commandExecution", }, threadId: "thread-1", @@ -331,6 +417,7 @@ describe("OpenAi app-server event bridge", () => { { aggregatedOutput: "artifact 已创建。", id: "artifact-tool", + status: "completed", type: "commandExecution", }, { @@ -342,7 +429,6 @@ describe("OpenAi app-server event bridge", () => { status: "completed", }, }); - await logger.destroy(); const assistantMessages = events().flatMap((event) => { if (event.kind !== "message.delta") { @@ -364,13 +450,15 @@ describe("OpenAi app-server event bridge", () => { .map((event) => readEventPayloadString(event, "parentMessageId")) .find((messageId): messageId is string => messageId !== null); - expect(toolParentMessageId).toBe(assistantMessages.at(-1)?.messageId); - expect(events().filter((event) => event.kind === "message.completed")).toHaveLength(4); + expect(assistantMessages.some(({ messageId }) => messageId === toolParentMessageId)).toBe( + false, + ); + expect(events().filter((event) => event.kind === "message.completed")).toHaveLength(5); expect(events().filter((event) => event.kind === "run.completed")).toHaveLength(1); }); test("keeps interleaved assistant item identities isolated and ignores late replay", async () => { - const { bridge, context, events, logger } = createHarness(); + const { bridge, context, events } = createHarness(); await bridge.handleNotification(context, "item/agentMessage/delta", { delta: "消息甲", @@ -411,7 +499,6 @@ describe("OpenAi app-server event bridge", () => { status: "completed", }, }); - await logger.destroy(); const deltas = events().flatMap((event) => { if (event.kind !== "message.delta") { @@ -435,11 +522,11 @@ describe("OpenAi app-server event bridge", () => { const finalMessageId = readEventPayloadString(runCompleted, "finalMessageId"); expect(finalMessageId).toBe(deltas.find((entry) => entry.contentDelta === "消息乙")?.messageId); - expect(readEventPayloadString(runCompleted, "finalMessageText")).toBe("消息乙"); + expect(runCompleted.payload).not.toHaveProperty("finalMessageText"); }); test("command output streams as tool result content", async () => { - const { bridge, context, events, logger } = createHarness(); + const { bridge, context, events } = createHarness(); await bridge.handleNotification(context, "item/started", { item: { @@ -461,7 +548,6 @@ describe("OpenAi app-server event bridge", () => { threadId: "thread-1", turnId: "turn-1", }); - await logger.destroy(); const assistantMessageId = readAssistantMessageId(events()); expect(events()).toMatchObject([ @@ -494,9 +580,8 @@ describe("OpenAi app-server event bridge", () => { { kind: "tool.call.updated", payload: { - content: "hello", messageId: assistantMessageId, - rawOutput: "hello", + rawOutputDelta: "hello", status: "running", toolCallId: "cmd-1", }, @@ -504,19 +589,41 @@ describe("OpenAi app-server event bridge", () => { { kind: "tool.call.updated", payload: { - content: " world", messageId: assistantMessageId, - rawOutput: " world", + rawOutputDelta: " world", status: "running", toolCallId: "cmd-1", }, }, ]); expect(events().some((event) => event.kind === "item.updated")).toBe(false); + + await bridge.handleNotification(context, "item/completed", { + item: { + aggregatedOutput: "hello world", + command: "printf 'hello world'", + id: "cmd-1", + status: "completed", + type: "commandExecution", + }, + threadId: "thread-1", + turnId: "turn-1", + }); + + const terminal = events() + .filter( + (event) => + event.kind === "tool.call.updated" && + readEventPayloadString(event, "toolCallId") === "cmd-1" && + readEventPayloadString(event, "status") === "completed", + ) + .at(-1); + expect(terminal?.payload).toMatchObject({ rawOutput: "hello world" }); + expect(terminal?.payload).not.toHaveProperty("rawOutputDelta"); }); test("turn plan updates map to the session plan custom event", async () => { - const { bridge, context, events, logger } = createHarness(); + const { bridge, context, events } = createHarness(); await bridge.handleNotification(context, "turn/plan/updated", { explanation: null, @@ -533,7 +640,21 @@ describe("OpenAi app-server event bridge", () => { threadId: "thread-1", turnId: "turn-1", }); - await logger.destroy(); + await bridge.handleNotification(context, "turn/plan/updated", { + explanation: null, + plan: [ + { + status: "completed", + step: "Inspect stream events", + }, + { + status: "completed", + step: "Patch bridge", + }, + ], + threadId: "thread-1", + turnId: "turn-1", + }); expect(events()).toEqual([ { @@ -554,6 +675,24 @@ describe("OpenAi app-server event bridge", () => { source: "driver", }, }, + { + kind: "plan.updated", + payload: { + entries: [ + { + content: "Inspect stream events", + priority: "medium", + status: "completed", + }, + { + content: "Patch bridge", + priority: "medium", + status: "completed", + }, + ], + source: "driver", + }, + }, ]); }); }); diff --git a/tests/openai-app-server-event-bridge-terminal.test.ts b/tests/openai-app-server-event-bridge-terminal.test.ts index 37f7ebb..59110db 100644 --- a/tests/openai-app-server-event-bridge-terminal.test.ts +++ b/tests/openai-app-server-event-bridge-terminal.test.ts @@ -2,92 +2,270 @@ import { describe, expect, test } from "bun:test"; import type { AgentDriverContext } from "../src/core/agent-driver-backend"; import { createAgentDriverContext } from "../src/core/agent-driver-backend"; -import { createBufferedSinkLogger } from "../src/observability"; +import type { AgentDriverPermissionPort, DriverPermissionRequest } from "../src/host-ports"; +import { toDriverEventEnvelopes } from "../src/infrastructure/runtime/driver-instance-socket"; +import { createDisabledLogger } from "../src/observability"; import type { DriverEventInput } from "../src/protocol/events"; import { isDriverId } from "../src/protocol/id"; +import { DriverEventPublisher } from "../src/runtimes/driver-event-publisher"; import { OpenAiAppServerEventBridge } from "../src/runtimes/openai/app-server-event-bridge"; +import { createRuntimeSourceEventId } from "../src/runtimes/runtime-public-id"; +import { + parseServerNotification, + parseServerRequest, +} from "../src/runtimes/openai/app-server-protocol-server"; +import { OpenAiAppServerRequestHandler } from "../src/runtimes/openai/app-server-request-handler"; +import { createCmaMemoryStore } from "../src/stores/memory"; import { DRIVER_TEST_IDS } from "./driver-boot-payload-fixture"; -import { driverStartInput as bootPayload } from "./driver-boot-payload-fixture"; +import { driverBootPayload, driverStartInput as bootPayload } from "./driver-boot-payload-fixture"; +import { + createOpenAiBridgeHarness as createHarness, + readAssistantMessageId, + readEventPayloadString, +} from "./openai-app-server-event-bridge-fixture"; -interface EventBatch { - events: DriverEventInput[]; - reason: string; -} - -function readEventPayloadString(event: DriverEventInput, field: string): string | null { - const payload = event.payload; - - if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { - return null; - } - - const value = (payload as Record)[field]; - return typeof value === "string" ? value : null; -} - -function readAssistantMessageId(events: readonly DriverEventInput[]): string { - for (const event of events) { - const messageId = - readEventPayloadString(event, "messageId") ?? - readEventPayloadString(event, "parentMessageId"); - - if (messageId !== null) { - expect(isDriverId(messageId)).toBe(true); - return messageId; - } - } +function expectRunFinalReferences( + events: readonly DriverEventInput[], + expectedContent: string, +): void { + const terminal = events.find((event) => event.kind === "run.completed"); + const messageId = + terminal === undefined ? null : readEventPayloadString(terminal, "finalMessageId"); + const snapshots = events.filter( + (event) => + event.delivery === "lossless" && + readEventPayloadString(event, "messageId") === messageId && + (event.kind === "message.added" || event.kind === "message.delta"), + ); + const content = snapshots + .map((event) => + readEventPayloadString(event, event.kind === "message.added" ? "content" : "contentDelta"), + ) + .join(""); - throw new Error("Expected a platform assistant message ID."); + expect(terminal).toBeDefined(); + expect(messageId).not.toBeNull(); + expect(snapshots[0]?.kind).toBe("message.added"); + expect(content).toBe(expectedContent); + const lastSnapshotIndex = events.findLastIndex( + (event) => + readEventPayloadString(event, "messageId") === messageId && + (event.kind === "message.added" || event.kind === "message.delta"), + ); + const sealIndex = events.findIndex( + (event, index) => + index > lastSnapshotIndex && + event.kind === "message.completed" && + readEventPayloadString(event, "messageId") === messageId, + ); + expect(sealIndex).toBeGreaterThan(lastSnapshotIndex); + expect(events.findIndex((event) => event === terminal)).toBeGreaterThan(sealIndex); + expect(terminal!.payload).not.toHaveProperty("finalMessageText"); } -function createHarness(options: { failNativeResumePublish?: boolean; holdReason?: string } = {}) { - const batches: EventBatch[] = []; - const heldPush = Promise.withResolvers(); - const releasePush = Promise.withResolvers(); - const logger = createBufferedSinkLogger({ - level: "debug", - service: "openai-app-server-event-bridge-test", - sink: async () => {}, - }); +function createPublisherHarness( + options: { + partialSourcePrefix?: string; + requestPermission?: AgentDriverPermissionPort["request"]; + threadId?: string; + } = {}, +) { + const threadId = options.threadId ?? "thread-1"; + const delivered: DriverEventInput[] = []; + const partialAttempts: DriverEventInput[][] = []; + const store = createCmaMemoryStore({ sessions: [{ id: DRIVER_TEST_IDS.sessionId }] }); + let nextSeq = 1; const context: AgentDriverContext = createAgentDriverContext({ eventSink: { - pushEvents: async () => ({ accepted: [] }), + currentRunId: () => DRIVER_TEST_IDS.runId, + pushEvents: async ({ events }) => { + const partialSourcePrefix = options.partialSourcePrefix; + const isPartialAttempt = + partialSourcePrefix !== undefined && + events[0]?.sourceEventId?.startsWith(partialSourcePrefix) === true; + + if (isPartialAttempt) { + partialAttempts.push(events); + if (partialAttempts.length === 2) { + throw new Error("transient snapshot delivery failure"); + } + } + + const acceptedEvents = + isPartialAttempt && partialAttempts.length === 1 ? events.slice(0, 1) : events; + for (const event of acceptedEvents) { + for (const envelope of toDriverEventEnvelopes( + driverBootPayload, + event, + DRIVER_TEST_IDS.runId, + )) { + await store.appendDriverEvent(DRIVER_TEST_IDS.sessionId, envelope.event); + } + } + delivered.push(...acceptedEvents); + return { + accepted: acceptedEvents.map((event) => ({ + eventId: event.sourceEventId!, + seq: nextSeq++, + type: event.kind, + })), + }; + }, }, - logger, + logger: createDisabledLogger(), payload: bootPayload, permission: { - request: async () => "allow_once", + request: options.requestPermission ?? (async () => "allow_once"), }, }); + const publisher = new DriverEventPublisher("openai-runtime", () => threadId); const bridge = new OpenAiAppServerEventBridge({ - push: async (_context, reason, events) => { - batches.push({ events, reason }); - if (reason === options.holdReason) { - heldPush.resolve(); - await releasePush.promise; - } - if ( - options.failNativeResumePublish === true && - reason === "driver.openai.native_resume_ref.updated" - ) { - throw new Error("event sink unavailable"); - } - }, - requireThreadId: () => "thread-1", - }); - - return { - batches, - bridge, - context, - events: () => batches.flatMap((batch) => batch.events), - heldPush: heldPush.promise, - logger, - releasePush: releasePush.resolve, - }; + push: (pushContext, reason, events) => publisher.push(pushContext, reason, events), + pushSession: (pushContext, reason, events) => + publisher.pushSession(pushContext, reason, events), + pushTerminal: (pushContext, reason, closures, terminal) => + publisher.pushTerminal(pushContext, reason, closures, terminal), + requireThreadId: () => threadId, + }); + + return { bridge, context, delivered, partialAttempts }; } describe("OpenAi app-server event bridge", () => { + test("reports monotonic per-turn usage from cumulative thread totals", async () => { + const harness = createHarness(); + const trackedTurn = harness.bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); + void trackedTurn.catch(() => {}); + const notify = async (last: readonly number[], total: readonly number[]) => { + const usage = (values: readonly number[]) => ({ + cacheWriteInputTokens: values[0]!, + cachedInputTokens: values[1]!, + inputTokens: values[2]!, + outputTokens: values[3]!, + reasoningOutputTokens: values[4]!, + totalTokens: values[5]!, + }); + + await harness.bridge.handleNotification(harness.context, "thread/tokenUsage/updated", { + threadId: "thread-1", + tokenUsage: { last: usage(last), modelContextWindow: 200_000, total: usage(total) }, + turnId: "turn-1", + }); + }; + + await notify([3, 2, 10, 4, 1, 14], [33, 22, 110, 54, 11, 164]); + await notify([4, 1, 3, 2, 1, 5], [37, 23, 113, 56, 12, 169]); + await notify([1, 0, 1, 1, 0, 2], [34, 22, 111, 55, 11, 165]); + + expect( + harness + .events() + .filter((event) => event.kind === "usage.updated") + .map((event) => event.payload), + ).toEqual([ + expect.objectContaining({ + cachedReadTokens: 2, + cachedWriteTokens: 3, + inputTokens: 10, + outputTokens: 4, + thoughtTokens: 1, + totalTokens: 14, + }), + expect.objectContaining({ + cachedReadTokens: 3, + cachedWriteTokens: 7, + inputTokens: 13, + outputTokens: 6, + thoughtTokens: 2, + totalTokens: 19, + }), + ]); + harness.bridge.rejectTurn("turn-1", new Error("test complete")); + }); + + test("replays usage after delivery rejection before committing its baseline", async () => { + const harness = createHarness({ failReasonOnce: "driver.openai.usage.updated" }); + const trackedTurn = harness.bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); + void trackedTurn.catch(() => {}); + const params = { + threadId: "thread-1", + tokenUsage: { + last: { + cacheWriteInputTokens: 0, + cachedInputTokens: 0, + inputTokens: 10, + outputTokens: 4, + reasoningOutputTokens: 1, + totalTokens: 14, + }, + modelContextWindow: 200_000, + total: { + cacheWriteInputTokens: 0, + cachedInputTokens: 0, + inputTokens: 10, + outputTokens: 4, + reasoningOutputTokens: 1, + totalTokens: 14, + }, + }, + turnId: "turn-1", + } as const; + + await expect( + harness.bridge.handleNotification(harness.context, "thread/tokenUsage/updated", params), + ).rejects.toThrow("first attempt"); + await harness.bridge.handleNotification(harness.context, "thread/tokenUsage/updated", params); + + expect(harness.events().filter((event) => event.kind === "usage.updated")).toHaveLength(1); + harness.bridge.rejectTurn("turn-1", new Error("test complete")); + }); + + test("attributes cumulative usage growth across turns without replaying the previous total", async () => { + const harness = createHarness(); + const usage = (totalTokens: number) => ({ + cacheWriteInputTokens: 0, + cachedInputTokens: 0, + inputTokens: totalTokens, + outputTokens: 0, + reasoningOutputTokens: 0, + totalTokens, + }); + const notify = async (turnId: string, last: number, total: number) => { + await harness.bridge.handleNotification(harness.context, "thread/tokenUsage/updated", { + threadId: "thread-1", + tokenUsage: { + last: usage(last), + modelContextWindow: 200_000, + total: usage(total), + }, + turnId, + }); + }; + + await notify("resume-snapshot", 20, 100); + + const turn1 = harness.bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); + void turn1.catch(() => {}); + await notify("turn-1", 30, 130); + harness.bridge.rejectTurn("turn-1", new Error("turn complete")); + + const turn2 = harness.bridge.trackTurn("turn-2", DRIVER_TEST_IDS.runId); + void turn2.catch(() => {}); + await notify("turn-2", 30, 130); + await notify("turn-2", 30, 160); + + expect( + harness + .events() + .filter((event) => event.kind === "usage.updated") + .map((event) => event.payload), + ).toEqual([ + expect.objectContaining({ size: 200_000, totalTokens: 30, used: 30 }), + expect.objectContaining({ size: 200_000, totalTokens: 30, used: 30 }), + ]); + harness.bridge.rejectTurn("turn-2", new Error("test complete")); + }); + test.each([ ["completed", "run.completed", true], ["failed", "run.failed", false], @@ -95,7 +273,7 @@ describe("OpenAi app-server event bridge", () => { ] as const)( "%s turns never emit resume metadata after the terminal event", async (outcome, terminalKind, publishesResume) => { - const { bridge, context, events, logger } = createHarness(); + const { bridge, context, events } = createHarness(); const trackedTurn = bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); void trackedTurn.catch(() => {}); @@ -127,7 +305,6 @@ describe("OpenAi app-server event bridge", () => { expect(resumeIndexes).toHaveLength(publishesResume ? 1 : 0); expect(resumeIndexes.every((index) => index < terminalIndex)).toBe(true); expect(allEvents.slice(terminalIndex + 1)).toEqual([]); - await logger.destroy(); }, ); @@ -140,6 +317,7 @@ describe("OpenAi app-server event bridge", () => { threadId: "thread-1", tokenUsage: { last: { + cacheWriteInputTokens: 3, cachedInputTokens: 2, inputTokens: 10, outputTokens: 4, @@ -148,6 +326,7 @@ describe("OpenAi app-server event bridge", () => { }, modelContextWindow: 200_000, total: { + cacheWriteInputTokens: 3, cachedInputTokens: 2, inputTokens: 10, outputTokens: 4, @@ -165,6 +344,11 @@ describe("OpenAi app-server event bridge", () => { await harness.bridge.handleNotification(harness.context, "thread/tokenUsage/updated", usage); await harness.bridge.handleNotification(harness.context, "turn/diff/updated", diff); + expect(harness.events().find((event) => event.kind === "usage.updated")?.payload).toMatchObject( + { + cachedWriteTokens: 3, + }, + ); const completion = harness.bridge.handleNotification(harness.context, "turn/completed", { threadId: "thread-1", @@ -197,11 +381,426 @@ describe("OpenAi app-server event bridge", () => { { kind: "diagnostic.reported", runId: DRIVER_TEST_IDS.runId }, { kind: "run.completed", runId: DRIVER_TEST_IDS.runId }, ]); - await harness.logger.destroy(); + }); + + test("bounds a large turn diff before real CMA admission", async () => { + const { bridge, context, delivered } = createPublisherHarness(); + const trackedTurn = bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); + void trackedTurn.catch(() => {}); + const diff = "x".repeat(1_100_000); + + const notification = parseServerNotification({ + method: "turn/diff/updated", + params: { diff, threadId: "thread-1", turnId: "turn-1" }, + }); + expect(notification).not.toBeNull(); + await bridge.handleNotification(context, notification!.method, notification!.params); + + const diagnostic = delivered.find( + (event) => + event.kind === "diagnostic.reported" && + readEventPayloadString(event, "message") === "OpenAI turn diff updated.", + ); + expect(diagnostic).toMatchObject({ + delivery: "best_effort", + payload: { details: { utf8Bytes: 1_100_000 } }, + }); + expect(JSON.stringify(diagnostic)).not.toContain(diff.slice(0, 1_024)); + bridge.rejectTurn("turn-1", new Error("test complete")); + }); + + test("bounds non-durable official telemetry before real CMA admission", async () => { + const { bridge, context, delivered } = createPublisherHarness(); + const large = "x".repeat(1_100_000); + const notifications = [ + parseServerNotification({ + method: "hook/completed", + params: { + run: { + completedAt: 2, + displayOrder: 0, + durationMs: 1, + entries: [{ kind: "context", text: large }], + eventName: "preToolUse", + executionMode: "sync", + handlerType: "command", + id: "hook-1", + scope: "turn", + source: "user", + sourcePath: "/tmp/hook", + startedAt: 1, + status: "completed", + statusMessage: null, + }, + threadId: "thread-1", + turnId: "turn-1", + }, + }), + parseServerNotification({ + method: "item/autoApprovalReview/started", + params: { + action: { command: "echo ok", cwd: "/tmp", source: "shell", type: "command" }, + review: { rationale: large, status: "inProgress" }, + reviewId: "review-1", + startedAtMs: 1, + targetItemId: "tool-1", + threadId: "thread-1", + turnId: "turn-1", + }, + }), + parseServerNotification({ + method: "mcpServer/startupStatus/updated", + params: { + error: large, + failureReason: null, + name: "filesystem", + status: "failed", + threadId: "thread-1", + }, + }), + parseServerNotification({ + method: "model/rerouted", + params: { + fromModel: large, + reason: "highRiskCyberActivity", + threadId: "thread-1", + toModel: "gpt-b", + turnId: "turn-1", + }, + }), + parseServerNotification({ + method: "model/safetyBuffering/updated", + params: { + fasterModel: null, + model: "gpt-b", + reasons: ["policy"], + showBufferingUi: true, + threadId: "thread-1", + turnId: "turn-1", + useCases: [large], + }, + }), + ]; + expect(notifications.every((notification) => notification !== null)).toBe(true); + + for (const notification of notifications) { + await bridge.handleNotification(context, notification!.method, notification!.params); + } + + expect(delivered.map((event) => event.kind)).toEqual([ + "hook.completed", + "permission.review.started", + "mcp.server.updated", + "model.routing.updated", + "model.routing.updated", + ]); + expect(delivered.every((event) => event.delivery === "best_effort")).toBe(true); + expect(delivered.every((event) => Buffer.byteLength(JSON.stringify(event)) < 1_048_576)).toBe( + true, + ); + expect(JSON.stringify(delivered)).not.toContain(large); + }); + + test("maps official approval identities and bounds terminal telemetry before CMA", async () => { + const permission = Promise.withResolvers(); + const nativeThreadId = `thread-${"i".repeat(300)}`; + const { bridge, context, delivered } = createPublisherHarness({ + requestPermission: async (input) => { + permission.resolve(input); + return "allow_once"; + }, + threadId: nativeThreadId, + }); + const nativeItemId = `command-${"i".repeat(300)}`; + const nativeMcpItemId = `mcp-${"i".repeat(300)}`; + const nativeTurnId = `turn-${"i".repeat(300)}`; + const large = "x".repeat(1_100_000); + const trackedTurn = bridge.trackTurn(nativeTurnId, DRIVER_TEST_IDS.runId); + void trackedTurn.catch(() => {}); + const itemStarts = [ + parseServerNotification({ + method: "item/started", + params: { + item: { + aggregatedOutput: null, + command: "printf ok", + commandActions: [], + cwd: "/tmp", + durationMs: null, + exitCode: null, + id: nativeItemId, + pluginId: null, + processId: null, + scriptPath: null, + source: "agent", + status: "inProgress", + type: "commandExecution", + }, + startedAtMs: 1, + threadId: nativeThreadId, + turnId: nativeTurnId, + }, + }), + parseServerNotification({ + method: "item/started", + params: { + item: { + appContext: null, + arguments: {}, + durationMs: null, + error: null, + id: nativeMcpItemId, + pluginId: null, + readOnlyHint: null, + result: null, + server: "filesystem", + status: "inProgress", + tool: "read_file", + type: "mcpToolCall", + }, + startedAtMs: 1, + threadId: nativeThreadId, + turnId: nativeTurnId, + }, + }), + ]; + expect(itemStarts.every((notification) => notification !== null)).toBe(true); + for (const notification of itemStarts) { + await bridge.handleNotification(context, notification!.method, notification!.params); + } + + const request = parseServerRequest({ + id: 1, + method: "item/commandExecution/requestApproval", + params: { + environmentId: null, + itemId: nativeItemId, + startedAtMs: 2, + threadId: nativeThreadId, + turnId: nativeTurnId, + }, + }); + expect(request).not.toBeNull(); + const response = Promise.withResolvers(); + const errors: Error[] = []; + const handler = new OpenAiAppServerRequestHandler({ + context, + handleError: async (error) => { + errors.push(error); + }, + isStopped: () => false, + mapToolCallId: (toolCallId) => bridge.mapToolCallId(toolCallId), + respond: (_id, result) => response.resolve(result), + respondError: (_id, message) => errors.push(new Error(message)), + }); + handler.dispatch(request!.method, request!.id, request!.params); + await expect(response.promise).resolves.toEqual({ decision: "accept" }); + + const notifications = [ + parseServerNotification({ + method: "item/autoApprovalReview/started", + params: { + action: { command: "printf ok", cwd: "/tmp", source: "shell", type: "command" }, + review: { + rationale: null, + riskLevel: null, + status: "inProgress", + userAuthorization: null, + }, + reviewId: "review-1", + startedAtMs: 2, + targetItemId: nativeItemId, + threadId: nativeThreadId, + turnId: nativeTurnId, + }, + }), + parseServerNotification({ + method: "item/mcpToolCall/progress", + params: { + itemId: nativeMcpItemId, + message: large, + threadId: nativeThreadId, + turnId: nativeTurnId, + }, + }), + parseServerNotification({ + method: "item/commandExecution/terminalInteraction", + params: { + itemId: nativeItemId, + processId: large, + stdin: "", + threadId: nativeThreadId, + turnId: nativeTurnId, + }, + }), + parseServerNotification({ + method: "turn/diff/updated", + params: { + diff: "diff --git a/file b/file", + threadId: nativeThreadId, + turnId: nativeTurnId, + }, + }), + ]; + expect(notifications.every((notification) => notification !== null)).toBe(true); + for (const notification of notifications) { + await bridge.handleNotification(context, notification!.method, notification!.params); + } + + const publicItemId = bridge.mapToolCallId(nativeItemId); + const permissionInput = await permission.promise; + const review = delivered.find((event) => event.kind === "permission.review.started"); + const progress = delivered.find( + (event) => + event.kind === "tool.call.updated" && + typeof event.payload === "object" && + event.payload !== null && + !Array.isArray(event.payload) && + (event.payload as Record)["rawOutputUtf8Bytes"] === 1_100_000, + ); + const shell = delivered.find((event) => event.kind === "shell.command.updated"); + const diff = delivered.find( + (event) => + event.kind === "diagnostic.reported" && + readEventPayloadString(event, "message") === "OpenAI turn diff updated.", + ); + expect(publicItemId).not.toBe(nativeItemId); + expect(Buffer.byteLength(publicItemId, "utf8")).toBeLessThanOrEqual(256); + expect(permissionInput.toolCallId).toBe(publicItemId); + expect(readEventPayloadString(review!, "targetItemId")).toBe(publicItemId); + expect(progress?.payload).toMatchObject({ + rawOutputUtf8Bytes: 1_100_000, + status: "running", + toolCallId: expect.any(String), + }); + expect(progress?.payload).not.toHaveProperty("rawOutput"); + expect(shell?.payload).toMatchObject({ + itemId: publicItemId, + processIdUtf8Bytes: 1_100_000, + status: "running", + }); + expect(shell?.payload).not.toHaveProperty("processId"); + expect(readEventPayloadString(shell!, "threadId")).not.toBe(nativeThreadId); + expect(readEventPayloadString(shell!, "turnId")).not.toBe(nativeTurnId); + expect(readEventPayloadString(diff!, "turnId")).toBe(readEventPayloadString(shell!, "turnId")); + expect(delivered.every((event) => Buffer.byteLength(JSON.stringify(event)) < 1_048_576)).toBe( + true, + ); + expect(errors).toEqual([]); + bridge.rejectTurn(nativeTurnId, new Error("test complete")); + await handler.abortAll(new Error("test complete")); + }); + + test("chunks large user-facing warnings and bounds world-writable samples for CMA", async () => { + const { bridge, context, delivered } = createPublisherHarness(); + const large = "x".repeat(1_100_000); + const notifications = [ + parseServerNotification({ + method: "warning", + params: { message: large, threadId: "thread-1" }, + }), + parseServerNotification({ + method: "guardianWarning", + params: { message: large, threadId: "thread-1" }, + }), + parseServerNotification({ + method: "autoApprovalReview/strictReviewRequired", + params: { startedAtMs: 1, threadId: "thread-1", turnId: "turn-1" }, + }), + parseServerNotification({ + method: "windows/worldWritableWarning", + params: { extraCount: 2, failedScan: false, samplePaths: [large] }, + }), + ]; + expect(notifications.every((notification) => notification !== null)).toBe(true); + + for (const notification of notifications) { + await bridge.handleNotification(context, notification!.method, notification!.params); + } + + for (const subtype of ["warning", "guardian_warning"] as const) { + const events = delivered.filter( + (event) => readEventPayloadString(event, "subtype") === subtype, + ); + expect(events.map((event) => readEventPayloadString(event, "content")).join("")).toBe(large); + expect(events.length).toBeGreaterThan(1); + expect(events.every((event) => event.delivery === "lossless")).toBe(true); + const messageIds = new Set(events.map((event) => readEventPayloadString(event, "messageId"))); + expect(messageIds.size).toBe(events.length); + expect([...messageIds].every(isDriverId)).toBe(true); + } + const strictReview = delivered.find( + (event) => readEventPayloadString(event, "subtype") === "strict_review_required", + ); + expect(strictReview?.delivery).toBe("lossless"); + expect(strictReview?.payload).toMatchObject({ startedAtMs: 1 }); + expect(isDriverId(readEventPayloadString(strictReview!, "messageId"))).toBe(true); + const worldWritable = delivered.find( + (event) => readEventPayloadString(event, "subtype") === "windows_world_writable_warning", + ); + expect(worldWritable?.payload).toMatchObject({ extraCount: 3, samplePaths: [] }); + expect(isDriverId(readEventPayloadString(worldWritable!, "messageId"))).toBe(true); + expect(delivered.every((event) => Buffer.byteLength(JSON.stringify(event)) < 1_048_576)).toBe( + true, + ); + }); + + test("bounds an official failed-turn error before terminal CMA admission", async () => { + const { bridge, context, delivered } = createPublisherHarness(); + const trackedTurn = bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); + const large = "x".repeat(1_100_000); + const notification = parseServerNotification({ + method: "turn/completed", + params: { + threadId: "thread-1", + turn: { + completedAt: 2, + durationMs: 1, + error: { + additionalDetails: large, + codexErrorInfo: null, + message: large, + misalignment: { + detailedExplanation: large, + errorType: large, + steer: { message: large }, + }, + }, + id: "turn-1", + items: [], + itemsView: "notLoaded", + startedAt: 1, + status: "failed", + }, + }, + }); + expect(notification).not.toBeNull(); + + await bridge.handleNotification(context, notification!.method, notification!.params); + await expect(trackedTurn).rejects.toThrow("was omitted"); + + const terminal = delivered.at(-1)!; + const [canonical] = toDriverEventEnvelopes(driverBootPayload, terminal, DRIVER_TEST_IDS.runId); + expect(canonical?.event).toMatchObject({ + kind: "run.failed", + payload: { + error: { + details: { + additionalDetailsUtf8Bytes: 1_100_000, + messageUtf8Bytes: 1_100_000, + misalignmentDetailedExplanationUtf8Bytes: 1_100_000, + misalignmentErrorTypeUtf8Bytes: 1_100_000, + misalignmentSteerMessageUtf8Bytes: 1_100_000, + }, + }, + }, + }); + expect(Buffer.byteLength(JSON.stringify(canonical))).toBeLessThan(1_048_576); }); test("removes OpenAI private citation markup from streamed and final assistant text", async () => { - const { bridge, context, events, logger } = createHarness(); + const { bridge, context, delivered } = createPublisherHarness(); + const trackedTurn = bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); const privateCitation = "\uE200cite\uE202turn7search12\uE202turn8view0\uE201"; await bridge.handleNotification(context, "item/agentMessage/delta", { @@ -245,9 +844,9 @@ describe("OpenAi app-server event bridge", () => { status: "completed", }, }); - await logger.destroy(); + await expect(trackedTurn).resolves.toBeUndefined(); - const allEvents = events(); + const allEvents = delivered; const streamedText = allEvents .filter((event) => event.kind === "message.delta") .map((event) => readEventPayloadString(event, "contentDelta") ?? "") @@ -261,12 +860,13 @@ describe("OpenAi app-server event bridge", () => { expect(streamedText).toBe("beforeafter"); expect(runCompleted).toBeDefined(); - expect(readEventPayloadString(runCompleted!, "finalMessageText")).toBe("beforeafter"); + expectRunFinalReferences(allEvents, "beforeafter"); expect(diagnostics).toHaveLength(1); + expect(new Set(allEvents.map((event) => event.sourceEventId)).size).toBe(allEvents.length); }); test("flushes incomplete private markup when an item completes without a text snapshot", async () => { - const { bridge, context, events, logger } = createHarness(); + const { bridge, context, events } = createHarness(); await bridge.handleNotification(context, "item/agentMessage/delta", { delta: "before\uE200cite\uE202turn7search12", @@ -282,7 +882,6 @@ describe("OpenAi app-server event bridge", () => { threadId: "thread-1", turnId: "turn-1", }); - await logger.destroy(); const streamedText = events() .filter((event) => event.kind === "message.delta") @@ -293,7 +892,7 @@ describe("OpenAi app-server event bridge", () => { }); test("does not fall back to progress when the final turn item is incomplete", async () => { - const { bridge, context, events, logger } = createHarness(); + const { bridge, context, events } = createHarness(); await bridge.handleNotification(context, "item/completed", { item: { @@ -315,7 +914,6 @@ describe("OpenAi app-server event bridge", () => { status: "completed", }, }); - await logger.destroy(); const runCompleted = events().find((event) => event.kind === "run.completed"); @@ -324,11 +922,11 @@ describe("OpenAi app-server event bridge", () => { } expect(readEventPayloadString(runCompleted, "finalMessageId")).toBeNull(); - expect(readEventPayloadString(runCompleted, "finalMessageText")).toBeNull(); + expect(runCompleted.payload).not.toHaveProperty("finalMessageText"); }); test("uses the last completed assistant snapshot when turn items are omitted", async () => { - const { bridge, context, events, logger } = createHarness(); + const { bridge, context, events } = createHarness(); await bridge.handleNotification(context, "item/agentMessage/delta", { delta: "PROGRESS", @@ -367,7 +965,6 @@ describe("OpenAi app-server event bridge", () => { status: "completed", }, }); - await logger.destroy(); const runCompleted = events().find((event) => event.kind === "run.completed"); @@ -375,20 +972,28 @@ describe("OpenAi app-server event bridge", () => { throw new Error("Expected a run.completed event."); } - expect(readEventPayloadString(runCompleted, "finalMessageId")).not.toBeNull(); - expect(readEventPayloadString(runCompleted, "finalMessageText")).toBe( - "最终回答:中文 Markdown ✅", - ); + expectRunFinalReferences(events(), "最终回答:中文 Markdown ✅"); }); test("uses completed snapshots when terminal items are not loaded", async () => { - const { bridge, context, events, logger } = createHarness(); + const { bridge, context, events } = createHarness(); await bridge.handleNotification(context, "item/completed", { item: { id: "message-final", text: "FINAL", type: "agentMessage" }, threadId: "thread-1", turnId: "turn-1", }); + await bridge.handleNotification(context, "item/completed", { + item: { + delivery: "async", + id: "message-async-progress", + phase: "final_answer", + text: "ASYNC PROGRESS", + type: "agentMessage", + }, + threadId: "thread-1", + turnId: "turn-1", + }); await bridge.handleNotification(context, "turn/completed", { threadId: "thread-1", turn: { @@ -398,7 +1003,6 @@ describe("OpenAi app-server event bridge", () => { status: "completed", }, }); - await logger.destroy(); const runCompleted = events().find((event) => event.kind === "run.completed"); @@ -406,11 +1010,11 @@ describe("OpenAi app-server event bridge", () => { throw new Error("Expected a run.completed event."); } - expect(readEventPayloadString(runCompleted, "finalMessageText")).toBe("FINAL"); + expectRunFinalReferences(events(), "FINAL"); }); test("does not fall back when a full terminal item list has no assistant", async () => { - const { bridge, context, events, logger } = createHarness(); + const { bridge, context, events } = createHarness(); await bridge.handleNotification(context, "item/completed", { item: { id: "message-progress", text: "PROGRESS", type: "agentMessage" }, @@ -426,7 +1030,6 @@ describe("OpenAi app-server event bridge", () => { status: "completed", }, }); - await logger.destroy(); const runCompleted = events().find((event) => event.kind === "run.completed"); @@ -434,11 +1037,11 @@ describe("OpenAi app-server event bridge", () => { throw new Error("Expected a run.completed event."); } - expect(readEventPayloadString(runCompleted, "finalMessageText")).toBeNull(); + expect(runCompleted.payload).not.toHaveProperty("finalMessageText"); }); test("uses first-seen item order when older completions arrive late", async () => { - const { bridge, context, events, logger } = createHarness(); + const { bridge, context, events } = createHarness(); for (const [itemId, delta] of [ ["message-progress", "PROGRESS"], @@ -465,7 +1068,6 @@ describe("OpenAi app-server event bridge", () => { threadId: "thread-1", turn: { id: "turn-1", status: "completed" }, }); - await logger.destroy(); const runCompleted = events().find((event) => event.kind === "run.completed"); @@ -473,11 +1075,752 @@ describe("OpenAi app-server event bridge", () => { throw new Error("Expected a run.completed event."); } - expect(readEventPayloadString(runCompleted, "finalMessageText")).toBe("FINAL"); + expectRunFinalReferences(events(), "FINAL"); + }); + + test("publishes more than 31 open tool closures before the run terminal", async () => { + const { bridge, context, delivered } = createPublisherHarness(); + const completion = bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); + const toolCount = 40; + + for (let index = 0; index < toolCount; index += 1) { + await bridge.handleNotification(context, "item/started", { + item: { + id: `tool-${String(index)}`, + status: "inProgress", + type: "commandExecution", + }, + threadId: "thread-1", + turnId: "turn-1", + }); + } + + await bridge.handleNotification(context, "turn/completed", { + threadId: "thread-1", + turn: { + id: "turn-1", + items: [], + itemsView: "notLoaded", + status: "completed", + }, + }); + await expect(completion).resolves.toBeUndefined(); + + expect( + delivered.filter( + (event) => + event.kind === "tool.call.updated" && + readEventPayloadString(event, "status") === "completed", + ), + ).toHaveLength(toolCount); + expect( + delivered.filter( + (event) => + event.kind === "item.completed" && + readEventPayloadString(event, "status") === "completed", + ), + ).toHaveLength(toolCount); + expect(delivered.filter((event) => event.kind === "run.completed")).toHaveLength(1); + expect(delivered.at(-1)?.kind).toBe("run.completed"); + }); + + test("publishes a final snapshot larger than the terminal byte limit by reference", async () => { + const { bridge, context, delivered } = createPublisherHarness(); + const completion = bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); + const finalText = "x".repeat(1_100_000); + + await bridge.handleNotification(context, "item/completed", { + item: { + id: "message-final", + text: finalText, + type: "agentMessage", + }, + threadId: "thread-1", + turnId: "turn-1", + }); + await bridge.handleNotification(context, "turn/completed", { + threadId: "thread-1", + turn: { + id: "turn-1", + items: [], + itemsView: "notLoaded", + status: "completed", + }, + }); + await expect(completion).resolves.toBeUndefined(); + + expectRunFinalReferences(delivered, finalText); + expect(delivered.at(-1)?.kind).toBe("run.completed"); + }); + + test("commits a chunked snapshot once after a partial transport retry", async () => { + const sourcePrefix = `${createRuntimeSourceEventId( + "openai.item.completed", + "turn-1", + "message-partial", + )}:`; + const { bridge, context, delivered, partialAttempts } = createPublisherHarness({ + partialSourcePrefix: sourcePrefix, + }); + const completion = bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); + const finalText = "x".repeat(1_100_000); + const notification = { + item: { + id: "message-partial", + text: finalText, + type: "agentMessage", + }, + threadId: "thread-1", + turnId: "turn-1", + }; + + await bridge.handleNotification(context, "item/completed", notification); + const deliveredAfterCompletion = delivered.length; + await bridge.handleNotification(context, "item/completed", notification); + expect(delivered).toHaveLength(deliveredAfterCompletion); + + await bridge.handleNotification(context, "turn/completed", { + threadId: "thread-1", + turn: { id: "turn-1", items: [], itemsView: "notLoaded", status: "completed" }, + }); + await expect(completion).resolves.toBeUndefined(); + + expect(partialAttempts).toHaveLength(3); + expect(partialAttempts[2]?.map((event) => event.sourceEventId)).toEqual( + partialAttempts[1]?.map((event) => event.sourceEventId), + ); + const deliveredSnapshotIds = delivered + .filter((event) => event.sourceEventId?.startsWith(sourcePrefix) === true) + .map((event) => event.sourceEventId); + expect(new Set(deliveredSnapshotIds).size).toBe(deliveredSnapshotIds.length); + expect(delivered.filter((event) => event.kind === "message.completed")).toHaveLength(1); + expectRunFinalReferences(delivered, finalText); + expect(delivered.at(-1)?.kind).toBe("run.completed"); + }); + + test("publishes a multi-byte final snapshot through CMA-safe lossless chunks", async () => { + const { bridge, context, delivered } = createPublisherHarness(); + const completion = bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); + const finalText = "界".repeat(400_000); + + await bridge.handleNotification(context, "item/completed", { + item: { + id: "message-final", + memoryCitation: { source: "memory-1" }, + phase: "final_answer", + text: finalText, + type: "agentMessage", + }, + threadId: "thread-1", + turnId: "turn-1", + }); + await bridge.handleNotification(context, "turn/completed", { + threadId: "thread-1", + turn: { + id: "turn-1", + items: [], + itemsView: "notLoaded", + status: "completed", + }, + }); + await expect(completion).resolves.toBeUndefined(); + + expectRunFinalReferences(delivered, finalText); + const snapshot = delivered.find( + (event) => event.delivery === "lossless" && event.kind === "message.added", + ); + expect(snapshot?.payload).toMatchObject({ + memoryCitation: { source: "memory-1" }, + phase: "final", + }); + }); + + test("publishes a large command result once without exceeding CMA admission", async () => { + const { bridge, context, delivered } = createPublisherHarness(); + const output = "x".repeat(525_000); + + await bridge.handleNotification(context, "item/completed", { + item: { + aggregatedOutput: output, + command: "generate-output", + id: "tool-large", + status: "completed", + type: "commandExecution", + }, + threadId: "thread-1", + turnId: "turn-1", + }); + + const completion = delivered.find( + (event) => + event.kind === "tool.call.updated" && + readEventPayloadString(event, "toolCallId") === "tool-large" && + readEventPayloadString(event, "status") === "completed", + ); + expect(readEventPayloadString(completion!, "rawOutput")).toBe(output); + expect(completion?.payload).not.toHaveProperty("content"); + }); + + test("publishes a large dynamic result once through structured output", async () => { + const { bridge, context, delivered } = createPublisherHarness(); + const text = "x".repeat(600_000); + const notification = parseServerNotification({ + method: "item/completed", + params: { + completedAtMs: 1, + item: { + arguments: { query: "migration" }, + contentItems: [{ text, type: "inputText" }], + durationMs: 9, + id: "dynamic-large", + namespace: "project", + status: "completed", + success: true, + tool: "lookup", + type: "dynamicToolCall", + }, + threadId: "thread-1", + turnId: "turn-1", + }, + }); + expect(notification).not.toBeNull(); + + await bridge.handleNotification(context, notification!.method, notification!.params); + + const completion = delivered.find( + (event) => + event.kind === "tool.call.updated" && + readEventPayloadString(event, "toolCallId") === "dynamic-large" && + readEventPayloadString(event, "status") === "completed", + ); + expect(completion?.payload).not.toHaveProperty("rawOutput"); + expect(completion?.payload).toMatchObject({ + structuredOutput: { contentItems: [{ text, type: "inputText" }] }, + }); + }); + + test("fails closed when one dynamic structured result exceeds CMA admission", async () => { + const { bridge, context, delivered } = createPublisherHarness(); + const trackedTurn = bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); + void trackedTurn.catch(() => {}); + const notification = parseServerNotification({ + method: "item/completed", + params: { + completedAtMs: 1, + item: { + arguments: {}, + contentItems: [{ text: "x".repeat(1_100_000), type: "inputText" }], + durationMs: 9, + id: "dynamic-oversized", + namespace: "project", + status: "completed", + success: true, + tool: "lookup", + type: "dynamicToolCall", + }, + threadId: "thread-1", + turnId: "turn-1", + }, + }); + expect(notification).not.toBeNull(); + let failure: Error | null = null; + + try { + await bridge.handleNotification(context, notification!.method, notification!.params); + } catch (error) { + failure = error instanceof Error ? error : new Error("dynamic completion failed"); + } + + expect(failure?.message).toContain("durable event capacity"); + await bridge.failActiveTurns(context, failure!); + await expect(trackedTurn).rejects.toThrow("durable event capacity"); + expect( + delivered.find( + (event) => + event.kind === "tool.call.updated" && + readEventPayloadString(event, "toolCallId") === "dynamic-oversized" && + readEventPayloadString(event, "status") === "completed", + ), + ).toBeUndefined(); + }); + + test("fails the active turn instead of claiming an oversized tool result completed", async () => { + const { bridge, context, delivered } = createPublisherHarness(); + const trackedTurn = bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); + void trackedTurn.catch(() => {}); + const output = "x".repeat(1_100_000); + let failure: Error | null = null; + + try { + await bridge.handleNotification(context, "item/completed", { + item: { + aggregatedOutput: output, + command: "generate-output", + id: "tool-oversized", + status: "completed", + type: "commandExecution", + }, + threadId: "thread-1", + turnId: "turn-1", + }); + } catch (error) { + failure = error instanceof Error ? error : new Error("tool completion failed"); + } + + expect(failure).not.toBeNull(); + expect(failure?.message).toContain("UTF-8 bytes"); + await bridge.failActiveTurns(context, failure!); + await expect(trackedTurn).rejects.toThrow("durable event capacity"); + + expect( + delivered.find( + (event) => + event.kind === "tool.call.updated" && + readEventPayloadString(event, "toolCallId") === "tool-oversized" && + readEventPayloadString(event, "status") === "completed", + ), + ).toBeUndefined(); + expect( + delivered.find( + (event) => + event.kind === "tool.call.updated" && + readEventPayloadString(event, "toolCallId") === "tool-oversized" && + readEventPayloadString(event, "status") === "failed", + ), + ).toBeDefined(); + expect(delivered.find((event) => event.kind === "run.failed")?.payload).toMatchObject({ + error: { message: expect.stringContaining("UTF-8 bytes") }, + }); + }); + + test("budgets the first message chunk around large citation metadata", async () => { + const { bridge, context, delivered } = createPublisherHarness(); + const trackedTurn = bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); + const text = "a".repeat(512 * 1_024); + const notification = parseServerNotification({ + method: "item/completed", + params: { + completedAtMs: 1, + item: { + id: "message-citation", + memoryCitation: { + entries: [ + { + lineEnd: 1, + lineStart: 0, + note: "x".repeat(600_000), + path: "/memory.md", + }, + ], + threadIds: ["memory-thread"], + }, + phase: "final_answer", + text, + type: "agentMessage", + }, + threadId: "thread-1", + turnId: "turn-1", + }, + }); + expect(notification).not.toBeNull(); + + await bridge.handleNotification(context, notification!.method, notification!.params); + await bridge.handleNotification(context, "turn/completed", { + threadId: "thread-1", + turn: { id: "turn-1", items: [], itemsView: "notLoaded", status: "completed" }, + }); + await expect(trackedTurn).resolves.toBeUndefined(); + expectRunFinalReferences(delivered, text); + expect( + delivered.filter( + (event) => + event.delivery === "lossless" && + (event.kind === "message.added" || event.kind === "message.delta"), + ).length, + ).toBeGreaterThan(1); + }); + + test("budgets snapshot chunks after adding a long provider item identity", async () => { + const { bridge, context, delivered } = createPublisherHarness(); + const trackedTurn = bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); + const itemId = `message-${"i".repeat(100_000)}`; + const text = "a".repeat(512 * 1_024); + const notification = parseServerNotification({ + method: "item/completed", + params: { + completedAtMs: 1, + item: { + id: itemId, + memoryCitation: { + entries: [ + { + lineEnd: 1, + lineStart: 0, + note: "x".repeat(600_000), + path: "/memory.md", + }, + ], + threadIds: ["memory-thread"], + }, + phase: "final_answer", + text, + type: "agentMessage", + }, + threadId: "thread-1", + turnId: "turn-1", + }, + }); + expect(notification).not.toBeNull(); + + await bridge.handleNotification(context, notification!.method, notification!.params); + await bridge.handleNotification(context, "turn/completed", { + threadId: "thread-1", + turn: { id: "turn-1", items: [], itemsView: "notLoaded", status: "completed" }, + }); + await expect(trackedTurn).resolves.toBeUndefined(); + expectRunFinalReferences(delivered, text); + }); + + test("maps long provider reasoning identity before durable CMA delivery", async () => { + const { bridge, context, delivered } = createPublisherHarness(); + const trackedTurn = bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); + const itemId = `reasoning-${"i".repeat(262_000)}`; + const text = "x".repeat(512 * 1_024); + const notification = parseServerNotification({ + method: "item/reasoning/summaryTextDelta", + params: { + delta: text, + itemId, + summaryIndex: 0, + threadId: "thread-1", + turnId: "turn-1", + }, + }); + expect(notification).not.toBeNull(); + + await bridge.handleNotification(context, notification!.method, notification!.params); + await bridge.handleNotification(context, "turn/completed", { + threadId: "thread-1", + turn: { id: "turn-1", items: [], itemsView: "notLoaded", status: "completed" }, + }); + await expect(trackedTurn).resolves.toBeUndefined(); + + const reasoningEvents = delivered.filter((event) => event.kind.startsWith("thought.")); + const thoughtIds = reasoningEvents.map((event) => readEventPayloadString(event, "thoughtId")); + expect( + thoughtIds.every( + (thoughtId) => + thoughtId !== null && thoughtId !== itemId && Buffer.byteLength(thoughtId, "utf8") <= 256, + ), + ).toBe(true); + expect(new Set(thoughtIds).size).toBe(1); + expect( + reasoningEvents + .filter((event) => event.kind === "thought.delta") + .map((event) => readEventPayloadString(event, "contentDelta")) + .join(""), + ).toBe(text); + expect(reasoningEvents.some((event) => event.sourceEventId?.includes(itemId) === true)).toBe( + false, + ); + }); + + test("maps long provider item and tool identities before durable CMA delivery", async () => { + const { bridge, context, delivered } = createPublisherHarness(); + const trackedTurn = bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); + const contextItemId = `compact-${"i".repeat(525_000)}`; + const toolItemId = `tool-${"i".repeat(525_000)}`; + const notifications = [ + parseServerNotification({ + method: "item/completed", + params: { + completedAtMs: 1, + item: { id: contextItemId, type: "contextCompaction" }, + threadId: "thread-1", + turnId: "turn-1", + }, + }), + parseServerNotification({ + method: "item/completed", + params: { + completedAtMs: 2, + item: { + aggregatedOutput: "done", + command: "printf done", + commandActions: [], + cwd: "/tmp", + durationMs: 1, + exitCode: 0, + id: toolItemId, + pluginId: null, + processId: null, + scriptPath: null, + source: "agent", + status: "completed", + type: "commandExecution", + }, + threadId: "thread-1", + turnId: "turn-1", + }, + }), + ]; + expect(notifications.every((notification) => notification !== null)).toBe(true); + + for (const notification of notifications) { + await bridge.handleNotification(context, notification!.method, notification!.params); + } + await bridge.handleNotification(context, "turn/completed", { + threadId: "thread-1", + turn: { id: "turn-1", items: [], itemsView: "notLoaded", status: "completed" }, + }); + await expect(trackedTurn).resolves.toBeUndefined(); + + const compactedId = readEventPayloadString( + delivered.find((event) => event.kind === "context.compacted")!, + "itemId", + ); + const toolIds = delivered + .filter( + (event) => + event.kind === "tool.call.updated" || + (event.kind === "item.started" && + readEventPayloadString(event, "itemType") === "tool_call") || + (event.kind === "item.completed" && + readEventPayloadString(event, "itemType") === "tool_call"), + ) + .map( + (event) => + readEventPayloadString(event, "toolCallId") ?? readEventPayloadString(event, "itemId"), + ); + expect( + compactedId !== null && + compactedId !== contextItemId && + Buffer.byteLength(compactedId, "utf8") <= 256, + ).toBe(true); + expect( + toolIds.every( + (toolId) => + toolId !== null && toolId !== toolItemId && Buffer.byteLength(toolId, "utf8") <= 256, + ), + ).toBe(true); + expect(new Set(toolIds).size).toBe(1); + expect( + delivered.some( + (event) => + event.sourceEventId?.includes(contextItemId) === true || + event.sourceEventId?.includes(toolItemId) === true, + ), + ).toBe(false); + }); + + test("fails closed before CMA when one official plan update exceeds durable capacity", async () => { + const { bridge, context, delivered } = createPublisherHarness(); + const trackedTurn = bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); + void trackedTurn.catch(() => {}); + const notification = parseServerNotification({ + method: "item/plan/delta", + params: { + delta: "x".repeat(1_050_000), + itemId: "plan-large", + threadId: "thread-1", + turnId: "turn-1", + }, + }); + expect(notification).not.toBeNull(); + let failure: Error | null = null; + + try { + await bridge.handleNotification(context, notification!.method, notification!.params); + } catch (error) { + failure = error instanceof Error ? error : new Error("plan update failed"); + } + + expect(failure?.message).toContain("durable event capacity"); + await bridge.failActiveTurns(context, failure!); + await expect(trackedTurn).rejects.toThrow("durable event capacity"); + expect(delivered.some((event) => event.kind === "plan.updated")).toBe(false); + expect(delivered.at(-1)?.kind).toBe("run.failed"); + }); + + test.each([ + [ + "MCP server", + { + arguments: {}, + id: "mcp-server-large", + server: "s".repeat(1_050_000), + status: "inProgress", + tool: "inspect", + type: "mcpToolCall", + }, + ], + [ + "MCP tool", + { + arguments: {}, + id: "mcp-tool-large", + server: "filesystem", + status: "inProgress", + tool: "t".repeat(1_050_000), + type: "mcpToolCall", + }, + ], + [ + "dynamic tool", + { + arguments: {}, + id: "dynamic-tool-large", + status: "inProgress", + tool: "t".repeat(1_050_000), + type: "dynamicToolCall", + }, + ], + ] as const)("fails %s start before poisoning CMA terminal delivery", async (_label, item) => { + const { bridge, context, delivered } = createPublisherHarness(); + const trackedTurn = bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); + void trackedTurn.catch(() => {}); + const notification = parseServerNotification({ + method: "item/started", + params: { item, startedAtMs: 1, threadId: "thread-1", turnId: "turn-1" }, + }); + expect(notification).not.toBeNull(); + let failure: Error | null = null; + + try { + await bridge.handleNotification(context, notification!.method, notification!.params); + } catch (error) { + failure = error instanceof Error ? error : new Error("tool start failed"); + } + + expect(failure?.message).toContain("tool start exceeds durable event capacity"); + await expect(bridge.failActiveTurns(context, failure!)).resolves.toBe(true); + await expect(trackedTurn).rejects.toThrow("tool start exceeds durable event capacity"); + expect(delivered.some((event) => event.kind === "item.started")).toBe(false); + expect(delivered.some((event) => event.kind === "tool.call.updated")).toBe(false); + expect(delivered.at(-1)?.kind).toBe("run.failed"); + }); + + test("completes a large renamed file patch without duplicating its diff", async () => { + const { bridge, context, delivered } = createPublisherHarness(); + const trackedTurn = bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); + void trackedTurn.catch(() => {}); + const diff = "x".repeat(1_050_000); + const change = { + diff, + kind: { move_path: "/tmp/renamed.txt", type: "update" }, + path: "/tmp/file.txt", + }; + const notification = parseServerNotification({ + method: "item/fileChange/patchUpdated", + params: { + changes: [change], + itemId: "patch-large", + threadId: "thread-1", + turnId: "turn-1", + }, + }); + expect(notification).not.toBeNull(); + + await bridge.handleNotification(context, notification!.method, notification!.params); + const completion = parseServerNotification({ + method: "item/completed", + params: { + completedAtMs: 1, + item: { + changes: [change], + id: "patch-large", + status: "completed", + type: "fileChange", + }, + threadId: "thread-1", + turnId: "turn-1", + }, + }); + expect(completion).not.toBeNull(); + await bridge.handleNotification(context, completion!.method, completion!.params); + + expect(delivered.find((event) => event.kind === "file.change.updated")?.payload).toMatchObject({ + changes: [ + { change: "delete", path: "/tmp/file.txt" }, + { change: "upsert", path: "/tmp/renamed.txt" }, + ], + }); + expect( + delivered.some( + (event) => + event.kind === "tool.call.updated" && readEventPayloadString(event, "rawOutput") === diff, + ), + ).toBe(false); + expect( + delivered.some( + (event) => + event.kind === "tool.call.updated" && + readEventPayloadString(event, "toolCallId") === "patch-large" && + readEventPayloadString(event, "status") === "completed", + ), + ).toBe(true); + expect( + delivered.some( + (event) => + event.kind === "item.completed" && + readEventPayloadString(event, "itemId") === "patch-large", + ), + ).toBe(true); + + await bridge.failActiveTurns(context, new Error("test complete")); + await expect(trackedTurn).rejects.toThrow("test complete"); + }); + + test("fails closed when citation metadata alone exceeds CMA snapshot capacity", async () => { + const { bridge, context, delivered } = createPublisherHarness(); + const trackedTurn = bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); + void trackedTurn.catch(() => {}); + const notification = parseServerNotification({ + method: "item/completed", + params: { + completedAtMs: 1, + item: { + id: "message-citation", + memoryCitation: { + entries: [ + { + lineEnd: 1, + lineStart: 0, + note: "x".repeat(1_100_000), + path: "/memory.md", + }, + ], + threadIds: ["memory-thread"], + }, + phase: "final_answer", + text: "short", + type: "agentMessage", + }, + threadId: "thread-1", + turnId: "turn-1", + }, + }); + expect(notification).not.toBeNull(); + let failure: Error | null = null; + + try { + await bridge.handleNotification(context, notification!.method, notification!.params); + } catch (error) { + failure = error instanceof Error ? error : new Error("message completion failed"); + } + + expect(failure?.message).toContain("message snapshot message-citation"); + await bridge.failActiveTurns(context, failure!); + await expect(trackedTurn).rejects.toThrow("durable event capacity"); + expect(delivered.find((event) => event.kind === "message.added")).toBeUndefined(); + expect(delivered.find((event) => event.kind === "message.completed")).toBeUndefined(); + expect(delivered.find((event) => event.kind === "message.failed")).toBeDefined(); + expect(delivered.find((event) => event.kind === "run.failed")).toBeDefined(); + expect(JSON.stringify(delivered)).not.toContain("memory-thread"); }); test("completed turns app final items before run finish", async () => { - const { bridge, context, events, logger } = createHarness(); + const { bridge, context, events } = createHarness(); await bridge.handleNotification(context, "turn/completed", { threadId: "thread-1", @@ -493,7 +1836,6 @@ describe("OpenAi app-server event bridge", () => { status: "completed", }, }); - await logger.destroy(); const assistantMessageId = readAssistantMessageId(events()); expect(events()).toMatchObject([ @@ -510,15 +1852,6 @@ describe("OpenAi app-server event bridge", () => { role: "agent", }, }, - { - delivery: "best_effort", - kind: "message.delta", - payload: { - contentDelta: "pong", - messageId: assistantMessageId, - role: "agent", - }, - }, { delivery: "lossless", kind: "message.added", @@ -542,6 +1875,10 @@ describe("OpenAi app-server event bridge", () => { threadId: "thread-1", }, }, + { + kind: "agent.tasks.replaced", + payload: { tasks: [] }, + }, { kind: "run.completed", payload: { diff --git a/tests/openai-app-server-event-bridge-transcript.test.ts b/tests/openai-app-server-event-bridge-transcript.test.ts index de05cf5..1ee8a5c 100644 --- a/tests/openai-app-server-event-bridge-transcript.test.ts +++ b/tests/openai-app-server-event-bridge-transcript.test.ts @@ -1,95 +1,16 @@ import { describe, expect, test } from "bun:test"; -import type { AgentDriverContext } from "../src/core/agent-driver-backend"; -import { createAgentDriverContext } from "../src/core/agent-driver-backend"; -import { createBufferedSinkLogger } from "../src/observability"; -import type { DriverEventInput } from "../src/protocol/events"; -import { isDriverId } from "../src/protocol/id"; -import { OpenAiAppServerEventBridge } from "../src/runtimes/openai/app-server-event-bridge"; +import { DriverTurnCancelledError } from "../src/core/driver-runtime-state"; import { DRIVER_TEST_IDS } from "./driver-boot-payload-fixture"; -import { driverStartInput as bootPayload } from "./driver-boot-payload-fixture"; - -interface EventBatch { - events: DriverEventInput[]; - reason: string; -} - -function readEventPayloadString(event: DriverEventInput, field: string): string | null { - const payload = event.payload; - - if (typeof payload !== "object" || payload === null || Array.isArray(payload)) { - return null; - } - - const value = (payload as Record)[field]; - return typeof value === "string" ? value : null; -} - -function readAssistantMessageId(events: readonly DriverEventInput[]): string { - for (const event of events) { - const messageId = - readEventPayloadString(event, "messageId") ?? - readEventPayloadString(event, "parentMessageId"); - - if (messageId !== null) { - expect(isDriverId(messageId)).toBe(true); - return messageId; - } - } - - throw new Error("Expected a platform assistant message ID."); -} - -function createHarness(options: { failNativeResumePublish?: boolean; holdReason?: string } = {}) { - const batches: EventBatch[] = []; - const heldPush = Promise.withResolvers(); - const releasePush = Promise.withResolvers(); - const logger = createBufferedSinkLogger({ - level: "debug", - service: "openai-app-server-event-bridge-test", - sink: async () => {}, - }); - const context: AgentDriverContext = createAgentDriverContext({ - eventSink: { - pushEvents: async () => ({ accepted: [] }), - }, - logger, - payload: bootPayload, - permission: { - request: async () => "allow_once", - }, - }); - const bridge = new OpenAiAppServerEventBridge({ - push: async (_context, reason, events) => { - batches.push({ events, reason }); - if (reason === options.holdReason) { - heldPush.resolve(); - await releasePush.promise; - } - if ( - options.failNativeResumePublish === true && - reason === "driver.openai.native_resume_ref.updated" - ) { - throw new Error("event sink unavailable"); - } - }, - requireThreadId: () => "thread-1", - }); - - return { - batches, - bridge, - context, - events: () => batches.flatMap((batch) => batch.events), - heldPush: heldPush.promise, - logger, - releasePush: releasePush.resolve, - }; -} +import { + createOpenAiBridgeHarness as createHarness, + readAssistantMessageId, + readEventPayloadString, +} from "./openai-app-server-event-bridge-fixture"; describe("OpenAi app-server event bridge", () => { test("closes only open item state when a mixed turn is cancelled", async () => { - const { bridge, context, events, logger } = createHarness(); + const { bridge, context, events } = createHarness(); const trackedTurn = bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); await bridge.handleNotification(context, "item/reasoning/summaryTextDelta", { @@ -100,17 +21,22 @@ describe("OpenAi app-server event bridge", () => { turnId: "turn-1", }); await bridge.handleNotification(context, "item/started", { - item: { id: "tool-completed", type: "commandExecution" }, + item: { id: "tool-completed", status: "inProgress", type: "commandExecution" }, threadId: "thread-1", turnId: "turn-1", }); await bridge.handleNotification(context, "item/completed", { - item: { aggregatedOutput: "", id: "tool-completed", type: "commandExecution" }, + item: { + aggregatedOutput: "", + id: "tool-completed", + status: "completed", + type: "commandExecution", + }, threadId: "thread-1", turnId: "turn-1", }); await bridge.handleNotification(context, "item/started", { - item: { id: "tool-open", type: "commandExecution" }, + item: { id: "tool-open", status: "inProgress", type: "commandExecution" }, threadId: "thread-1", turnId: "turn-1", }); @@ -126,8 +52,8 @@ describe("OpenAi app-server event bridge", () => { ) .map((event) => readEventPayloadString(event, "status")); expect(toolStatuses("tool-completed")).toEqual(["running", "completed"]); - expect(toolStatuses("tool-open")).toEqual(["running", "failed"]); - expect(events().filter((event) => event.kind === "thought.completed")).toHaveLength(1); + expect(toolStatuses("tool-open")).toEqual(["running", "cancelled"]); + expect(events().filter((event) => event.kind === "thought.cancelled")).toHaveLength(1); const terminalEventCount = events().length; await bridge.handleNotification(context, "item/reasoning/summaryTextDelta", { @@ -139,11 +65,106 @@ describe("OpenAi app-server event bridge", () => { }); await bridge.cancelTurn(context, "turn-1", "test.cancel.retry"); expect(events()).toHaveLength(terminalEventCount); - await logger.destroy(); + }); + + test("lets core cancellation replace a pre-response unselected completed turn", async () => { + const { bridge, context, events, releaseTerminal, terminalAttempts, terminalHeld } = + createHarness({ holdCompletedTerminalOnce: true }); + const controller = new AbortController(); + const admission = bridge.beginTurnAdmission(DRIVER_TEST_IDS.runId, controller.signal); + bridge.armTurnAdmission(admission); + const started = bridge.handleNotification(context, "turn/started", { + threadId: "thread-1", + turn: { id: "turn-1", status: "inProgress" }, + }); + bridge.bindTurnAdmission(admission, "turn-1"); + await started; + await bridge.handleNotification(context, "item/agentMessage/delta", { + delta: "done", + itemId: "message-1", + threadId: "thread-1", + turnId: "turn-1", + }); + const completion = bridge.handleNotification(context, "turn/completed", { + threadId: "thread-1", + turn: { id: "turn-1", status: "completed" }, + }); + + await terminalHeld; + controller.abort(new DriverTurnCancelledError("test.cancel")); + releaseTerminal(); + await completion; + const trackedTurn = bridge.claimTurnAdmission( + admission, + "turn-1", + DRIVER_TEST_IDS.runId, + controller.signal, + ); + void trackedTurn.catch(() => {}); + await bridge.cancelTurn(context, "turn-1", "test.cancel"); + + await expect(trackedTurn).rejects.toThrow("test.cancel"); + expect(terminalAttempts.map(({ terminal }) => terminal.kind)).toEqual([ + "run.completed", + "run.cancelled", + ]); + expect(terminalAttempts[0]?.cancellationSignal).toBe(controller.signal); + expect( + events().filter((event) => + ["run.cancel.requested", "run.cancelled", "run.completed"].includes(event.kind), + ), + ).toMatchObject([{ kind: "run.cancel.requested" }, { kind: "run.cancelled" }]); + expect(events().filter((event) => event.kind === "message.completed")).toHaveLength(1); + expect(events().filter((event) => event.kind === "message.cancelled")).toHaveLength(0); + expect(events().filter((event) => event.kind === "agent.tasks.replaced")).toHaveLength(1); + }); + + test("does not duplicate streamed reasoning in the completed snapshot", async () => { + const { bridge, context, events } = createHarness(); + + await bridge.handleNotification(context, "item/reasoning/summaryTextDelta", { + delta: "First", + itemId: "reasoning-1", + summaryIndex: 0, + threadId: "thread-1", + turnId: "turn-1", + }); + await bridge.handleNotification(context, "item/reasoning/summaryPartAdded", { + itemId: "reasoning-1", + summaryIndex: 1, + threadId: "thread-1", + turnId: "turn-1", + }); + await bridge.handleNotification(context, "item/reasoning/summaryPartAdded", { + itemId: "reasoning-1", + summaryIndex: 1, + threadId: "thread-1", + turnId: "turn-1", + }); + await bridge.handleNotification(context, "item/reasoning/summaryTextDelta", { + delta: "Second", + itemId: "reasoning-1", + summaryIndex: 1, + threadId: "thread-1", + turnId: "turn-1", + }); + await bridge.handleNotification(context, "item/completed", { + item: { content: [], id: "reasoning-1", summary: ["First", "Second"], type: "reasoning" }, + threadId: "thread-1", + turnId: "turn-1", + }); + + expect( + events() + .filter((event) => event.kind === "thought.delta") + .map((event) => readEventPayloadString(event, "contentDelta")) + .join(""), + ).toBe("First\n\nSecond"); + expect(events().filter((event) => event.kind === "thought.completed")).toHaveLength(1); }); test("releases translation state between turns and ignores late terminal replay", async () => { - const { bridge, context, events, logger } = createHarness(); + const { bridge, context, events } = createHarness(); await bridge.handleNotification(context, "item/plan/delta", { delta: "old plan", @@ -200,7 +221,6 @@ describe("OpenAi app-server event bridge", () => { threadId: "thread-1", turnId: "turn-2", }); - await logger.destroy(); const planEvents = events().filter((event) => event.kind === "plan.updated"); expect(planEvents.at(-1)).toMatchObject({ @@ -217,7 +237,7 @@ describe("OpenAi app-server event bridge", () => { }); test("turn completion can arrive before the turn response is tracked", async () => { - const { bridge, context, events, logger } = createHarness(); + const { bridge, context, events } = createHarness(); await bridge.handleNotification(context, "turn/completed", { threadId: "thread-1", @@ -228,7 +248,6 @@ describe("OpenAi app-server event bridge", () => { }); await expect(bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId)).resolves.toBeUndefined(); - await logger.destroy(); for (const event of events()) { expect(event.runId).toBeUndefined(); @@ -247,6 +266,10 @@ describe("OpenAi app-server event bridge", () => { threadId: "thread-1", }, }, + { + kind: "agent.tasks.replaced", + payload: { tasks: [] }, + }, { kind: "run.completed", payload: { @@ -257,7 +280,7 @@ describe("OpenAi app-server event bridge", () => { }); test("resume metadata failure does not reject a completed turn", async () => { - const { bridge, context, events, logger } = createHarness({ + const { bridge, context, events } = createHarness({ failNativeResumePublish: true, }); const trackedTurn = bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); @@ -272,17 +295,17 @@ describe("OpenAi app-server event bridge", () => { }), ).resolves.toBeUndefined(); await expect(trackedTurn).resolves.toBeUndefined(); - await logger.destroy(); expect(events().some((event) => event.kind === "run.completed")).toBe(true); }); test("turn errors wait for the authoritative failed turn", async () => { - const { bridge, context, events, logger } = createHarness(); + const { bridge, context, events } = createHarness(); await bridge.handleNotification(context, "error", { error: { additionalDetails: "HTTP 502 from upstream.", + codexErrorInfo: { responseStreamDisconnected: { httpStatusCode: 502 } }, message: "Response stream disconnected.", }, threadId: "thread-1", @@ -298,6 +321,7 @@ describe("OpenAi app-server event bridge", () => { turn: { error: { additionalDetails: "HTTP 502 from upstream.", + codexErrorInfo: { responseStreamDisconnected: { httpStatusCode: 502 } }, message: "Response stream disconnected.", }, id: "turn-1", @@ -308,7 +332,6 @@ describe("OpenAi app-server event bridge", () => { await expect(trackedTurn).rejects.toThrow( "Response stream disconnected.\nHTTP 502 from upstream.", ); - await logger.destroy(); const failedEvent = events().find((event) => event.kind === "run.failed"); expect(failedEvent?.runId).toBe(DRIVER_TEST_IDS.runId); @@ -316,21 +339,97 @@ describe("OpenAi app-server event bridge", () => { { kind: "run.started", }, + { + kind: "agent.tasks.replaced", + payload: { tasks: [] }, + }, { kind: "run.failed", payload: { error: { code: "openai.turn_failed", + details: { + additionalDetails: "HTTP 502 from upstream.", + codexErrorInfo: "responseStreamDisconnected", + httpStatusCode: 502, + }, message: "Response stream disconnected.\nHTTP 502 from upstream.", + retryable: true, }, - recoverable: false, + recoverable: true, }, }, ]); }); + test("classifies exhausted streaming rate limits as recoverable", async () => { + const { bridge, context, events } = createHarness(); + const trackedTurn = bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); + + await bridge.handleNotification(context, "turn/completed", { + threadId: "thread-1", + turn: { + error: { + codexErrorInfo: "rateLimitExceeded", + message: "Streaming rate limit exceeded.", + }, + id: "turn-1", + status: "failed", + }, + }); + + await expect(trackedTurn).rejects.toThrow("Streaming rate limit exceeded."); + expect(events().find((event) => event.kind === "run.failed")).toMatchObject({ + payload: { + error: { + details: { codexErrorInfo: "rateLimitExceeded" }, + retryable: true, + }, + recoverable: true, + }, + }); + }); + + test("preserves actionable misalignment details without making the failure retryable", async () => { + const { bridge, context, events } = createHarness(); + const trackedTurn = bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); + + await bridge.handleNotification(context, "turn/completed", { + threadId: "thread-1", + turn: { + error: { + codexErrorInfo: "misalignmentPolicyViolation", + message: "The request was blocked by policy.", + misalignment: { + detailedExplanation: "The request needs a narrower authorized scope.", + errorType: "scope_mismatch", + steer: { message: "Continue only within the authorized repository." }, + }, + }, + id: "turn-1", + status: "failed", + }, + }); + + await expect(trackedTurn).rejects.toThrow("The request was blocked by policy."); + expect(events().find((event) => event.kind === "run.failed")).toMatchObject({ + payload: { + error: { + details: { + codexErrorInfo: "misalignmentPolicyViolation", + misalignmentDetailedExplanation: "The request needs a narrower authorized scope.", + misalignmentErrorType: "scope_mismatch", + misalignmentSteerMessage: "Continue only within the authorized repository.", + }, + retryable: false, + }, + recoverable: false, + }, + }); + }); + test("thread systemError waits for the authoritative failed turn", async () => { - const { bridge, context, events, logger } = createHarness(); + const { bridge, context, events } = createHarness(); const trackedTurn = bridge.trackTurn("turn-1", DRIVER_TEST_IDS.runId); await bridge.handleNotification(context, "thread/status/changed", { @@ -342,6 +441,7 @@ describe("OpenAi app-server event bridge", () => { turn: { error: { additionalDetails: null, + codexErrorInfo: "serverOverloaded", message: "The model returned an empty response.", }, id: "turn-1", @@ -350,21 +450,23 @@ describe("OpenAi app-server event bridge", () => { }); await expect(trackedTurn).rejects.toThrow("The model returned an empty response."); - await logger.destroy(); expect(events().filter((event) => event.kind === "run.failed")).toMatchObject([ { payload: { error: { code: "openai.turn_failed", + details: { codexErrorInfo: "serverOverloaded" }, message: "The model returned an empty response.", + retryable: false, }, + recoverable: false, }, }, ]); }); test("completed agent messages backfill text when no deltas streamed", async () => { - const { bridge, context, events, logger } = createHarness(); + const { bridge, context, events } = createHarness(); await bridge.handleNotification(context, "item/completed", { item: { @@ -375,7 +477,6 @@ describe("OpenAi app-server event bridge", () => { threadId: "thread-1", turnId: "turn-1", }); - await logger.destroy(); const assistantMessageId = readAssistantMessageId(events()); expect(events()).toMatchObject([ @@ -386,15 +487,6 @@ describe("OpenAi app-server event bridge", () => { role: "agent", }, }, - { - delivery: "best_effort", - kind: "message.delta", - payload: { - contentDelta: "pong", - messageId: assistantMessageId, - role: "agent", - }, - }, { delivery: "lossless", kind: "message.added", @@ -415,7 +507,7 @@ describe("OpenAi app-server event bridge", () => { }); test("uses item completion text as the authoritative final snapshot", async () => { - const { bridge, context, events, logger } = createHarness(); + const { bridge, context, events } = createHarness(); await bridge.handleNotification(context, "item/agentMessage/delta", { delta: "损坏的流式片段", @@ -445,7 +537,6 @@ describe("OpenAi app-server event bridge", () => { status: "completed", }, }); - await logger.destroy(); const runCompleted = events().find((event) => event.kind === "run.completed"); @@ -453,8 +544,15 @@ describe("OpenAi app-server event bridge", () => { throw new Error("Expected a run.completed event."); } - expect(readEventPayloadString(runCompleted, "finalMessageText")).toBe( - "完整最终回答:中文 Markdown ✅", + const finalSnapshot = events().find( + (event) => + event.kind === "message.added" && + readEventPayloadString(event, "content") === "完整最终回答:中文 Markdown ✅", + ); + expect(finalSnapshot).toBeDefined(); + expect(readEventPayloadString(runCompleted, "finalMessageId")).toBe( + readEventPayloadString(finalSnapshot!, "messageId"), ); + expect(runCompleted.payload).not.toHaveProperty("finalMessageText"); }); }); diff --git a/tests/openai-app-server-provider-fixtures.test.ts b/tests/openai-app-server-provider-fixtures.test.ts index 9e8c7cc..22209a4 100644 --- a/tests/openai-app-server-provider-fixtures.test.ts +++ b/tests/openai-app-server-provider-fixtures.test.ts @@ -1,27 +1,20 @@ import { describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; -import { createBufferedSinkLogger } from "../src/observability"; import type { DriverEventInput } from "../src/protocol/events"; -import { isDriverId } from "../src/protocol/id"; import type { AgentDriverContext } from "../src/core/agent-driver-backend"; -import { createAgentDriverContext } from "../src/core/agent-driver-backend"; import { OpenAiAppServerEventBridge } from "../src/runtimes/openai/app-server-event-bridge"; +import { isRecord } from "../src/runtimes/openai/app-server-json"; import { + CLIENT_RESULT_SCHEMAS, isServerRequestMethod, - isServerNotificationMethod, - OPENAI_APP_SERVER_SCHEMA_VERSION, - parseClientRequestResult, - parseServerNotificationParams, -} from "../src/runtimes/openai/generated/app-server-protocol"; -import type { ServerNotificationMethod } from "../src/runtimes/openai/generated/app-server-protocol"; + parseServerNotification, +} from "../src/runtimes/openai/app-server-protocol"; import { DRIVER_TEST_IDS } from "./driver-boot-payload-fixture"; -import { driverStartInput as bootPayload } from "./driver-boot-payload-fixture"; - -interface EventBatch { - readonly events: DriverEventInput[]; - readonly reason: string; -} +import { createOpenAiBridgeHarness as createHarness } from "./openai-app-server-event-bridge-fixture"; +import { + normalizeOpenAiProviderEvents as normalizeBridgeEvents, + readProviderFixture, +} from "./provider-fixture-test-helpers"; interface ProviderNotificationFixture { readonly method: string; @@ -51,173 +44,140 @@ const providerFixtureNames = [ "unknown-notification-ignored", ] as const; -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function readJsonFixture(path: string): unknown { - return JSON.parse(readFileSync(new URL(path, import.meta.url), "utf8")); -} - -function readTrackTurnFixture(value: unknown): TrackTurnFixture | undefined { - if (value === undefined) { - return undefined; - } - - if (!isRecord(value)) { - throw new Error("Provider fixture trackTurnAfterNotifications must be an object."); - } - - const expectation = value["expectation"]; - const turnId = value["turnId"]; - - if ((expectation !== "reject" && expectation !== "resolve") || typeof turnId !== "string") { - throw new Error("Provider fixture trackTurnAfterNotifications is malformed."); - } - - return { - expectation, - turnId, - }; -} - -function readProviderNotificationFixture(value: unknown): ProviderNotificationFixture { - if (!isRecord(value)) { - throw new Error("Provider fixture notification must be an object."); - } - - const method = value["method"]; - - if (typeof method !== "string") { - throw new Error("Provider fixture notification method must be a string."); - } - - return { - method, - params: value["params"], - }; -} - function readProviderFixtureCase(path: string): ProviderFixtureCase { - const fixture = readJsonFixture(path); + const fixture = readProviderFixture(path, { + arrays: ["expectedEvents", "notifications"], + }); - if (!isRecord(fixture)) { - throw new Error("Provider fixture must be an object."); + for (const notification of fixture.notifications) { + if (!isRecord(notification) || typeof notification["method"] !== "string") { + throw new TypeError(`Provider fixture ${path} has a malformed notification.`); + } } - const notifications = fixture["notifications"]; - const expectedEvents = fixture["expectedEvents"]; - - if (!Array.isArray(notifications) || !Array.isArray(expectedEvents)) { - throw new Error("Provider fixture must include notifications and expectedEvents arrays."); + for (const trackTurn of [ + fixture.trackTurnBeforeNotifications, + fixture.trackTurnAfterNotifications, + ]) { + if ( + trackTurn !== undefined && + (!isRecord(trackTurn) || + (trackTurn["expectation"] !== "reject" && trackTurn["expectation"] !== "resolve") || + typeof trackTurn["turnId"] !== "string") + ) { + throw new TypeError(`Provider fixture ${path} has malformed turn tracking.`); + } } - return { - expectedEvents, - notifications: notifications.map(readProviderNotificationFixture), - trackTurnBeforeNotifications: readTrackTurnFixture(fixture["trackTurnBeforeNotifications"]), - trackTurnAfterNotifications: readTrackTurnFixture(fixture["trackTurnAfterNotifications"]), - }; -} - -function isIsoTimestamp(value: string): boolean { - return value.endsWith("Z") && !Number.isNaN(Date.parse(value)); + return fixture; } -function normalizeBridgeValue(value: unknown, fieldName?: string): unknown { - if (typeof value === "string") { - if (isDriverId(value)) { - return ""; - } - - if (fieldName !== undefined && fieldName.endsWith("At") && isIsoTimestamp(value)) { - return ""; - } - - return value; - } - - if (Array.isArray(value)) { - return value.map((entry) => normalizeBridgeValue(entry)); - } +async function dispatchProviderNotification( + input: ProviderNotificationFixture, + bridge: OpenAiAppServerEventBridge, + context: AgentDriverContext, +): Promise { + const notification = parseServerNotification({ + method: input.method, + params: input.params, + }); - if (!isRecord(value)) { - return value; + if (notification === null) { + return; } - return Object.fromEntries( - Object.entries(value).map(([key, entry]) => [key, normalizeBridgeValue(entry, key)]), - ); + await bridge.handleNotification(context, notification.method, notification.params); } -function normalizeBridgeEvent(event: DriverEventInput): Record { - const eventRecord = event as unknown as Record; - const normalized: Record = { - kind: event.kind, - payload: normalizeBridgeValue(event.payload), - }; +function parseNotificationParams(method: string, params: unknown) { + const notification = parseServerNotification({ method, params }); - for (const field of ["delivery", "native", "runId", "sourceEventId", "visibility"] as const) { - if (eventRecord[field] !== undefined) { - normalized[field] = normalizeBridgeValue(eventRecord[field], field); - } + if (notification === null) { + throw new Error(`Unknown OpenAI app-server notification ${method}.`); } - return normalized; + return notification.params; } -function createHarness() { - const batches: EventBatch[] = []; - const logger = createBufferedSinkLogger({ - level: "debug", - service: "openai-app-server-provider-fixtures-test", - sink: async () => {}, - }); - const context: AgentDriverContext = createAgentDriverContext({ - eventSink: { - pushEvents: async () => ({ accepted: [] }), - }, - logger, - payload: bootPayload, - permission: { - request: async () => "allow_once", - }, - }); - const bridge = new OpenAiAppServerEventBridge({ - push: async (_context, reason, events) => { - batches.push({ events, reason }); - }, - requireThreadId: () => "thread-1", - }); - - return { - batches, - bridge, - context, - events: () => batches.flatMap((batch) => batch.events), - logger, - }; -} - -async function dispatchProviderNotification( - input: ProviderNotificationFixture, - bridge: OpenAiAppServerEventBridge, - context: AgentDriverContext, -): Promise { - if (!isServerNotificationMethod(input.method)) { - await bridge.handleNotification( - context, - input.method as ServerNotificationMethod, - input.params as never, - ); - return; +function createOfficialStatusToolItem( + type: + | "collabAgentToolCall" + | "commandExecution" + | "dynamicToolCall" + | "fileChange" + | "imageGeneration" + | "mcpToolCall", + status: "failed" | "inProgress" | "in_progress", + id: string, +): Record { + switch (type) { + case "commandExecution": + return { + aggregatedOutput: null, + command: "true", + commandActions: [], + cwd: "/workspace", + durationMs: null, + exitCode: status === "failed" ? 1 : null, + id, + pluginId: null, + processId: null, + scriptPath: null, + source: "agent", + status, + type, + }; + case "fileChange": + return { changes: [], id, status, type }; + case "mcpToolCall": + return { + appContext: null, + arguments: {}, + durationMs: null, + error: status === "failed" ? { message: "failed" } : null, + id, + pluginId: null, + readOnlyHint: null, + result: null, + server: "test", + status, + tool: "inspect", + type, + }; + case "dynamicToolCall": + return { + arguments: {}, + contentItems: null, + durationMs: null, + id, + namespace: null, + status, + success: status === "failed" ? false : null, + tool: "inspect", + type, + }; + case "collabAgentToolCall": + return { + agentsStates: {}, + id, + model: null, + prompt: null, + reasoningEffort: null, + receiverThreadIds: [], + senderThreadId: "thread-1", + status, + tool: "wait", + type, + }; + case "imageGeneration": + return { + id, + result: "", + revisedPrompt: null, + status, + type, + }; } - - await bridge.handleNotification( - context, - input.method, - parseServerNotificationParams(input.method, input.params), - ); } async function assertTrackTurnFixture( @@ -238,11 +198,100 @@ async function assertTrackTurnFixture( await expect(result).resolves.toBeUndefined(); } +function createThreadFixture(id = "thread-1") { + return { + agentNickname: null, + agentRole: null, + canAcceptDirectInput: true, + cliVersion: "0.152.0", + createdAt: 1_700_000_000, + cwd: "/workspace", + ephemeral: false, + extra: null, + forkedFromId: null, + gitInfo: null, + historyMode: "paginated", + id, + modelProvider: "openai", + name: null, + parentThreadId: null, + path: null, + preview: "Hello", + projectId: null, + recencyAt: null, + section: null, + sectionEnteredAt: null, + sessionId: "session-1", + source: "appServer", + status: { type: "idle" }, + threadSource: null, + turns: [], + updatedAt: 1_700_000_001, + } as const; +} + +function createThreadStartFixture() { + return { + activePermissionProfile: null, + approvalPolicy: "on-request", + approvalsReviewer: "user", + cwd: "/workspace", + instructionSources: ["/workspace/AGENTS.md"], + model: "gpt-5.6", + modelProvider: "openai", + multiAgentMode: "explicitRequestOnly", + reasoningEffort: "high", + runtimeWorkspaceRoots: ["/workspace"], + sandbox: { + excludeSlashTmp: false, + excludeTmpdirEnvVar: false, + networkAccess: false, + type: "workspaceWrite", + writableRoots: ["/workspace"], + }, + serviceTier: null, + thread: createThreadFixture(), + } as const; +} + +function createTurnFixture() { + return { + completedAt: null, + durationMs: null, + error: null, + id: "turn-1", + items: [], + itemsView: "full", + startedAt: 1_700_000_002, + status: "inProgress", + } as const; +} + describe("OpenAI app-server provider fixtures", () => { - test("matches the 0.144.5 reasoning, MCP progress, and interactive request surface", () => { - expect(OPENAI_APP_SERVER_SCHEMA_VERSION).toBe("0.144.5"); + test("normalizes generated IDs without collapsing their equivalence classes", () => { + const [normalized] = normalizeBridgeEvents([ + { + kind: "diagnostic.reported", + payload: { + first: DRIVER_TEST_IDS.runId, + repeated: DRIVER_TEST_IDS.runId, + second: DRIVER_TEST_IDS.secondRunId, + }, + sourceEventId: `test:${DRIVER_TEST_IDS.thirdRunId}`, + } as unknown as DriverEventInput, + ]); + + expect(normalized?.["payload"]).toEqual({ + first: "", + repeated: "", + second: "", + }); + expect(normalized?.["sourceEventId"]).toBe("test:"); + }); + + test("matches the installed reasoning, MCP progress, and interactive request surface", () => { expect( - parseServerNotificationParams("item/reasoning/summaryPartAdded", { + parseNotificationParams("item/reasoning/summaryPartAdded", { itemId: "reasoning-1", summaryIndex: 1, threadId: "thread-1", @@ -255,7 +304,7 @@ describe("OpenAI app-server provider fixtures", () => { turnId: "turn-1", }); expect( - parseServerNotificationParams("item/mcpToolCall/progress", { + parseNotificationParams("item/mcpToolCall/progress", { itemId: "tool-1", message: "Working", threadId: "thread-1", @@ -271,55 +320,324 @@ describe("OpenAI app-server provider fixtures", () => { expect(isServerRequestMethod("item/tool/call")).toBe(true); expect(isServerRequestMethod("mcpServer/elicitation/request")).toBe(true); expect(isServerRequestMethod("account/chatgptAuthTokens/refresh")).toBe(true); + expect(isServerRequestMethod("applyPatchApproval")).toBe(true); expect(isServerRequestMethod("attestation/generate")).toBe(true); expect(isServerRequestMethod("currentTime/read")).toBe(true); + expect(isServerRequestMethod("execCommandApproval")).toBe(true); + expect(parseServerNotification({ method: "account/updated", params: {} })).toEqual({ + method: "account/updated", + params: {}, + }); expect( - parseServerNotificationParams("serverRequest/resolved", { + parseNotificationParams("serverRequest/resolved", { requestId: 7, threadId: "thread-1", }), ).toEqual({ requestId: 7, threadId: "thread-1" }); + expect( + parseNotificationParams("thread/tokenUsage/updated", { + threadId: "thread-1", + tokenUsage: { + last: { + cacheWriteInputTokens: 3, + cachedInputTokens: 2, + inputTokens: 10, + outputTokens: 4, + reasoningOutputTokens: 1, + totalTokens: 14, + }, + modelContextWindow: 200_000, + total: { + cacheWriteInputTokens: 30, + cachedInputTokens: 20, + inputTokens: 100, + outputTokens: 40, + reasoningOutputTokens: 10, + totalTokens: 140, + }, + }, + turnId: "turn-1", + }), + ).toEqual({ + threadId: "thread-1", + tokenUsage: { + last: { + cacheWriteInputTokens: 3, + cachedInputTokens: 2, + inputTokens: 10, + outputTokens: 4, + reasoningOutputTokens: 1, + totalTokens: 14, + }, + modelContextWindow: 200_000, + total: { + cacheWriteInputTokens: 30, + cachedInputTokens: 20, + inputTokens: 100, + outputTokens: 40, + reasoningOutputTokens: 10, + totalTokens: 140, + }, + }, + turnId: "turn-1", + }); + }); + + test("classifies unused MCP-stream and realtime timeline notifications explicitly", async () => { + const { bridge, context, events } = createHarness(); + const realtimeItem = { + id: "realtime-item-1", + realtimeSessionId: "realtime-1", + type: "realtimeSessionStarted", + } as const; + const notifications: ProviderNotificationFixture[] = [ + { + method: "mcpServer/event/stream/notification", + params: { + notification: { method: "notifications/tools/list_changed", params: {} }, + subscriptionId: "subscription-1", + }, + }, + { + method: "thread/realtime/item/started", + params: { item: realtimeItem, threadId: "thread-1" }, + }, + { + method: "thread/realtime/item/transcript/delta", + params: { delta: "hello", itemId: realtimeItem.id, threadId: "thread-1" }, + }, + { + method: "thread/realtime/item/completed", + params: { item: realtimeItem, threadId: "thread-1" }, + }, + ]; + + for (const notification of notifications) { + expect(parseServerNotification(notification)).not.toBeNull(); + await dispatchProviderNotification(notification, bridge, context); + } + + expect(events()).toEqual([]); }); - test.each([undefined, "inProgress", "unknown"])( + test.each([undefined, "unknown", "inProgress"])( "rejects non-terminal turn/completed status %p", (status) => { expect(() => - parseServerNotificationParams("turn/completed", { + parseNotificationParams("turn/completed", { threadId: "thread-1", - turn: { id: "turn-1", ...(status === undefined ? {} : { status }) }, + turn: { id: "turn-1", items: [], ...(status === undefined ? {} : { status }) }, }), ).toThrow(); }, ); test.each([ - "completedAt", - "durationMs", - "error", - "items", - "itemsView", - "startedAt", - "status", - ] as const)("rejects turn/start without required Turn field %s", (missing) => { - const turn: Record = { - completedAt: null, - durationMs: null, - error: null, - id: "turn-1", - items: [], - itemsView: "notLoaded", - startedAt: null, - status: "inProgress", + ["failed", null], + ["completed", { message: "unexpected" }], + ["interrupted", { message: "unexpected" }], + ] as const)("rejects turn/completed status %s with contradictory error", (status, error) => { + expect(() => + parseNotificationParams("turn/completed", { + threadId: "thread-1", + turn: { error, id: "turn-1", items: [], status }, + }), + ).toThrow("turn.error must be present exactly when the turn failed"); + }); + + test("accepts a failed turn/completed with its required error", () => { + expect( + parseNotificationParams("turn/completed", { + threadId: "thread-1", + turn: { error: { message: "failed" }, id: "turn-1", items: [], status: "failed" }, + }), + ).toMatchObject({ turn: { error: { message: "failed" }, status: "failed" } }); + }); + + test.each(["id", "items", "status"] as const)( + "rejects turn/start without schema-required Turn field %s", + (missing) => { + const turn: Record = { + completedAt: null, + durationMs: null, + error: null, + id: "turn-1", + items: [], + itemsView: "notLoaded", + startedAt: null, + status: "inProgress", + }; + delete turn[missing]; + + expect(() => CLIENT_RESULT_SCHEMAS["turn/start"].parse({ turn })).toThrow(missing); + }, + ); + + test.each(["codexHome", "platformFamily", "platformOs", "userAgent"] as const)( + "rejects initialize without required response field %s", + (missing) => { + const response: Record = { + codexHome: "/tmp/openai-home", + platformFamily: "unix", + platformOs: "linux", + userAgent: "test-app-server/0.152.0", + }; + delete response[missing]; + + expect(() => CLIENT_RESULT_SCHEMAS.initialize.parse(response)).toThrow(missing); + }, + ); + + test("preserves the complete initialize response", () => { + const response = { + codexHome: "/tmp/openai-home", + platformFamily: "unix", + platformOs: "linux", + userAgent: "test-app-server/0.152.0", + }; + + expect(CLIENT_RESULT_SCHEMAS.initialize.parse(response)).toEqual(response); + }); + + test.each([ + "approvalPolicy", + "approvalsReviewer", + "cwd", + "model", + "modelProvider", + "sandbox", + "thread", + ] as const)("rejects thread/start without schema-required response field %s", (missing) => { + const response: Record = { ...createThreadStartFixture() }; + delete response[missing]; + + expect(() => CLIENT_RESULT_SCHEMAS["thread/start"].parse(response)).toThrow(missing); + }); + + test("preserves the complete thread/start response and rejects a sparse thread", () => { + const response = createThreadStartFixture(); + + expect(CLIENT_RESULT_SCHEMAS["thread/start"].parse(response)).toEqual(response); + expect(() => + CLIENT_RESULT_SCHEMAS["thread/start"].parse({ + ...response, + thread: { id: "thread-1" }, + }), + ).toThrow("sessionId"); + }); + + test.each(["thread/start", "thread/resume"] as const)( + "preserves omitted optional %s wire fields", + (method: "thread/resume" | "thread/start") => { + const response: Record = { + ...createThreadStartFixture(), + ...(method === "thread/resume" + ? { + initialTurnsPage: null, + itemsBackwardsCursor: null, + turnsBackwardsCursor: null, + } + : {}), + }; + delete response["reasoningEffort"]; + delete response["serviceTier"]; + + expect(CLIENT_RESULT_SCHEMAS[method].parse(response)).toEqual(response); + }, + ); + + test("applies the official thread/resume pagination defaults", () => { + const response = createThreadStartFixture(); + + expect(CLIENT_RESULT_SCHEMAS["thread/resume"].parse(response)).toEqual({ + ...response, + initialTurnsPage: null, + itemsBackwardsCursor: null, + turnsBackwardsCursor: null, + }); + }); + + test("preserves the complete thread/resume pagination response", () => { + const response = { + ...createThreadStartFixture(), + initialTurnsPage: { + backwardsCursor: "turn-head", + data: [createTurnFixture()], + nextCursor: null, + }, + itemsBackwardsCursor: "item-head", + turnsBackwardsCursor: "turn-head", + }; + + expect(CLIENT_RESULT_SCHEMAS["thread/resume"].parse(response)).toEqual(response); + }); + + test("preserves structured turn errors and rejects unknown thread items", () => { + const turn = { + ...createTurnFixture(), + error: { + additionalDetails: "HTTP 502 from upstream.", + codexErrorInfo: { responseStreamDisconnected: { httpStatusCode: 502 } }, + message: "Response stream disconnected.", + misalignment: null, + }, + status: "failed", }; - delete turn[missing]; - expect(() => parseClientRequestResult("turn/start", { turn })).toThrow(missing); + expect(CLIENT_RESULT_SCHEMAS["turn/start"].parse({ turn })).toEqual({ turn }); + expect(() => + CLIENT_RESULT_SCHEMAS["turn/start"].parse({ + turn: { ...createTurnFixture(), items: [{ id: "item-1", type: "futureItem" }] }, + }), + ).toThrow("Invalid input"); + }); + + test.each([ + ["failed", null], + ["completed", { message: "unexpected" }], + ["interrupted", { message: "unexpected" }], + ["inProgress", { message: "unexpected" }], + ] as const)("rejects turn/start status %s with contradictory error", (status, error) => { + expect(() => + CLIENT_RESULT_SCHEMAS["turn/start"].parse({ + turn: { error, id: "turn-1", items: [], status }, + }), + ).toThrow("turn.error must be present exactly when the turn failed"); + }); + + test("accepts an in-progress turn/start without an error", () => { + expect( + CLIENT_RESULT_SCHEMAS["turn/start"].parse({ + turn: { id: "turn-1", items: [], status: "inProgress" }, + }), + ).toMatchObject({ turn: { status: "inProgress" } }); + }); + + test("accepts omitted optional TurnError wire fields without inventing classification", () => { + expect( + parseNotificationParams("error", { + error: { message: "Provider failed." }, + threadId: "thread-1", + turnId: "turn-1", + willRetry: false, + }), + ).toEqual({ + error: { additionalDetails: null, message: "Provider failed.", misalignment: null }, + threadId: "thread-1", + turnId: "turn-1", + willRetry: false, + }); + }); + + test("rejects a non-object thread/inject_items response", () => { + expect(() => CLIENT_RESULT_SCHEMAS["thread/inject_items"].parse(null)).toThrow( + "expected object", + ); }); test("preserves the assistant item identity on text deltas", () => { expect( - parseServerNotificationParams("item/agentMessage/delta", { + parseNotificationParams("item/agentMessage/delta", { delta: "中文 delta", itemId: "message-1", threadId: "thread-1", @@ -332,7 +650,7 @@ describe("OpenAI app-server provider fixtures", () => { turnId: "turn-1", }); expect(() => - parseServerNotificationParams("item/agentMessage/delta", { + parseNotificationParams("item/agentMessage/delta", { delta: "missing identity", threadId: "thread-1", turnId: "turn-1", @@ -342,7 +660,7 @@ describe("OpenAI app-server provider fixtures", () => { test("preserves the terminal turn item loading state", () => { expect( - parseServerNotificationParams("turn/completed", { + parseNotificationParams("turn/completed", { threadId: "thread-1", turn: { id: "turn-1", @@ -361,11 +679,688 @@ describe("OpenAI app-server provider fixtures", () => { }); }); + test("maps official collaboration items to complete tool lifecycles", async () => { + const { bridge, context, events } = createHarness(); + const cases = [ + { + agentStatus: "completed", + id: "collab-ok", + message: "result", + publicStatus: "completed", + status: "completed", + }, + { + agentStatus: "errored", + id: "collab-failed", + message: "boom", + publicStatus: "failed", + status: "failed", + }, + { + agentStatus: "interrupted", + id: "collab-interrupted", + message: "stopped", + publicStatus: "cancelled", + status: "interrupted", + }, + ] as const; + + for (const item of cases) { + await dispatchProviderNotification( + { + method: "item/started", + params: { + item: { + agentsStates: {}, + id: item.id, + model: "gpt-5.4", + prompt: "Inspect the migration", + reasoningEffort: "high", + receiverThreadIds: ["agent-1"], + senderThreadId: "thread-1", + status: "inProgress", + tool: "wait", + type: "collabAgentToolCall", + }, + startedAtMs: 1, + threadId: "thread-1", + turnId: "turn-1", + }, + }, + bridge, + context, + ); + await dispatchProviderNotification( + { + method: "item/completed", + params: { + completedAtMs: 2, + item: { + agentsStates: { + "agent-1": { message: item.message, status: item.agentStatus }, + }, + id: item.id, + model: "gpt-5.4", + prompt: "Inspect the migration", + reasoningEffort: "high", + receiverThreadIds: ["agent-1"], + senderThreadId: "thread-1", + status: item.status, + tool: "wait", + type: "collabAgentToolCall", + }, + threadId: "thread-1", + turnId: "turn-1", + }, + }, + bridge, + context, + ); + } + + for (const item of cases) { + const updates = events().filter( + (event) => + event.kind === "tool.call.updated" && + isRecord(event.payload) && + event.payload["toolCallId"] === item.id, + ); + + expect( + updates.map((event) => (isRecord(event.payload) ? event.payload["status"] : null)), + ).toEqual(["running", item.publicStatus]); + expect(updates.at(-1)).toMatchObject({ + payload: { + agentId: "agent-1", + structuredOutput: { + agentsStates: { + "agent-1": { message: item.message, status: item.agentStatus }, + }, + model: "gpt-5.4", + prompt: "Inspect the migration", + reasoningEffort: "high", + receiverThreadIds: ["agent-1"], + senderThreadId: "thread-1", + status: item.status, + tool: "wait", + }, + }, + }); + } + }); + + test.each([ + ["sendMessage", "Send message to agent"], + ["followupTask", "Follow up with agent"], + ["interruptAgent", "Interrupt agent"], + ["listAgents", "List agents"], + ] as const)("maps the new %s collaboration tool lifecycle", async (tool, title) => { + const { bridge, context, events } = createHarness(); + const baseItem = { + agentsStates: {}, + id: `collab-${tool}`, + model: null, + prompt: null, + reasoningEffort: null, + receiverThreadIds: [], + senderThreadId: "thread-1", + tool, + type: "collabAgentToolCall", + } as const; + + for (const [method, status] of [ + ["item/started", "inProgress"], + ["item/completed", "completed"], + ] as const) { + await dispatchProviderNotification( + { + method, + params: { + ...(method === "item/started" ? { startedAtMs: 1 } : { completedAtMs: 2 }), + item: { ...baseItem, status }, + threadId: "thread-1", + turnId: "turn-1", + }, + }, + bridge, + context, + ); + } + + expect(events()).toContainEqual( + expect.objectContaining({ + kind: "tool.call.updated", + payload: expect.objectContaining({ status: "running", title }), + }), + ); + expect(events()).toContainEqual( + expect.objectContaining({ + kind: "tool.call.updated", + payload: expect.objectContaining({ status: "completed" }), + }), + ); + }); + + test("maps official sleep items to tool lifecycle", async () => { + const { bridge, context, events } = createHarness(); + const item = { durationMs: 2_000, id: "sleep-1", type: "sleep" } as const; + + for (const method of ["item/started", "item/completed"] as const) { + await dispatchProviderNotification( + { + method, + params: { + ...(method === "item/started" ? { startedAtMs: 1 } : { completedAtMs: 2 }), + item, + threadId: "thread-1", + turnId: "turn-1", + }, + }, + bridge, + context, + ); + } + + const updates = events().filter( + (event) => + event.kind === "tool.call.updated" && + isRecord(event.payload) && + event.payload["toolCallId"] === item.id, + ); + expect( + updates.map((event) => (isRecord(event.payload) ? event.payload["status"] : null)), + ).toEqual(["running", "completed"]); + expect(updates.at(-1)).toMatchObject({ payload: { rawOutput: "Slept for 2000 ms." } }); + }); + + test("preserves command, MCP, and dynamic tool inputs and structured results", async () => { + const { bridge, context, events } = createHarness(); + const items = [ + { + aggregatedOutput: "ok\n", + command: "bun test", + commandActions: [{ command: "bun test", type: "unknown" }], + cwd: "/workspace", + durationMs: 42, + exitCode: 0, + id: "command-audit", + pluginId: "plugin.test", + processId: "process-1", + scriptPath: "scripts/test.ts", + source: "agent", + status: "completed", + type: "commandExecution", + }, + { + appContext: null, + arguments: { depth: 2, path: "src" }, + durationMs: 7, + error: null, + id: "mcp-audit", + pluginId: null, + readOnlyHint: true, + result: { + _meta: { private: true }, + content: [{ _meta: { blockPrivate: true }, text: "done", type: "text" }], + structuredContent: { + _meta: { structuredPrivate: true }, + files: ["src/index.ts"], + }, + }, + server: "filesystem", + status: "completed", + tool: "inspect", + type: "mcpToolCall", + }, + { + arguments: { query: "migration" }, + contentItems: [{ text: "found", type: "inputText" }], + durationMs: 9, + id: "dynamic-audit", + namespace: "project", + status: "completed", + success: true, + tool: "lookup", + type: "dynamicToolCall", + }, + ] as const; + + for (const item of items) { + await dispatchProviderNotification( + { + method: "item/completed", + params: { completedAtMs: 2, item, threadId: "thread-1", turnId: "turn-1" }, + }, + bridge, + context, + ); + } + + const terminalPayload = (toolCallId: string) => + events().find( + (event) => + event.kind === "tool.call.updated" && + isRecord(event.payload) && + event.payload["toolCallId"] === toolCallId && + event.payload["status"] === "completed", + )?.payload; + expect(terminalPayload("command-audit")).toMatchObject({ + rawInput: "bun test", + rawOutput: "ok\n", + structuredOutput: { + commandActions: [{ command: "bun test", type: "unknown" }], + cwd: "/workspace", + durationMs: 42, + exitCode: 0, + pluginId: "plugin.test", + processId: "process-1", + scriptPath: "scripts/test.ts", + source: "agent", + }, + }); + expect(terminalPayload("mcp-audit")).toMatchObject({ + rawInput: '{"depth":2,"path":"src"}', + rawOutput: '[{"text":"done","type":"text"}]', + structuredOutput: { files: ["src/index.ts"] }, + }); + expect(JSON.stringify(terminalPayload("mcp-audit"))).not.toContain("_meta"); + expect(JSON.stringify(terminalPayload("mcp-audit"))).not.toContain("private"); + expect(terminalPayload("dynamic-audit")).toMatchObject({ + rawInput: '{"query":"migration"}', + structuredOutput: { + contentItems: [{ text: "found", type: "inputText" }], + durationMs: 9, + namespace: "project", + success: true, + }, + }); + }); + + test("fails closed when no durable image transport is available", async () => { + const pngBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + const { bridge, context, events } = createHarness(); + + await expect( + dispatchProviderNotification( + { + method: "item/completed", + params: { + completedAtMs: 2, + item: { + id: "image-outside", + result: pngBase64, + revisedPrompt: null, + savedPath: "/untrusted/provider.png", + status: "completed", + type: "imageGeneration", + }, + threadId: "thread-1", + turnId: "turn-1", + }, + }, + bridge, + context, + ), + ).rejects.toThrow("without a supported durable image transport"); + expect(JSON.stringify(events())).not.toContain("/untrusted/provider.png"); + }); + + test("maps the remaining official ThreadItem variants without silent data loss", async () => { + const { bridge, context, events } = createHarness(); + const completedItems = [ + { + action: { queries: ["OpenAI app-server 0.147"], query: null, type: "search" }, + id: "web-1", + query: "OpenAI app-server 0.147", + results: [{ title: "Protocol release", url: "https://example.test/release" }], + type: "webSearch", + }, + { id: "image-view-1", path: "/tmp/image.png", type: "imageView" }, + { id: "review-enter-1", review: "Review changes", type: "enteredReviewMode" }, + { id: "review-exit-1", review: "Review complete", type: "exitedReviewMode" }, + { id: "compact-1", type: "contextCompaction" }, + ] as const; + + for (const item of completedItems) { + await dispatchProviderNotification( + { + method: "item/completed", + params: { + completedAtMs: 2, + item, + threadId: "thread-1", + turnId: "turn-1", + }, + }, + bridge, + context, + ); + } + + expect(events()).toContainEqual( + expect.objectContaining({ + kind: "tool.call.updated", + payload: expect.objectContaining({ + structuredOutput: { + action: { queries: ["OpenAI app-server 0.147"], query: null, type: "search" }, + query: "OpenAI app-server 0.147", + results: [{ title: "Protocol release", url: "https://example.test/release" }], + }, + toolCallId: "web-1", + }), + }), + ); + expect(events()).toContainEqual( + expect.objectContaining({ + kind: "tool.call.updated", + payload: expect.objectContaining({ + rawOutput: "/tmp/image.png", + toolCallId: "image-view-1", + }), + }), + ); + expect(events().filter((event) => event.kind === "review.updated")).toMatchObject([ + { payload: { mode: "entered", review: "Review changes", status: "completed" } }, + { payload: { mode: "exited", review: "Review complete", status: "completed" } }, + ]); + expect(events()).toContainEqual( + expect.objectContaining({ + kind: "context.compacted", + payload: { itemId: "compact-1", status: "completed" }, + }), + ); + + const eventCount = events().length; + for (const item of [ + { clientId: null, content: [], id: "user-echo-1", type: "userMessage" }, + { + fragments: [{ hookRunId: "hook-1", text: "provider echo" }], + id: "hook-echo-1", + type: "hookPrompt", + }, + { + id: "function-output-echo-1", + name: "lookup", + namespace: "project", + output: "provider echo", + type: "functionCallOutput", + }, + ] as const) { + for (const method of ["item/started", "item/completed"] as const) { + await dispatchProviderNotification( + { + method, + params: { + ...(method === "item/started" ? { startedAtMs: 2 } : { completedAtMs: 3 }), + item, + threadId: "thread-1", + turnId: "turn-1", + }, + }, + bridge, + context, + ); + } + } + expect(events()).toHaveLength(eventCount); + }); + + test.each(["started", "interacted", "interrupted", "completed"] as const)( + "publishes official %s sub-agent activity from completion-only delivery", + async (activityKind) => { + const { bridge, context, events } = createHarness(); + const item = { + agentPath: "/root/worker", + agentThreadId: "agent-1", + id: `activity-${activityKind}`, + kind: activityKind, + type: "subAgentActivity", + } as const; + + await dispatchProviderNotification( + { + method: "item/completed", + params: { completedAtMs: 2, item, threadId: "thread-1", turnId: "turn-1" }, + }, + bridge, + context, + ); + expect(events()).toEqual([ + { + delivery: "lossless", + kind: "agent.task.updated", + payload: { + ...(activityKind === "started" + ? { active: true, status: "running" } + : activityKind === "interacted" + ? {} + : { + active: false, + status: activityKind === "interrupted" ? "cancelled" : "completed", + }), + activityKind, + agentId: "agent-1", + agentPath: "/root/worker", + taskId: "agent-1", + title: `Sub-agent ${activityKind}`, + }, + sourceEventId: expect.stringMatching(/^openai\.derived:sid1_/), + }, + { + delivery: "lossless", + kind: "agent.tasks.replaced", + payload: { + tasks: + activityKind === "started" || activityKind === "interacted" + ? [ + { + taskId: "agent-1", + taskType: "openai_subagent", + title: "/root/worker", + }, + ] + : [], + }, + sourceEventId: expect.stringMatching(/^openai\.derived:sid1_/), + visibility: "participant", + }, + ]); + }, + ); + + test("publishes sub-agent activity and ignores tool-output echo from a terminal snapshot", async () => { + const { bridge, context, events } = createHarness(); + + await dispatchProviderNotification( + { + method: "turn/completed", + params: { + threadId: "thread-1", + turn: { + id: "turn-snapshot", + items: [ + { + id: "function-output-snapshot", + name: "lookup", + namespace: null, + output: "provider echo", + type: "functionCallOutput", + }, + { + agentPath: "/root/replayed-worker", + agentThreadId: "agent-replayed", + id: "activity-replayed", + kind: "interacted", + type: "subAgentActivity", + }, + ], + status: "completed", + }, + }, + }, + bridge, + context, + ); + + expect(events()).toContainEqual({ + delivery: "lossless", + kind: "agent.task.updated", + payload: { + activityKind: "interacted", + agentId: "agent-replayed", + agentPath: "/root/replayed-worker", + taskId: "agent-replayed", + title: "Sub-agent interacted", + }, + sourceEventId: expect.stringMatching(/^openai\.derived:sid1_/), + }); + const snapshots = events().filter((event) => event.kind === "agent.tasks.replaced"); + expect(snapshots).toMatchObject([ + { payload: { tasks: [{ taskId: "agent-replayed" }] } }, + { delivery: "lossless", payload: { tasks: [] }, visibility: "participant" }, + ]); + expect(events().findIndex((event) => event.kind === "agent.tasks.replaced")).toBeLessThan( + events().findIndex((event) => event.kind === "run.completed"), + ); + }); + + test.each([ + ["commandExecution", "inProgress"], + ["fileChange", "inProgress"], + ["mcpToolCall", "inProgress"], + ["dynamicToolCall", "inProgress"], + ["collabAgentToolCall", "inProgress"], + ["imageGeneration", "in_progress"], + ] as const)( + "fails closed on a schema-valid nonterminal %s completion before mutating state", + async (type, runningStatus) => { + const { bridge, context, events } = createHarness(); + const id = `nonterminal-${type}`; + + await expect( + dispatchProviderNotification( + { + method: "item/completed", + params: { + completedAtMs: 2, + item: createOfficialStatusToolItem(type, runningStatus, id), + threadId: "thread-1", + turnId: "turn-1", + }, + }, + bridge, + context, + ), + ).rejects.toThrow(`OpenAI ${type} completed with non-terminal status ${runningStatus}`); + + // A valid terminal retry for the same item must still publish, proving rejection did not + // poison the completion dedupe or tool state. + await dispatchProviderNotification( + { + method: "item/completed", + params: { + completedAtMs: 3, + item: createOfficialStatusToolItem(type, "failed", id), + threadId: "thread-1", + turnId: "turn-1", + }, + }, + bridge, + context, + ); + + expect(events()).toContainEqual( + expect.objectContaining({ + kind: "tool.call.updated", + payload: expect.objectContaining({ status: "failed", toolCallId: id }), + }), + ); + }, + ); + + test("rejects completed images and diagnoses provider-declared image failure", async () => { + const { bridge, context, events } = createHarness(); + + await expect( + dispatchProviderNotification( + { + method: "item/completed", + params: { + completedAtMs: 2, + item: { + id: "image-invalid", + result: "not base64", + revisedPrompt: null, + status: "completed", + type: "imageGeneration", + }, + threadId: "thread-1", + turnId: "turn-1", + }, + }, + bridge, + context, + ), + ).rejects.toThrow("without a supported durable image transport"); + + await dispatchProviderNotification( + { + method: "item/completed", + params: { + completedAtMs: 3, + item: { + failure: { + limitId: "image_generation", + resetsAt: null, + type: "usageLimitExceeded", + }, + id: "image-failed", + result: "", + revisedPrompt: "Failed prompt", + savedPath: "/untrusted/failed.png", + status: "failed", + type: "imageGeneration", + }, + threadId: "thread-1", + turnId: "turn-1", + }, + }, + bridge, + context, + ); + + expect(events()).toContainEqual( + expect.objectContaining({ + kind: "diagnostic.reported", + payload: expect.objectContaining({ code: "openai.image_generation.failed" }), + visibility: "owner_debug", + }), + ); + expect(events()).toContainEqual( + expect.objectContaining({ + kind: "tool.call.updated", + payload: expect.objectContaining({ + status: "failed", + structuredOutput: { + failure: { + limitId: "image_generation", + resetsAt: null, + type: "usageLimitExceeded", + }, + }, + toolCallId: "image-failed", + }), + }), + ); + expect(JSON.stringify(events())).not.toContain("/untrusted/failed.png"); + }); + test.each(providerFixtureNames)("apps provider-native fixture %s", async (name) => { const fixture = readProviderFixtureCase( `./fixtures/providers/openai-app-server/cases/${name}.json`, ); - const { bridge, context, events, logger } = createHarness(); + const { bridge, context, events } = createHarness(); const trackedTurn = fixture.trackTurnBeforeNotifications; const trackedCompletion = @@ -385,8 +1380,7 @@ describe("OpenAI app-server provider fixtures", () => { } } await assertTrackTurnFixture(bridge, fixture.trackTurnAfterNotifications); - await logger.destroy(); - expect(events().map(normalizeBridgeEvent)).toEqual(fixture.expectedEvents); + expect(normalizeBridgeEvents(events())).toEqual(fixture.expectedEvents); }); }); diff --git a/tests/openai-app-server-request-handler.test.ts b/tests/openai-app-server-request-handler.test.ts index ff6587b..8ff61b4 100644 --- a/tests/openai-app-server-request-handler.test.ts +++ b/tests/openai-app-server-request-handler.test.ts @@ -2,30 +2,40 @@ import { expect, test } from "bun:test"; import { createAgentDriverContext } from "../src/core/agent-driver-backend"; import { PermissionEventDeliveryError } from "../src/core/driver-permission-broker"; -import { createBufferedSinkLogger } from "../src/observability"; +import type { DriverPermissionRequest } from "../src/host-ports"; +import { createDisabledLogger } from "../src/observability"; import { OpenAiAppServerRequestHandler } from "../src/runtimes/openai/app-server-request-handler"; +import { parseServerRequest } from "../src/runtimes/openai/app-server-protocol"; import { driverStartInput } from "./driver-boot-payload-fixture"; test("OpenAI server request callbacks handle, reject, and cancel explicitly", async () => { const permissionStarted = Promise.withResolvers(); - let permissionSignal: AbortSignal | null = null; + const permissionInputs: DriverPermissionRequest[] = []; + const permissionSignals: AbortSignal[] = []; const responses: Array<{ id: string | number; result: unknown }> = []; const rejections: Array<{ id: string | number; message: string }> = []; const errors: Error[] = []; - const logger = createBufferedSinkLogger({ - level: "error", - service: "openai-request-handler-test", - sink: async () => {}, - }); const context = createAgentDriverContext({ - eventSink: { pushEvents: async () => ({ accepted: [] }) }, - logger, + eventSink: { + currentRunId: () => null, + pushEvents: async () => ({ accepted: [] }), + }, + logger: createDisabledLogger(), payload: driverStartInput, permission: { - request: async (_input, signal) => { + request: async (input, signal) => { const requestSignal = signal ?? new AbortController().signal; - permissionSignal = requestSignal; - permissionStarted.resolve(); + permissionInputs.push(input); + permissionSignals.push(requestSignal); + if (permissionInputs.length === 4) { + permissionStarted.resolve(); + } + if (input.toolKind === "item/permissions/requestApproval") { + return "allow_once"; + } + if (input.toolKind === "item/commandExecution/requestApproval") { + return "allow_once"; + } return await new Promise<"allow_once">((_resolve, reject) => { requestSignal.addEventListener("abort", () => reject(requestSignal.reason), { once: true, @@ -40,52 +50,186 @@ test("OpenAI server request callbacks handle, reject, and cancel explicitly", as errors.push(error); }, isStopped: () => false, + mapToolCallId: (toolCallId) => toolCallId, respond: (id, result) => responses.push({ id, result }), respondError: (id, message) => rejections.push({ id, message }), }); try { - handler.dispatch("currentTime/read", 1, {}); + handler.dispatch("currentTime/read", 1, { threadId: "thread-1" }); handler.dispatch("attestation/generate", 2, {}); + handler.dispatch("applyPatchApproval", 6, {}); + handler.dispatch("execCommandApproval", 7, {}); + const openAiFormRequest = parseServerRequest({ + id: 8, + method: "mcpServer/elicitation/request", + params: { + _meta: { "example/request": "template-picker" }, + message: "Choose a template", + mode: "openaiForm", + requestedSchema: { + properties: { template: { type: "string", "x-openai-preview": {} } }, + type: "object", + }, + serverName: "codex_apps", + threadId: "thread-1", + turnId: null, + }, + }); + if (openAiFormRequest === null) { + throw new Error("Codex 0.152 openaiForm elicitation did not match the generated schema."); + } + handler.dispatch(openAiFormRequest.method, openAiFormRequest.id, openAiFormRequest.params); handler.dispatch("item/commandExecution/requestApproval", 3, { - command: "pwd", + additionalPermissions: { + fileSystem: { write: ["/secrets"] }, + network: { enabled: true }, + }, + command: "npm test", + cwd: "/workspace", + availableDecisions: [ + "acceptForSession", + { + acceptWithExecpolicyAmendment: { + execpolicy_amendment: ["npm", "test"], + }, + }, + "cancel", + ], itemId: "tool-1", }); + handler.dispatch("item/commandExecution/requestApproval", "3", { + command: "npm test", + itemId: "tool-string-3", + }); + handler.dispatch("item/fileChange/requestApproval", 4, { + grantRoot: "/workspace", + itemId: "tool-2", + reason: "Apply changes", + }); + handler.dispatch("item/permissions/requestApproval", 5, { + cwd: "/workspace", + environmentId: "sandbox-1", + permissions: { fileSystem: { read: ["/workspace"] }, network: null }, + reason: "Install dependencies", + }); await permissionStarted.promise; await handler.abortAll(new Error("turn cancelled")); - expect(responses).toHaveLength(1); - expect(responses[0]).toMatchObject({ id: 1, result: { currentTimeAt: expect.any(Number) } }); + expect(responses).toEqual( + expect.arrayContaining([ + { id: 1, result: { currentTimeAt: expect.any(Number) } }, + { id: 3, result: { decision: "cancel" } }, + { id: "3", result: { decision: "accept" } }, + { + id: 5, + result: { + permissions: { fileSystem: { read: ["/workspace"] } }, + scope: "turn", + }, + }, + ]), + ); expect(rejections).toEqual([ { id: 2, message: "Unsupported OpenAi app-server request: attestation/generate." }, + { id: 6, message: "Unsupported OpenAi app-server request: applyPatchApproval." }, + { id: 7, message: "Unsupported OpenAi app-server request: execCommandApproval." }, + { + id: 8, + message: "Unsupported OpenAi app-server request: mcpServer/elicitation/request.", + }, + ]); + expect(permissionSignals.filter((signal) => signal.aborted)).toHaveLength(1); + expect(permissionInputs.map(({ rawInput }) => JSON.parse(rawInput ?? "null"))).toEqual([ + expect.objectContaining({ + additionalPermissions: { + fileSystem: { write: ["/secrets"] }, + network: { enabled: true }, + }, + command: "npm test", + cwd: "/workspace", + }), + expect.objectContaining({ command: "npm test", itemId: "tool-string-3" }), + expect.objectContaining({ grantRoot: "/workspace", reason: "Apply changes" }), + expect.objectContaining({ + cwd: "/workspace", + environmentId: "sandbox-1", + permissions: { fileSystem: { read: ["/workspace"] }, network: null }, + }), + ]); + expect(permissionInputs.map(({ title }) => title)).toEqual([ + "Approve command execution", + "Approve command execution", + "Approve file changes", + "Approve runtime permissions", + ]); + expect(permissionInputs.map(({ requestId }) => requestId)).toEqual([ + "item/commandExecution/requestApproval:number:3", + "item/commandExecution/requestApproval:string:3", + "item/fileChange/requestApproval:number:4", + "item/permissions/requestApproval:number:5", ]); - expect((permissionSignal as AbortSignal | null)?.aborted).toBe(true); - expect(responses.some((response) => response.id === 3)).toBe(false); expect(rejections.some((response) => response.id === 3)).toBe(false); expect(errors).toEqual([]); } finally { await handler.abortAll(new Error("test complete")); - await logger.destroy(); } }); +test("OpenAI writeStdin approval uses terminal-input semantics", async () => { + const permission = Promise.withResolvers(); + const response = Promise.withResolvers(); + const context = createAgentDriverContext({ + eventSink: { + currentRunId: () => null, + pushEvents: async () => ({ accepted: [] }), + }, + logger: createDisabledLogger(), + payload: driverStartInput, + permission: { + request: async (input) => { + permission.resolve(input); + return "allow_once"; + }, + }, + }); + const handler = new OpenAiAppServerRequestHandler({ + context, + handleError: async () => {}, + isStopped: () => false, + mapToolCallId: (toolCallId) => toolCallId, + respond: (_id, result) => response.resolve(result), + respondError: (_id, message) => response.reject(new Error(message)), + }); + + handler.dispatch("item/commandExecution/requestApproval", 1, { + approvalId: "approval-1", + itemId: "command-1", + kind: "writeStdin", + }); + + await expect(response.promise).resolves.toEqual({ decision: "accept" }); + expect(await permission.promise).toMatchObject({ + title: "Approve terminal input", + toolCallId: "command-1", + }); +}); + test("OpenAI server request cancellation propagates permission delivery failures", async () => { const permissionStarted = Promise.withResolvers(); const deliveryGate = Promise.withResolvers(); const deliveryError = new PermissionEventDeliveryError( - "item/commandExecution/requestApproval:3", + "item/commandExecution/requestApproval:number:3", "resolved", new Error("event sink unavailable"), ); const handledErrors: Error[] = []; - const logger = createBufferedSinkLogger({ - level: "error", - service: "openai-request-handler-test", - sink: async () => {}, - }); const context = createAgentDriverContext({ - eventSink: { pushEvents: async () => ({ accepted: [] }) }, - logger, + eventSink: { + currentRunId: () => null, + pushEvents: async () => ({ accepted: [] }), + }, + logger: createDisabledLogger(), payload: driverStartInput, permission: { request: async (_input, signal) => { @@ -109,6 +253,7 @@ test("OpenAI server request cancellation propagates permission delivery failures handledErrors.push(error); }, isStopped: () => false, + mapToolCallId: (toolCallId) => toolCallId, respond: () => {}, respondError: () => {}, }); @@ -128,6 +273,5 @@ test("OpenAI server request cancellation propagates permission delivery failures } finally { deliveryGate.resolve(); await handler.abortAll(new Error("test complete")); - await logger.destroy(); } }); diff --git a/tests/openai-app-server-startup.test.ts b/tests/openai-app-server-startup.test.ts index 8283115..c19471c 100644 --- a/tests/openai-app-server-startup.test.ts +++ b/tests/openai-app-server-startup.test.ts @@ -1,32 +1,69 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; -import { chmod, mkdtemp, readFile, rm } from "node:fs/promises"; +import { chmod, lstat, mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { AgentDriverPermissionPort } from "../src/host-ports"; -import { createBufferedSinkLogger } from "../src/observability"; +import { createDisabledLogger } from "../src/observability"; import type { DriverPermissionPolicy } from "../src/protocol/boot"; import type { DriverEventInput } from "../src/protocol/events"; +import { isDriverId } from "../src/protocol/id"; import { createDriverStartInputFromBootPayload } from "../src/protocol/start"; +import type { AgentDriverBackend } from "../src/core/agent-driver-backend"; import { createAgentDriverContext } from "../src/core/agent-driver-backend"; import { AgentDriverKernelCore } from "../src/core/agent-driver-kernel"; import { ACTIVE_TURN_CANCEL_GRACE_MS } from "../src/core/driver-command-dispatcher"; +import { toDriverEventEnvelopes } from "../src/infrastructure/runtime/driver-instance-socket"; import { OpenAiAppServerClient } from "../src/runtimes/openai/app-server-client"; import { OpenAiAppServerDriverBackend } from "../src/runtimes/openai/app-server-driver-backend"; +import { DriverEventPublisher } from "../src/runtimes/driver-event-publisher"; +import { createCmaMemoryStore } from "../src/stores/memory"; import { DRIVER_TEST_IDS, driverBootPayload } from "./driver-boot-payload-fixture"; import { settlePromiseWithTimeout } from "../src/utils/async"; const originalExecutable = process.env["MOSOO_OPENAI_RUNTIME_EXECUTABLE"]; const temporaryDirectories: string[] = []; +const initializeResultJson = JSON.stringify({ + codexHome: "/tmp/openai-home", + platformFamily: "unix", + platformOs: "linux", + userAgent: "test-app-server/0.152.0", +}); + +function eventPayloadStatus(event: DriverEventInput): unknown { + const { payload } = event; + + return typeof payload === "object" && payload !== null && !Array.isArray(payload) + ? (payload as Record)["status"] + : undefined; +} interface CancellationHarnessOptions { readonly backgroundTerminalCleanFailure?: "error" | "timeout"; + readonly duplicateRequestPhase?: "active_turn" | "turn_start"; readonly emitInterruptedTurnOnStart?: boolean; + readonly terminalNotificationBeforeTurnStartResponse?: boolean; + readonly emitToolCompletionOnTurnStart?: boolean; + readonly environmentVariables?: Readonly>; readonly failCancellationRequest?: boolean; readonly failInitialThreadStart?: boolean; readonly holdCancellationRequest?: boolean; readonly holdRunCancellation?: boolean; + readonly holdMessageStart?: boolean; + readonly holdTurnStartAfterItems?: boolean; + readonly holdToolCompletion?: boolean; readonly restartResumeError?: string; + readonly threadId?: string; + readonly turnId?: string; + readonly turnStartErrorMessage?: string; + readonly toolStartFollowup?: + | "error_response" + | "error_then_malformed" + | "error_then_success" + | "malformed" + | "terminal_response" + | "terminal_then_malformed"; + readonly useCma?: boolean; } afterEach(async () => { @@ -79,7 +116,12 @@ const launchNumber = existsSync(${JSON.stringify(launchCountFile)}) ? Number(readFileSync(${JSON.stringify(launchCountFile)}, "utf8")) + 1 : 1; writeFileSync(${JSON.stringify(launchCountFile)}, String(launchNumber)); -appendFileSync(${JSON.stringify(processLog)}, JSON.stringify({ launchNumber, pid: process.pid }) + "\\n"); +appendFileSync(${JSON.stringify(processLog)}, JSON.stringify({ + codexHome: process.env.CODEX_HOME, + launchNumber, + pid: process.pid, + sqliteHome: process.env.CODEX_SQLITE_HOME, +}) + "\\n"); const sendInterrupted = (turnId) => process.stdout.write(JSON.stringify({ method: "turn/completed", params: { @@ -96,6 +138,135 @@ const sendInterrupted = (turnId) => process.stdout.write(JSON.stringify({ }, }, }) + "\\n"); +const sendTurnStarted = (turnId) => process.stdout.write(JSON.stringify({ + method: "turn/started", + params: { + threadId: "fresh-thread", + turn: { + completedAt: null, + durationMs: null, + error: null, + id: turnId, + items: [], + itemsView: "notLoaded", + startedAt: Date.now(), + status: "inProgress", + }, + }, +}) + "\\n"); +const toolItem = { + aggregatedOutput: "done", + command: "printf done", + commandActions: [], + cwd: ${JSON.stringify(directory)}, + durationMs: 1, + exitCode: 0, + id: "tool-1", + pluginId: null, + processId: null, + scriptPath: null, + source: "agent", + status: "completed", + type: "commandExecution", +}; +const toolStartMessage = (turnId) => JSON.stringify({ + method: "item/started", + params: { + item: { ...toolItem, aggregatedOutput: null, durationMs: null, exitCode: null, status: "inProgress" }, + startedAtMs: 1, + threadId: "fresh-thread", + turnId, + }, + }) + "\\n"; +const sendToolLifecycle = (turnId, finishTurn, completeItem = true) => { + const item = toolItem; + process.stdout.write(toolStartMessage(turnId)); + if (completeItem) { + process.stdout.write(JSON.stringify({ + method: "item/completed", + params: { completedAtMs: 2, item, threadId: "fresh-thread", turnId }, + }) + "\\n"); + } + if (finishTurn) { + process.stdout.write(JSON.stringify({ + method: "turn/completed", + params: { + threadId: "fresh-thread", + turn: { + completedAt: Date.now(), + durationMs: 1, + error: null, + id: turnId, + items: [item], + itemsView: "full", + startedAt: null, + status: "completed", + }, + }, + }) + "\\n"); + } +}; +const duplicateRequestPhase = ${JSON.stringify(cancellationOptions.duplicateRequestPhase ?? null)}; +const sendDuplicateRequest = (turnId) => { + const duplicate = JSON.stringify({ + id: "x".repeat(1_100_000), + method: "item/commandExecution/requestApproval", + params: { + environmentId: null, + itemId: "item-duplicate", + startedAtMs: 1, + threadId: "fresh-thread", + turnId, + }, + }) + "\\n"; + process.stdout.write(duplicate); + process.stdout.write(duplicate); +}; +const initializeResult = ${initializeResultJson}; +const thread = { + id: ${JSON.stringify(cancellationOptions.threadId ?? "fresh-thread")}, + extra: null, + sessionId: "fresh-thread", + forkedFromId: null, + parentThreadId: null, + preview: "", + projectId: null, + ephemeral: false, + section: null, + sectionEnteredAt: null, + historyMode: "paginated", + modelProvider: "openai", + createdAt: 0, + updatedAt: 0, + recencyAt: null, + status: { type: "idle" }, + path: null, + cwd: ${JSON.stringify(directory)}, + cliVersion: "0.152.0", + source: "appServer", + canAcceptDirectInput: true, + threadSource: null, + agentNickname: null, + agentRole: null, + gitInfo: null, + name: null, + turns: [], +}; +const threadStartResult = { + thread, + model: "test-model", + modelProvider: "openai", + serviceTier: null, + cwd: ${JSON.stringify(directory)}, + runtimeWorkspaceRoots: [${JSON.stringify(directory)}], + instructionSources: [], + approvalPolicy: "never", + approvalsReviewer: "user", + sandbox: { type: "dangerFullAccess" }, + activePermissionProfile: null, + reasoningEffort: null, + multiAgentMode: "explicitRequestOnly", +}; process.stdin.setEncoding("utf8"); process.stdin.on("data", (chunk) => { buffer += chunk; @@ -105,25 +276,46 @@ process.stdin.on("data", (chunk) => { buffer = buffer.slice(newline + 1); appendFileSync(${JSON.stringify(requestLog)}, JSON.stringify(request) + "\\n"); const turnNumber = request.method === "turn/start" ? ++turnStartCount : 0; - const turnId = launchNumber === 1 + const configuredTurnId = ${JSON.stringify(cancellationOptions.turnId ?? null)}; + const turnId = configuredTurnId ?? (launchNumber === 1 ? "turn-" + turnNumber - : "turn-" + launchNumber + "-" + turnNumber; + : "turn-" + launchNumber + "-" + turnNumber); + const configuredToolStartFollowup = ${JSON.stringify(cancellationOptions.toolStartFollowup ?? null)}; + const toolStartFollowup = + request.method === "turn/start" && turnNumber === 1 ? configuredToolStartFollowup : null; + const configuredTurnStartErrorMessage = ${JSON.stringify(cancellationOptions.turnStartErrorMessage ?? null)}; + const turnStartErrorMessage = + configuredToolStartFollowup === "error_then_success" && turnNumber > 1 + ? null + : configuredTurnStartErrorMessage; const configuredResumeError = ${JSON.stringify(resumeErrorMessage)}; const restartResumeError = ${JSON.stringify(cancellationOptions.restartResumeError ?? null)}; const resumeError = launchNumber > 1 && restartResumeError !== null ? restartResumeError - : configuredResumeError.startsWith("no rollout found for thread id ") + : request.method === "thread/resume" && + configuredResumeError.startsWith("no rollout found for thread id ") ? "no rollout found for thread id " + request.params.threadId : configuredResumeError; - const terminalTurn = launchNumber > 1 || turnNumber > 1; + const terminalTurn = + ["terminal_response", "terminal_then_malformed"].includes( + toolStartFollowup, + ) || + (request.method === "turn/start" && + turnNumber === 1 && + ${JSON.stringify(cancellationOptions.terminalNotificationBeforeTurnStartResponse ?? false)}) || + ((launchNumber > 1 || turnNumber > 1) && + !${JSON.stringify(cancellationOptions.emitToolCompletionOnTurnStart ?? false)}); const holdTurnStart = - ${JSON.stringify(holdFirstTurnStartResponse)} && + (${JSON.stringify(holdFirstTurnStartResponse)} || + ${JSON.stringify(cancellationOptions.holdTurnStartAfterItems ?? false)}) && launchNumber === 1 && request.method === "turn/start" && turnNumber === 1; const response = - request.method === "thread/backgroundTerminals/clean" && + request.method === "initialized" + ? null + : request.method === "thread/backgroundTerminals/clean" && ${JSON.stringify(cancellationOptions.backgroundTerminalCleanFailure ?? null)} === "error" ? { id: request.id, error: { code: -32600, message: "background clean failed" } } : request.method === "thread/start" && @@ -133,7 +325,9 @@ process.stdin.on("data", (chunk) => { : request.method === "thread/resume" ? { id: request.id, error: { code: -32600, message: resumeError } } : request.method === "thread/start" - ? { id: request.id, result: { thread: { id: "fresh-thread" } } } + ? { id: request.id, result: threadStartResult } + : request.method === "turn/start" && turnStartErrorMessage !== null + ? { id: request.id, error: { code: -32000, message: turnStartErrorMessage } } : request.method === "turn/start" ? { id: request.id, result: { turn: { completedAt: terminalTurn ? Date.now() : null, @@ -145,18 +339,60 @@ process.stdin.on("data", (chunk) => { startedAt: null, status: terminalTurn ? "completed" : "inProgress", } } } - : { id: request.id, result: {} }; + : request.method === "initialize" + ? { id: request.id, result: initializeResult } + : { id: request.id, result: {} }; const sendApproval = () => process.stdout.write(JSON.stringify({ id: 91, method: "item/commandExecution/requestApproval", - params: { itemId: "item-1", threadId: "fresh-thread", turnId: "turn-1" }, + params: { + environmentId: null, + itemId: "item-1", + startedAtMs: 1, + threadId: "fresh-thread", + turnId: "turn-1", + }, }) + "\\n"); const sendResponse = () => { if ( request.method === "thread/backgroundTerminals/clean" && ${JSON.stringify(cancellationOptions.backgroundTerminalCleanFailure ?? null)} === "timeout" ) return; - process.stdout.write(JSON.stringify(response) + "\\n"); + if (request.method === "turn/start" && duplicateRequestPhase === "turn_start") { + sendDuplicateRequest(turnId); + return; + } + if ( + request.method === "turn/start" && + turnNumber === 1 && + ${JSON.stringify(cancellationOptions.terminalNotificationBeforeTurnStartResponse ?? false)} + ) { + sendTurnStarted(turnId); + sendToolLifecycle(turnId, true); + } + if (request.method === "turn/start" && response !== null && toolStartFollowup === "malformed") { + process.stdout.write(JSON.stringify(response) + "\\n" + toolStartMessage(turnId) + "not-json\\n"); + } else if ( + request.method === "turn/start" && + response !== null && + (toolStartFollowup === "error_then_malformed" || + toolStartFollowup === "terminal_then_malformed") + ) { + process.stdout.write(toolStartMessage(turnId) + JSON.stringify(response) + "\\nnot-json\\n"); + } else if ( + request.method === "turn/start" && + response !== null && + (toolStartFollowup === "error_response" || + toolStartFollowup === "error_then_success" || + toolStartFollowup === "terminal_response") + ) { + process.stdout.write(toolStartMessage(turnId) + JSON.stringify(response) + "\\n"); + } else if (response !== null) { + process.stdout.write(JSON.stringify(response) + "\\n"); + } + if (request.method === "turn/start" && duplicateRequestPhase === "active_turn") { + setTimeout(() => sendDuplicateRequest(turnId), 5); + } if (${JSON.stringify(emitApproval)} && request.method === "turn/start" && !holdTurnStart) { sendApproval(); } @@ -166,10 +402,31 @@ process.stdin.on("data", (chunk) => { ) { setTimeout(() => sendInterrupted(turnId), 5); } + if ( + request.method === "turn/start" && + ${JSON.stringify(cancellationOptions.emitToolCompletionOnTurnStart ?? false)} + ) { + setTimeout( + () => + sendToolLifecycle( + turnId, + launchNumber > 1 || + turnNumber > 1 || + !${JSON.stringify(cancellationOptions.holdTurnStartAfterItems ?? false)}, + ), + 5, + ); + } }; if (holdTurnStart) { if (${JSON.stringify(emitApproval)}) sendApproval(); writeFileSync(${JSON.stringify(turnStartHeldMarker)}, ""); + if ( + request.method === "turn/start" && + ${JSON.stringify(cancellationOptions.emitToolCompletionOnTurnStart ?? false)} + ) { + setTimeout(() => sendToolLifecycle(turnId, false, false), 5); + } const gate = setInterval(() => { if (existsSync(${JSON.stringify(turnStartReleaseMarker)})) { clearInterval(gate); @@ -191,6 +448,13 @@ process.stdin.on("data", (chunk) => { ...driverBootPayload, execution: { ...driverBootPayload.execution, + environment: { + ...driverBootPayload.execution.environment, + variables: { + ...driverBootPayload.execution.environment.variables, + ...cancellationOptions.environmentVariables, + }, + }, permissionPolicy, session: { ...driverBootPayload.execution.session, @@ -210,25 +474,44 @@ process.stdin.on("data", (chunk) => { }, }); const events: DriverEventInput[] = []; + const cmaEventTypes: string[] = []; + const cmaStore = + cancellationOptions.duplicateRequestPhase === undefined && cancellationOptions.useCma !== true + ? null + : createCmaMemoryStore({ sessions: [{ id: DRIVER_TEST_IDS.sessionId }] }); + let activeRunId = null as (typeof DRIVER_TEST_IDS)["runId"] | null; let cancellationRequestFailures = 0; const cancellationRequestEntered = Promise.withResolvers(); const cancellationRequestGate = Promise.withResolvers(); + const messageStartEntered = Promise.withResolvers(); + const messageStartGate = Promise.withResolvers(); const runCancellationEntered = Promise.withResolvers(); const runCancellationGate = Promise.withResolvers(); + const toolCompletionEntered = Promise.withResolvers(); + const toolCompletionGate = Promise.withResolvers(); + let toolCompletionHolds = 0; const turnTimingEntered = Promise.withResolvers(); const turnTimingGate = Promise.withResolvers(); - const logger = createBufferedSinkLogger({ - level: "debug", - service: "openai-app-server-startup-test", - sink: async () => {}, - }); + const logger = createDisabledLogger(); const context = createAgentDriverContext({ eventSink: { commandUpdate: async () => {}, + currentRunId: () => activeRunId, pushEvents: async (input) => { + if ( + cancellationOptions.holdMessageStart === true && + input.events.some((event) => event.kind === "message.started") + ) { + messageStartEntered.resolve(); + await messageStartGate.promise; + } if ( holdTurnTiming && - input.events.some((event) => event.sourceEventId === "openai.provider.turn_start:turn-1") + input.events.some( + (event) => + event.kind === "runtime.timing.recorded" && + event.native?.eventName === "provider.turn_start", + ) ) { turnTimingEntered.resolve(); await turnTimingGate.promise; @@ -252,9 +535,44 @@ process.stdin.on("data", (chunk) => { runCancellationEntered.resolve(); await runCancellationGate.promise; } + if ( + cancellationOptions.holdToolCompletion === true && + input.events.some( + (event) => + event.kind === "tool.call.updated" && eventPayloadStatus(event) === "completed", + ) && + toolCompletionHolds++ === 0 + ) { + toolCompletionEntered.resolve(); + await toolCompletionGate.promise; + } + if (cmaStore !== null) { + for (const event of input.events) { + for (const envelope of toDriverEventEnvelopes(driverBootPayload, event, activeRunId)) { + const records = await cmaStore.appendDriverEvent( + DRIVER_TEST_IDS.sessionId, + envelope.event, + ); + cmaEventTypes.push(...records.map((record) => record.event.type)); + } + } + } events.push(...input.events); + for (const event of input.events) { + if (event.kind === "run.started") { + activeRunId = (event.runId as (typeof DRIVER_TEST_IDS)["runId"] | undefined) ?? null; + } + if ( + event.kind === "run.cancelled" || + event.kind === "run.completed" || + event.kind === "run.failed" + ) { + activeRunId = null; + } + } return { accepted: input.events.map((event, index) => ({ + eventId: event.sourceEventId!, seq: index + 1, type: event.kind, })), @@ -266,23 +584,45 @@ process.stdin.on("data", (chunk) => { permission: { request: requestPermission }, ports: { skill: { materialize: async () => [] } }, }); + const backend = new OpenAiAppServerDriverBackend(payload); + const trackedBackend: AgentDriverBackend = { + runtime: backend.runtime, + cancelActiveTurn: (backendContext, reason) => backend.cancelActiveTurn(backendContext, reason), + handleInput: async (backendContext, input, runId, signal) => { + activeRunId = runId; + try { + await backend.handleInput(backendContext, input, runId, signal); + } finally { + if (activeRunId === runId) { + activeRunId = null; + } + } + }, + start: (backendContext, signal) => backend.start(backendContext, signal), + stop: (backendContext, reason, signal) => backend.stop(backendContext, reason, signal), + }; return { - backend: new OpenAiAppServerDriverBackend(payload), + backend: trackedBackend, cancellationRequestEntered: cancellationRequestEntered.promise, + cmaEventTypes, context, events, logger, + messageStartEntered: messageStartEntered.promise, payload, processLog, releaseCancellationRequest: () => cancellationRequestGate.resolve(), + releaseMessageStart: () => messageStartGate.resolve(), releaseRunCancellation: () => runCancellationGate.resolve(), + releaseToolCompletion: () => toolCompletionGate.resolve(), releaseTurnTiming: () => turnTimingGate.resolve(), releaseTurnStartResponse: () => Bun.write(turnStartReleaseMarker, ""), requestLog, runCancellationEntered: runCancellationEntered.promise, turnTimingEntered: turnTimingEntered.promise, turnStartHeldMarker, + toolCompletionEntered: toolCompletionEntered.promise, }; } @@ -291,7 +631,18 @@ function createCancellationHarness(options: CancellationHarnessOptions) { "no rollout found for thread id stale-thread", [], false, - async () => "allow_once", + async (_input, signal) => { + if (options.duplicateRequestPhase !== undefined) { + await new Promise((resolve) => { + if (signal?.aborted) { + resolve(); + return; + } + signal?.addEventListener("abort", () => resolve(), { once: true }); + }); + } + return "allow_once"; + }, false, "full_access", false, @@ -300,6 +651,39 @@ function createCancellationHarness(options: CancellationHarnessOptions) { } describe("OpenAI app-server startup", () => { + test("keeps transient auth through fake app-server native-resume startup", async () => { + const harness = await createHarness( + "no rollout found for thread id stale-thread", + [], + false, + async () => "allow_once", + false, + "full_access", + false, + { environmentVariables: { OPENAI_API_KEY: "resume-startup-key" } }, + ); + try { + await harness.backend.start(harness.context, new AbortController().signal); + const firstLaunch = JSON.parse( + (await readFile(harness.processLog, "utf8")).trim().split("\n")[0] ?? "{}", + ) as { codexHome: string; sqliteHome: string }; + const authJsonPath = join(firstLaunch.codexHome, "auth.json"); + + expect(firstLaunch.sqliteHome).toBe(harness.payload.execution.session.homePath); + expect(JSON.parse(await readFile(authJsonPath, "utf8"))).toMatchObject({ + OPENAI_API_KEY: "resume-startup-key", + }); + expect(await readFile(harness.requestLog, "utf8")).toContain('"method":"thread/resume"'); + await harness.backend.stop(harness.context, "test complete", new AbortController().signal); + await expect(lstat(firstLaunch.codexHome)).rejects.toThrow(); + expect((await lstat(join(firstLaunch.sqliteHome, "sessions"))).isDirectory()).toBe(true); + } finally { + await harness.backend + .stop(harness.context, "test complete", new AbortController().signal) + .catch(() => {}); + } + }); + test("maps supervised permissions to untrusted thread and turn policies", async () => { const harness = await createHarness( "no rollout found for thread id stale-thread", @@ -328,7 +712,15 @@ describe("OpenAI app-server startup", () => { (line) => JSON.parse(line) as { method: string; - params?: { approvalPolicy?: string }; + params?: { + approvalPolicy?: string; + cwd?: string; + excludeTurns?: boolean; + historyMode?: string; + input?: unknown; + model?: string; + threadId?: string; + }; }, ); expect( @@ -338,6 +730,25 @@ describe("OpenAI app-server startup", () => { ) .map((request) => request.params?.approvalPolicy), ).toEqual(["untrusted", "untrusted", "untrusted"]); + expect(requests.find((request) => request.method === "thread/resume")?.params).toMatchObject({ + excludeTurns: true, + }); + expect( + requests.find((request) => request.method === "thread/resume")?.params, + ).not.toHaveProperty("historyMode"); + expect(requests.find((request) => request.method === "thread/start")?.params).toMatchObject({ + historyMode: "paginated", + }); + expect( + requests.find((request) => request.method === "thread/start")?.params, + ).not.toHaveProperty("excludeTurns"); + expect(requests.find((request) => request.method === "turn/start")?.params).toEqual({ + approvalPolicy: "untrusted", + cwd: harness.payload.execution.session.cwd, + input: [{ text: "hello", text_elements: [], type: "text" }], + model: harness.payload.execution.model, + threadId: "fresh-thread", + }); const stop = harness.backend.stop( harness.context, @@ -353,12 +764,11 @@ describe("OpenAI app-server startup", () => { if (!stopped) { await harness.backend.stop(harness.context, "test complete", new AbortController().signal); } - await harness.logger.destroy(); } }); test("injects platform transcript without publishing an unmaterialized thread", async () => { - const { backend, context, events, logger, requestLog } = await createHarness( + const { backend, context, events, requestLog } = await createHarness( "no rollout found for thread id stale-thread", ); @@ -385,11 +795,10 @@ describe("OpenAI app-server startup", () => { threadId: "fresh-thread", }); await backend.stop(context, "test complete", new AbortController().signal); - await logger.destroy(); }); test("stop cleans background terminals even when the thread has no active turn", async () => { - const { backend, context, logger, requestLog } = await createCancellationHarness({}); + const { backend, context, requestLog } = await createCancellationHarness({}); try { await backend.start(context, new AbortController().signal); @@ -402,7 +811,6 @@ describe("OpenAI app-server startup", () => { expect(methods).toContain("thread/backgroundTerminals/clean"); } finally { await backend.stop(context, "test complete", new AbortController().signal).catch(() => {}); - await logger.destroy(); } }); @@ -440,7 +848,6 @@ describe("OpenAI app-server startup", () => { await harness.backend .stop(harness.context, "test complete", new AbortController().signal) .catch(() => {}); - await harness.logger.destroy(); } }, ); @@ -505,12 +912,11 @@ describe("OpenAI app-server startup", () => { ).resolves.toBeUndefined(); } finally { await harness.backend.stop(harness.context, "test complete", new AbortController().signal); - await harness.logger.destroy(); } }); test("does not replace the thread for other resume failures", async () => { - const { backend, context, events, logger } = await createHarness("Unauthorized"); + const { backend, context, events } = await createHarness("Unauthorized"); try { await expect(backend.start(context, new AbortController().signal)).rejects.toThrow( @@ -519,7 +925,6 @@ describe("OpenAI app-server startup", () => { expect(events).toEqual([]); } finally { await backend.stop(context, "test complete", new AbortController().signal); - await logger.destroy(); } }); @@ -536,10 +941,60 @@ describe("OpenAI app-server startup", () => { await harness.backend .stop(harness.context, "test complete", new AbortController().signal) .catch(() => {}); - await harness.logger.destroy(); } }); + test("fails startup before retaining an oversized native resume pointer", async () => { + const harness = await createCancellationHarness({ threadId: "t".repeat(257) }); + + try { + await expect( + harness.backend.start(harness.context, new AbortController().signal), + ).rejects.toThrow("1-256 UTF-8 bytes"); + expect(harness.events).toEqual([]); + const childPid = await readFirstLaunchPid(harness.processLog); + expect(() => process.kill(childPid, 0)).toThrow(); + } finally { + await harness.backend + .stop(harness.context, "test complete", new AbortController().signal) + .catch(() => {}); + } + }); + + test("maps an oversized official turn identity before timing and CMA delivery", async () => { + const nativeTurnId = `turn-${"x".repeat(1_100_000)}`; + const harness = await createCancellationHarness({ + emitToolCompletionOnTurnStart: true, + turnId: nativeTurnId, + useCma: true, + }); + + try { + await harness.backend.start(harness.context, new AbortController().signal); + await harness.backend.handleInput(harness.context, { text: "hello" }, DRIVER_TEST_IDS.runId); + + const timing = harness.events.find( + (event) => + event.kind === "runtime.timing.recorded" && + event.native?.eventName === "provider.turn_start", + ); + const started = harness.events.find((event) => event.kind === "run.started"); + const publicTurnId = timing?.native?.turnId; + expect(publicTurnId).not.toBe(nativeTurnId); + expect(Buffer.byteLength(publicTurnId ?? "", "utf8")).toBeLessThanOrEqual(256); + expect(started?.native?.turnId).toBe(publicTurnId); + expect(isDriverId(timing?.sourceEventId)).toBe(true); + expect( + harness.events.every((event) => Buffer.byteLength(JSON.stringify(event)) < 1_048_576), + ).toBe(true); + expect(harness.cmaEventTypes).toContain("session.status_idle"); + } finally { + await harness.backend + .stop(harness.context, "test complete", new AbortController().signal) + .catch(() => {}); + } + }, 10_000); + test.each(["cancel", "stop"] as const)( "%s closes a turn whose start response is waiting on event delivery", async (operation) => { @@ -585,7 +1040,6 @@ describe("OpenAI app-server startup", () => { new AbortController().signal, ); } - await harness.logger.destroy(); } }, ); @@ -649,7 +1103,6 @@ describe("OpenAI app-server startup", () => { error: { message: reason }, status: "failed", }); - await Bun.sleep(25); expect( harness.events @@ -685,11 +1138,203 @@ describe("OpenAI app-server startup", () => { } finally { await harness.releaseTurnStartResponse(); await harness.backend.stop(harness.context, "test complete", new AbortController().signal); - await harness.logger.destroy(); } }, ); + test("does not project items before their pending turn response identifies the run", async () => { + const harness = await createCancellationHarness({ + emitToolCompletionOnTurnStart: true, + holdTurnStartAfterItems: true, + }); + + try { + await harness.backend.start(harness.context, new AbortController().signal); + const input = harness.backend.handleInput( + harness.context, + { text: "first" }, + DRIVER_TEST_IDS.runId, + ); + void input.catch(() => {}); + await expect( + settlePromiseWithTimeout( + (async () => { + while (!(await Bun.file(harness.turnStartHeldMarker).exists())) { + await Bun.sleep(1); + } + })(), + { label: "held turn start", timeoutMs: 250 }, + ), + ).resolves.toMatchObject({ status: "completed" }); + await Bun.sleep(10); + expect( + harness.events.filter((event) => + ["item.started", "message.started", "run.started", "tool.call.updated"].includes( + event.kind, + ), + ), + ).toEqual([]); + + await harness.backend.cancelActiveTurn(harness.context, "test.cancel"); + await expect(input).rejects.toThrow("test.cancel"); + expect( + harness.events + .filter((event) => event.runId === DRIVER_TEST_IDS.runId) + .map((event) => [event.kind, eventPayloadStatus(event)]), + ).toEqual([ + ["agent.tasks.replaced", undefined], + ["run.started", undefined], + ["run.cancel.requested", undefined], + ["run.cancelled", undefined], + ]); + } finally { + await harness.releaseTurnStartResponse(); + await harness.backend + .stop(harness.context, "test complete", new AbortController().signal) + .catch(() => {}); + } + }); + + test.each([ + ["terminal response", "terminal_response", "completed", "run.completed"], + [ + "terminal response followed by a malformed frame", + "terminal_then_malformed", + "completed", + "run.completed", + ], + ["error response", "error_response", "failed", "run.failed"], + [ + "error response followed by a malformed frame", + "error_then_malformed", + "failed", + "run.failed", + ], + ["malformed frame", "malformed", "failed", "run.failed"], + ] as const)( + "closes a durably started tool before a same-burst %s", + async (_label, toolStartFollowup, closureStatus, terminalKind) => { + const responseIdentifiesTurn = + toolStartFollowup === "terminal_response" || + toolStartFollowup === "terminal_then_malformed"; + const harness = await createCancellationHarness({ + holdMessageStart: responseIdentifiesTurn || toolStartFollowup === "malformed", + ...(toolStartFollowup === "error_response" || toolStartFollowup === "error_then_malformed" + ? { turnStartErrorMessage: "turn start rejected" } + : {}), + toolStartFollowup, + }); + + try { + await harness.backend.start(harness.context, new AbortController().signal); + const input = harness.backend.handleInput( + harness.context, + { text: "hello" }, + DRIVER_TEST_IDS.runId, + ); + void input.catch(() => {}); + if (responseIdentifiesTurn || toolStartFollowup === "malformed") { + await harness.messageStartEntered; + expect( + harness.events.some((event) => + ["run.cancelled", "run.completed", "run.failed"].includes(event.kind), + ), + ).toBe(false); + harness.releaseMessageStart(); + } + if ( + toolStartFollowup !== "terminal_response" && + toolStartFollowup !== "terminal_then_malformed" + ) { + await expect(input).rejects.toThrow( + toolStartFollowup === "malformed" ? "stdout is not valid JSON" : "turn start rejected", + ); + } else { + await expect(input).resolves.toBeUndefined(); + } + + const projectedLifecycle = harness.events + .filter((event) => + [ + "item.completed", + "item.started", + "message.completed", + "message.failed", + "message.started", + "run.completed", + "run.failed", + "tool.call.updated", + ].includes(event.kind), + ) + .map((event) => [event.kind, eventPayloadStatus(event)]); + if (!responseIdentifiesTurn && toolStartFollowup !== "malformed") { + expect(projectedLifecycle).toEqual([["run.failed", undefined]]); + return; + } + expect(projectedLifecycle).toEqual([ + ["message.started", undefined], + ["item.started", undefined], + ["tool.call.updated", "running"], + [ + toolStartFollowup === "terminal_response" || + toolStartFollowup === "terminal_then_malformed" + ? "message.completed" + : "message.failed", + undefined, + ], + ["tool.call.updated", closureStatus], + ["item.completed", closureStatus], + [terminalKind, undefined], + ]); + } finally { + harness.releaseMessageStart(); + await harness.backend + .stop(harness.context, "test complete", new AbortController().signal) + .catch(() => {}); + } + }, + 10_000, + ); + + test("does not project an unadmitted failed-turn item into the next turn", async () => { + const harness = await createCancellationHarness({ + toolStartFollowup: "error_then_success", + turnStartErrorMessage: "turn start rejected", + }); + + try { + await harness.backend.start(harness.context, new AbortController().signal); + await expect( + harness.backend.handleInput(harness.context, { text: "first" }, DRIVER_TEST_IDS.runId), + ).rejects.toThrow("turn start rejected"); + await expect( + harness.backend.handleInput( + harness.context, + { text: "second" }, + DRIVER_TEST_IDS.secondRunId, + ), + ).resolves.toBeUndefined(); + + expect( + harness.events.filter((event) => event.kind === "item.completed").map(eventPayloadStatus), + ).toEqual([]); + expect( + harness.events + .filter((event) => event.kind === "tool.call.updated") + .map(eventPayloadStatus), + ).toEqual([]); + expect( + harness.events + .filter((event) => ["run.completed", "run.failed"].includes(event.kind)) + .map((event) => event.kind), + ).toEqual(["run.failed", "run.completed"]); + } finally { + await harness.backend + .stop(harness.context, "test complete", new AbortController().signal) + .catch(() => {}); + } + }); + test("dispatcher cancellation closes a pending turn start and recovers on a new client", async () => { const harness = await createHarness( "no rollout found for thread id stale-thread", @@ -849,7 +1494,87 @@ describe("OpenAI app-server startup", () => { } finally { await harness.releaseTurnStartResponse(); await kernel.stop("test complete").catch(() => {}); - await harness.logger.destroy(); + } + }); + + test("dispatcher cancellation replaces an unselected completed terminal", async () => { + const harness = await createCancellationHarness({ + terminalNotificationBeforeTurnStartResponse: true, + }); + const kernel = new AgentDriverKernelCore({ + backendFactory: (payload) => new OpenAiAppServerDriverBackend(payload), + hostPorts: { skill: { materialize: async () => [] } }, + logger: harness.logger, + }); + const terminalEntered = Promise.withResolvers(); + const releaseTerminal = Promise.withResolvers(); + const cancellationClaimed = + Promise.withResolvers>(); + const registerRunTerminalBarrier = kernel.registerRunTerminalBarrier.bind(kernel); + kernel.registerRunTerminalBarrier = (barrier) => + registerRunTerminalBarrier((events) => { + const pending = barrier(events); + if (!events.some(({ kind }) => kind === "run.completed")) { + return pending; + } + return Promise.resolve(pending).then(async () => { + terminalEntered.resolve(); + await releaseTerminal.promise; + }); + }); + const claimRunCancellation = kernel.claimRunCancellation.bind(kernel); + kernel.claimRunCancellation = (ticket, reason) => { + const result = claimRunCancellation(ticket, reason); + cancellationClaimed.resolve(result); + return result; + }; + const events: DriverEventInput[] = []; + const cancelled = (async () => { + for await (const event of kernel.events()) { + events.push(event); + if (event.kind === "run.cancelled") { + return; + } + } + })(); + + try { + await kernel.start(harness.payload); + const input = kernel.dispatch({ + commandId: "completed-terminal-race-input", + input: { text: "finish" }, + kind: "input.start", + requestId: "completed-terminal-race-request", + runId: DRIVER_TEST_IDS.runId, + }); + await terminalEntered.promise; + const cancellation = kernel.cancel("test.cancel"); + expect(await cancellationClaimed.promise).toBe("claimed"); + releaseTerminal.resolve(); + await Promise.all([input, cancellation, cancelled]); + + expect( + events + .filter(({ kind }) => ["run.cancelled", "run.completed", "run.failed"].includes(kind)) + .map(({ kind }) => kind), + ).toEqual(["run.cancelled"]); + expect(events.filter(({ kind }) => kind === "run.cancel.requested")).toHaveLength(1); + expect( + events + .filter( + ({ kind, payload }) => + kind === "tool.call.updated" && + typeof payload === "object" && + payload !== null && + "status" in payload && + (payload.status === "completed" || payload.status === "cancelled"), + ) + .map(({ payload }) => (payload as { status: string }).status), + ).toEqual(["completed"]); + expect(events.filter(({ kind }) => kind === "agent.tasks.replaced")).toHaveLength(1); + } finally { + releaseTerminal.resolve(); + await kernel.stop("test complete").catch(() => {}); } }); @@ -879,7 +1604,6 @@ describe("OpenAI app-server startup", () => { } finally { harness.releaseRunCancellation(); await harness.backend.stop(harness.context, "test complete", new AbortController().signal); - await harness.logger.destroy(); } }); @@ -913,13 +1637,106 @@ describe("OpenAI app-server startup", () => { harness.events .filter((event) => event.runId === DRIVER_TEST_IDS.runId) .map((event) => event.kind), - ).toEqual(["run.started", "run.failed"]); + ).toEqual(["agent.tasks.replaced", "run.started", "run.failed"]); } finally { await harness.releaseTurnStartResponse(); await harness.backend .stop(harness.context, "test complete", new AbortController().signal) .catch(() => {}); - await harness.logger.destroy(); + } + }, 10_000); + + test.each(["active_turn", "turn_start"] as const)( + "bounds a duplicate-request protocol failure during %s before terminal CMA delivery", + async (duplicateRequestPhase) => { + const harness = await createCancellationHarness({ duplicateRequestPhase }); + const lifecycleFail = spyOn(harness.context.lifecycle, "fail").mockImplementation(() => {}); + + try { + await harness.backend.start(harness.context, new AbortController().signal); + const input = harness.backend.handleInput( + harness.context, + { text: "hello" }, + DRIVER_TEST_IDS.runId, + ); + + await expect(input).rejects.toThrow( + "OpenAI provider error message was omitted because it contained 1100046 UTF-8 bytes.", + ); + const terminal = harness.events.find((event) => event.kind === "run.failed"); + expect(terminal).toMatchObject({ + payload: { + error: { + code: "openai.provider_failed", + details: { messageUtf8Bytes: 1_100_046 }, + message: + "OpenAI provider error message was omitted because it contained 1100046 UTF-8 bytes.", + retryable: false, + }, + recoverable: false, + }, + }); + expect(Buffer.byteLength(JSON.stringify(terminal), "utf8")).toBeLessThan(1_048_576); + expect(harness.cmaEventTypes).toContain("session.error"); + expect(lifecycleFail).not.toHaveBeenCalled(); + } finally { + lifecycleFail.mockRestore(); + await harness.backend + .stop(harness.context, "test complete", new AbortController().signal) + .catch(() => {}); + } + }, + 10_000, + ); + + test("bounds an official turn start error before terminal CMA and dispatcher delivery", async () => { + const message = "x".repeat(1_100_000); + const harness = await createCancellationHarness({ + turnStartErrorMessage: message, + useCma: true, + }); + + try { + await harness.backend.start(harness.context, new AbortController().signal); + let failure: Error | null = null; + + try { + await harness.backend.handleInput( + harness.context, + { text: "hello" }, + DRIVER_TEST_IDS.runId, + ); + } catch (error) { + failure = error instanceof Error ? error : new Error("turn start failed"); + } + + expect(failure?.message).toBe( + "OpenAI provider error message was omitted because it contained 1100000 UTF-8 bytes.", + ); + expect( + harness.events + .filter((event) => event.runId === DRIVER_TEST_IDS.runId) + .map((event) => event.kind), + ).toEqual(["agent.tasks.replaced", "run.started", "run.failed"]); + const terminal = harness.events.find((event) => event.kind === "run.failed"); + expect(terminal).toMatchObject({ + payload: { + error: { + code: "openai.provider_failed", + details: { messageUtf8Bytes: 1_100_000 }, + message: + "OpenAI provider error message was omitted because it contained 1100000 UTF-8 bytes.", + retryable: false, + }, + recoverable: false, + }, + }); + expect(Buffer.byteLength(JSON.stringify(terminal), "utf8")).toBeLessThan(1_048_576); + expect(harness.cmaEventTypes).toContain("session.error"); + } finally { + await harness.backend + .stop(harness.context, "test complete", new AbortController().signal) + .catch(() => {}); } }, 10_000); @@ -969,7 +1786,6 @@ describe("OpenAI app-server startup", () => { ).toEqual(["run.cancel.requested", "run.cancelled"]); } finally { await harness.backend.stop(harness.context, "test complete", new AbortController().signal); - await harness.logger.destroy(); } }); @@ -977,6 +1793,7 @@ describe("OpenAI app-server startup", () => { const harness = await createCancellationHarness({ holdCancellationRequest: true, }); + let stopped = false; try { await harness.backend.start(harness.context, new AbortController().signal); @@ -999,6 +1816,21 @@ describe("OpenAI app-server startup", () => { const cancellation = harness.backend.cancelActiveTurn(harness.context, "test.cancel"); void cancellation.catch(() => {}); + const stop = harness.backend.stop( + harness.context, + "test complete", + new AbortController().signal, + ); + void stop.catch(() => {}); + let stopSettled = false; + void stop.then( + () => { + stopSettled = true; + }, + () => { + stopSettled = true; + }, + ); await harness.cancellationRequestEntered; const firstPid = await readFirstLaunchPid(harness.processLog); expect(() => process.kill(firstPid, 0)).toThrow(); @@ -1009,8 +1841,13 @@ describe("OpenAI app-server startup", () => { }), ).resolves.toMatchObject({ status: "completed" }); + await Bun.sleep(10); + expect(stopSettled).toBe(false); + harness.releaseCancellationRequest(); await expect(input).rejects.toThrow("test.cancel"); + await stop; + stopped = true; await expect( settlePromiseWithTimeout( (async () => { @@ -1023,8 +1860,85 @@ describe("OpenAI app-server startup", () => { ).resolves.toMatchObject({ status: "completed" }); } finally { harness.releaseCancellationRequest(); - await harness.backend.stop(harness.context, "test complete", new AbortController().signal); - await harness.logger.destroy(); + if (!stopped) { + await harness.backend + .stop(harness.context, "test complete", new AbortController().signal) + .catch(() => {}); + } + } + }); + + test("drains a pending item completion before cancellation and keeps the next turn clean", async () => { + const harness = await createCancellationHarness({ + emitToolCompletionOnTurnStart: true, + holdToolCompletion: true, + }); + + try { + await harness.backend.start(harness.context, new AbortController().signal); + const firstInput = harness.backend.handleInput( + harness.context, + { text: "first" }, + DRIVER_TEST_IDS.runId, + ); + void firstInput.catch(() => {}); + await harness.toolCompletionEntered; + + const cancellation = harness.backend.cancelActiveTurn(harness.context, "test.cancel"); + void cancellation.catch(() => {}); + let cancellationSettled = false; + void cancellation.finally(() => { + cancellationSettled = true; + }); + await Bun.sleep(10); + expect(cancellationSettled).toBe(false); + + await expect( + settlePromiseWithTimeout(cancellation, { + label: "cancellation after pending item delivery", + timeoutMs: ACTIVE_TURN_CANCEL_GRACE_MS, + }), + ).resolves.toMatchObject({ status: "completed" }); + expect( + harness.events.some( + (event) => event.runId === DRIVER_TEST_IDS.runId && event.kind === "run.cancelled", + ), + ).toBe(false); + + harness.releaseToolCompletion(); + await expect(firstInput).rejects.toThrow("test.cancel"); + await expect( + settlePromiseWithTimeout( + harness.backend.handleInput( + harness.context, + { text: "second" }, + DRIVER_TEST_IDS.secondRunId, + ), + { + label: "turn after pending item cancellation", + timeoutMs: ACTIVE_TURN_CANCEL_GRACE_MS, + }, + ), + ).resolves.toMatchObject({ status: "completed" }); + + const secondStarted = harness.events.filter( + (event) => event.kind === "item.started" && event.runId === DRIVER_TEST_IDS.secondRunId, + ); + expect(secondStarted).toHaveLength(1); + expect( + harness.events + .filter( + (event) => + event.runId === DRIVER_TEST_IDS.secondRunId && + ["run.cancelled", "run.completed", "run.failed"].includes(event.kind), + ) + .map((event) => event.kind), + ).toEqual(["run.completed"]); + } finally { + harness.releaseToolCompletion(); + await harness.backend + .stop(harness.context, "test complete", new AbortController().signal) + .catch(() => {}); } }); @@ -1089,10 +2003,119 @@ describe("OpenAI app-server startup", () => { } catch {} } stopSpy.mockRestore(); - await harness.logger.destroy(); } }, 10_000); + test.each(["client cleanup", "terminal delivery", "late terminal delivery"] as const)( + "classifies active cancellation %s failure as an input failure", + async (failureMode) => { + const harness = await createCancellationHarness({}); + const backend = new OpenAiAppServerDriverBackend(harness.payload); + const kernel = new AgentDriverKernelCore({ + backendFactory: () => backend, + hostPorts: { skill: { materialize: async () => [] } }, + logger: harness.logger, + }); + const nativeStop = OpenAiAppServerClient.prototype.stop; + let failNextStop = false; + const stopSpy = + failureMode === "client cleanup" + ? spyOn(OpenAiAppServerClient.prototype, "stop").mockImplementation(function ( + this: OpenAiAppServerClient, + signal?: AbortSignal, + ) { + if (failNextStop) { + failNextStop = false; + return Promise.reject(new Error("test cancellation cleanup failed")); + } + return nativeStop.call(this, signal); + }) + : null; + const terminalEntered = Promise.withResolvers(); + const terminalGate = Promise.withResolvers(); + const terminalSpy = + failureMode !== "client cleanup" + ? spyOn(DriverEventPublisher.prototype, "pushTerminal").mockImplementation(async () => { + terminalEntered.resolve(); + if (failureMode === "late terminal delivery") { + await terminalGate.promise; + } + throw new Error("test cancellation event delivery failed"); + }) + : null; + const events = kernel.events()[Symbol.asyncIterator](); + const terminalKinds: string[] = []; + + try { + await kernel.start(harness.payload); + const input = kernel.dispatch({ + commandId: "input-before-cleanup-failure", + input: { text: "hello" }, + kind: "input.start", + requestId: "request-before-cleanup-failure", + runId: DRIVER_TEST_IDS.runId, + }); + void input.catch(() => {}); + await expect( + settlePromiseWithTimeout( + (async () => { + for (;;) { + const event = (await events.next()).value; + if (event === undefined) { + throw new Error("Kernel event stream ended before run.started."); + } + if (["run.cancelled", "run.completed", "run.failed"].includes(event.kind)) { + terminalKinds.push(event.kind); + } + if (event.kind === "run.started") { + return; + } + } + })(), + { label: "OpenAI turn before cancellation cleanup failure", timeoutMs: 1_000 }, + ), + ).resolves.toMatchObject({ status: "completed" }); + + failNextStop = failureMode === "client cleanup"; + const cancellation = kernel.cancel("test.cancel"); + void cancellation.catch(() => {}); + if (failureMode === "late terminal delivery") { + await terminalEntered.promise; + await Bun.sleep(300); + terminalGate.resolve(); + } + await expect(input).rejects.toThrow("test cancellation"); + await expect(cancellation).rejects.toThrow("test cancellation"); + await expect( + settlePromiseWithTimeout( + (async () => { + for (;;) { + const event = (await events.next()).value; + if (event === undefined) { + throw new Error("Kernel event stream ended before run.failed."); + } + if (["run.cancelled", "run.completed", "run.failed"].includes(event.kind)) { + terminalKinds.push(event.kind); + } + if (event.kind === "run.failed") { + return; + } + } + })(), + { label: "OpenAI cancellation cleanup run failure", timeoutMs: 1_000 }, + ), + ).resolves.toMatchObject({ status: "completed" }); + expect(terminalKinds).toEqual(["run.failed"]); + } finally { + failNextStop = false; + await kernel.stop("test complete").catch(() => {}); + stopSpy?.mockRestore(); + terminalSpy?.mockRestore(); + } + }, + 10_000, + ); + test("retains a client whose process stop fails so shutdown can retry", async () => { const harness = await createCancellationHarness({}); let stopped = false; @@ -1119,7 +2142,6 @@ describe("OpenAI app-server startup", () => { .stop(harness.context, "test complete", new AbortController().signal) .catch(() => {}); } - await harness.logger.destroy(); } }); @@ -1204,7 +2226,6 @@ describe("OpenAI app-server startup", () => { } catch {} } stopSpy.mockRestore(); - await harness.logger.destroy(); } }, 10_000); @@ -1245,7 +2266,6 @@ describe("OpenAI app-server startup", () => { permissionGate.resolve(); await cancellation; await expect(input).rejects.toThrow("test.cancel"); - await Bun.sleep(25); const messages = (await readFile(harness.requestLog, "utf8")) .trim() @@ -1257,7 +2277,6 @@ describe("OpenAI app-server startup", () => { } finally { permissionGate.resolve(); await harness.backend.stop(harness.context, "test complete", new AbortController().signal); - await harness.logger.destroy(); } }); @@ -1311,7 +2330,6 @@ describe("OpenAI app-server startup", () => { await harness.backend .stop(harness.context, "test complete", new AbortController().signal) .catch(() => {}); - await harness.logger.destroy(); } }, 10_000); }); diff --git a/tests/openai-app-server-turn-start.test.ts b/tests/openai-app-server-turn-start.test.ts deleted file mode 100644 index 71f98ed..0000000 --- a/tests/openai-app-server-turn-start.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { createTurnParams } from "../src/runtimes/openai/app-server-driver-backend"; - -describe("OpenAI app-server turn start params", () => { - test("carries the resolved approval policy with every user turn", () => { - expect( - createTurnParams({ - approvalPolicy: "never", - cwd: "/workspace", - model: "gpt-5.4", - text: "Run pwd", - threadId: "thread-1", - }), - ).toEqual({ - approvalPolicy: "never", - cwd: "/workspace", - input: [ - { - text: "Run pwd", - text_elements: [], - type: "text", - }, - ], - model: "gpt-5.4", - threadId: "thread-1", - }); - }); -}); diff --git a/tests/openai-contract-adapter-interactions.test.ts b/tests/openai-contract-adapter-interactions.test.ts deleted file mode 100644 index ac4fe57..0000000 --- a/tests/openai-contract-adapter-interactions.test.ts +++ /dev/null @@ -1,662 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - AuthorityOutcomeUnknownError, - applyCommittedMutation, - interactionSchema, - validateSessionSnapshot, -} from "../src/contract"; -import type { - AuthorityOperation, - CommittedMutation, - InteractionResolution, - ProtocolAdmissionLimits, - Run, - SessionSnapshot, -} from "../src/contract"; -import { - OPENAI_APP_SERVER_MCP_ELICITATION_EXTENSION, - OpenAiContractAdapter, - type OpenAiAuthorityUpdate, -} from "../src/runtimes/openai/contract-adapter"; -import type { ContractPreviewUpdate } from "../src/runtimes/contract-projection"; - -const SESSION_ID = protocolId(1); -const RUN_ID = protocolId(2); -const COMMAND_ID = protocolId(3); -const THREAD_ID = "thread-1"; -const TURN_ID = "turn-1"; - -function protocolId(value: number): string { - return value.toString().padStart(26, "0"); -} - -function activeRun(startedAt: string): Run { - return { - id: RUN_ID, - input: [{ text: "hello", type: "text" }], - origin: "user", - startedAt, - status: "active", - }; -} - -function createInitialSnapshot(capturedAt: string): SessionSnapshot { - return validateSessionSnapshot({ - capturedAt, - interactions: [], - items: [], - protocolVersion: 2, - revision: 0, - runs: [activeRun(capturedAt)], - session: { - capabilities: { - [OPENAI_APP_SERVER_MCP_ELICITATION_EXTENSION]: {}, - "interaction.input": {}, - "interaction.permission": {}, - "interaction.tool": {}, - "item.change": {}, - "item.plan": {}, - "item.reasoning": {}, - "item.terminal": {}, - "openai.app-server/thread-item": {}, - }, - config: [], - createdAt: capturedAt, - id: SESSION_ID, - status: "open", - updatedAt: capturedAt, - }, - }); -} - -function createHarness( - options: { - admissionLimits?: ProtocolAdmissionLimits; - holdAuthority?: boolean; - interactionTimeoutMs?: number; - maxPendingServerRequestBytes?: number; - previewCheckpointBytes?: number; - previewReplaceIntervalMs?: number; - } = {}, -) { - let nowMs = Date.parse("2026-07-16T08:00:00.000Z"); - let snapshot = createInitialSnapshot(new Date(nowMs).toISOString()); - let nextId = 100; - let rejectNextAuthorityAfterCommit = false; - const authorityEntered = Promise.withResolvers(); - const authorityGate = Promise.withResolvers(); - const authority: OpenAiAuthorityUpdate[] = []; - const committedMutationIds = new Set(); - const previews: ContractPreviewUpdate[] = []; - const commit = ( - cause: CommittedMutation["cause"], - operations: AuthorityOperation[], - mutationId = protocolId(1_000 + snapshot.revision + 1), - ): void => { - if (committedMutationIds.has(mutationId)) { - return; - } - - const revision = snapshot.revision + 1; - const mutation: CommittedMutation = { - baseRevision: snapshot.revision, - cause, - committedAt: new Date(nowMs).toISOString(), - mutationId, - operations, - revision, - sessionId: SESSION_ID, - }; - snapshot = applyCommittedMutation(snapshot, mutation); - committedMutationIds.add(mutationId); - }; - const adapter = new OpenAiContractAdapter({ - admissionLimits: options.admissionLimits, - authority: async (update) => { - authority.push(update); - if (options.holdAuthority === true) { - authorityEntered.resolve(); - await authorityGate.promise; - } - commit(update.cause, [...update.operations] as AuthorityOperation[], update.mutationId); - if (rejectNextAuthorityAfterCommit) { - rejectNextAuthorityAfterCommit = false; - throw new AuthorityOutcomeUnknownError("authority result lost"); - } - }, - createId: () => protocolId(nextId++), - interactionTimeoutMs: options.interactionTimeoutMs, - maxPendingServerRequestBytes: options.maxPendingServerRequestBytes, - now: () => new Date(nowMs), - preview: (update) => previews.push(update), - previewCheckpointBytes: options.previewCheckpointBytes, - previewReplaceIntervalMs: options.previewReplaceIntervalMs, - sessionId: SESSION_ID, - }); - - return { - adapter, - advance(milliseconds: number) { - nowMs += milliseconds; - }, - authority, - authorityEntered: authorityEntered.promise, - previews, - rejectNextAuthorityAfterCommit() { - rejectNextAuthorityAfterCommit = true; - }, - releaseAuthority() { - authorityGate.resolve(); - }, - settleInteraction(interactionId: string, resolution?: InteractionResolution) { - const interaction = snapshot.interactions.find((entry) => entry.id === interactionId); - - if (interaction === undefined || interaction.status !== "open") { - throw new Error("The test interaction must be open."); - } - - if (resolution !== undefined && resolution.kind !== interaction.kind) { - throw new Error("The test resolution kind must match the interaction kind."); - } - - const endedAt = new Date(nowMs).toISOString(); - const authoritativeResolution = - resolution?.kind === "input" && resolution.value.type === "answered" - ? { - answeredQuestionIds: Object.keys(resolution.value.answers), - type: "answered" as const, - } - : resolution?.value; - commit({ commandId: protocolId(2_000 + snapshot.revision + 1), type: "command" }, [ - { - entity: "interaction", - op: "put", - value: interactionSchema.parse( - resolution === undefined - ? { ...interaction, endedAt, status: "expired" } - : { - ...interaction, - endedAt, - resolution: authoritativeResolution, - status: "resolved", - }, - ), - }, - ]); - }, - snapshot: () => snapshot, - }; -} - -function turnAttachment() { - return { - cause: { commandId: COMMAND_ID, type: "command" as const }, - run: activeRun("2026-07-16T08:00:00.000Z"), - threadId: THREAD_ID, - turnId: TURN_ID, - }; -} - -async function registerTurn(adapter: OpenAiContractAdapter): Promise { - await adapter.attachTurn(turnAttachment()); -} - -describe("OpenAI Contract adapter", () => { - test("checkpoints long Preview text before opening a bounded next segment", async () => { - const harness = createHarness({ - previewCheckpointBytes: 5, - previewReplaceIntervalMs: 10_000, - }); - await registerTurn(harness.adapter); - await harness.adapter.handleNotification("item/started", { - item: { id: "message-1", text: "", type: "agentMessage" }, - startedAtMs: Date.parse("2026-07-16T08:00:00.100Z"), - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleNotification("item/agentMessage/delta", { - delta: "hello", - itemId: "message-1", - threadId: THREAD_ID, - turnId: TURN_ID, - }); - - expect(harness.previews).toHaveLength(0); - expect(harness.snapshot().items[0]).toMatchObject({ - content: [{ text: "hello", type: "text" }], - status: "active", - }); - - await harness.adapter.handleNotification("item/agentMessage/delta", { - delta: "!", - itemId: "message-1", - threadId: THREAD_ID, - turnId: TURN_ID, - }); - expect(harness.previews[0]?.update).toMatchObject({ - fromSequence: 1, - op: "append", - segment: 1, - text: "!", - throughSequence: 1, - }); - }); - - test("preserves reasoning section boundaries and replaces MCP progress snapshots", async () => { - const harness = createHarness(); - await registerTurn(harness.adapter); - await harness.adapter.handleNotification("item/started", { - item: { content: [], id: "reasoning-1", summary: [], type: "reasoning" }, - startedAtMs: Date.parse("2026-07-16T08:00:00.100Z"), - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleNotification("item/reasoning/summaryPartAdded", { - itemId: "reasoning-1", - summaryIndex: 0, - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleNotification("item/reasoning/summaryTextDelta", { - delta: "first", - itemId: "reasoning-1", - summaryIndex: 0, - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleNotification("item/reasoning/summaryPartAdded", { - itemId: "reasoning-1", - summaryIndex: 1, - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleNotification("item/reasoning/summaryTextDelta", { - delta: "second", - itemId: "reasoning-1", - summaryIndex: 1, - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleNotification("item/started", { - item: { - arguments: {}, - error: null, - id: "mcp-1", - result: null, - server: "demo", - status: "inProgress", - tool: "work", - type: "mcpToolCall", - }, - startedAtMs: Date.parse("2026-07-16T08:00:00.200Z"), - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleNotification("item/mcpToolCall/progress", { - itemId: "mcp-1", - message: "one", - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleNotification("item/mcpToolCall/progress", { - itemId: "mcp-1", - message: "two", - threadId: THREAD_ID, - turnId: TURN_ID, - }); - - expect(harness.previews.map((entry) => entry.update)).toMatchObject([ - { channel: "reasoning.text", op: "append", text: "first", throughSequence: 1 }, - { channel: "reasoning.text", op: "append", text: "\n\n", throughSequence: 2 }, - { channel: "reasoning.text", op: "append", text: "second", throughSequence: 3 }, - { channel: "tool.progress", op: "replace", text: "one", throughSequence: 1 }, - { channel: "tool.progress", op: "replace", text: "two", throughSequence: 2 }, - ]); - }); - - test("removes private citation markup from Preview and authoritative messages", async () => { - const harness = createHarness(); - const citation = "\uE200cite\uE202turn7search12\uE201"; - await registerTurn(harness.adapter); - await harness.adapter.handleNotification("item/started", { - item: { id: "message-1", text: "", type: "agentMessage" }, - startedAtMs: Date.parse("2026-07-16T08:00:00.100Z"), - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleNotification("item/agentMessage/delta", { - delta: "before\uE200ci", - itemId: "message-1", - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleNotification("item/agentMessage/delta", { - delta: "te\uE202turn7search12\uE201after", - itemId: "message-1", - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleNotification("item/completed", { - completedAtMs: Date.parse("2026-07-16T08:00:00.300Z"), - item: { id: "message-1", text: `before${citation}after`, type: "agentMessage" }, - threadId: THREAD_ID, - turnId: TURN_ID, - }); - - expect(harness.previews.map((entry) => entry.update)).toMatchObject([ - { text: "before", throughSequence: 1 }, - { text: "after", throughSequence: 2 }, - ]); - expect(harness.snapshot().items[0]).toMatchObject({ - content: [{ text: "beforeafter", type: "text" }], - status: "completed", - }); - }); - - test("projects usage and authoritative terminal, change, MCP, and plan snapshots", async () => { - const harness = createHarness(); - await registerTurn(harness.adapter); - await harness.adapter.handleNotification("item/started", { - item: { - aggregatedOutput: null, - command: "pwd", - cwd: "/workspace", - exitCode: null, - id: "command-1", - status: "inProgress", - type: "commandExecution", - }, - startedAtMs: Date.parse("2026-07-16T08:00:00.100Z"), - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleNotification("item/completed", { - completedAtMs: Date.parse("2026-07-16T08:00:00.200Z"), - item: { - aggregatedOutput: "/workspace\n", - command: "pwd", - cwd: "/workspace", - exitCode: 0, - id: "command-1", - status: "completed", - type: "commandExecution", - }, - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleNotification("item/fileChange/patchUpdated", { - changes: [ - { - diff: "+hello", - kind: { type: "add" }, - path: "hello.txt", - }, - ], - itemId: "change-1", - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleNotification("item/completed", { - completedAtMs: Date.parse("2026-07-16T08:00:00.300Z"), - item: { - changes: [ - { - diff: "+hello", - kind: { type: "add" }, - path: "hello.txt", - }, - ], - id: "change-1", - status: "completed", - type: "fileChange", - }, - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleNotification("item/started", { - item: { - arguments: { path: "README.md" }, - error: null, - id: "mcp-1", - result: null, - server: "files", - status: "inProgress", - tool: "read", - type: "mcpToolCall", - }, - startedAtMs: Date.parse("2026-07-16T08:00:00.400Z"), - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleNotification("item/completed", { - completedAtMs: Date.parse("2026-07-16T08:00:00.500Z"), - item: { - arguments: { path: "README.md" }, - error: null, - id: "mcp-1", - result: { - _meta: null, - content: [{ text: "ok", type: "text" }], - structuredContent: { bytes: 2 }, - }, - server: "files", - status: "completed", - tool: "read", - type: "mcpToolCall", - }, - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleNotification("item/started", { - item: { - action: null, - id: "search-1", - query: "", - results: null, - type: "webSearch", - }, - startedAtMs: Date.parse("2026-07-16T08:00:00.550Z"), - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleNotification("item/completed", { - completedAtMs: Date.parse("2026-07-16T08:00:00.560Z"), - item: { - action: { query: "protocol", queries: null, type: "search" }, - id: "search-1", - query: "protocol", - results: [{ title: "Result", type: "text_result", url: "https://example.com" }], - type: "webSearch", - }, - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleNotification("item/completed", { - completedAtMs: Date.parse("2026-07-16T08:00:00.570Z"), - item: { id: "native-plan", text: "Inspect the protocol", type: "plan" }, - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleNotification("turn/plan/updated", { - explanation: "work", - plan: [{ status: "inProgress", step: "inspect" }], - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleNotification("thread/tokenUsage/updated", { - threadId: THREAD_ID, - tokenUsage: { - last: { - cachedInputTokens: 2, - inputTokens: 10, - outputTokens: 4, - reasoningOutputTokens: 1, - totalTokens: 14, - }, - total: { - cachedInputTokens: 22, - inputTokens: 110, - outputTokens: 54, - reasoningOutputTokens: 11, - totalTokens: 164, - }, - }, - turnId: TURN_ID, - }); - - await harness.adapter.handleNotification("thread/tokenUsage/updated", { - threadId: THREAD_ID, - tokenUsage: { - last: { - cachedInputTokens: 1, - inputTokens: 3, - outputTokens: 2, - reasoningOutputTokens: 1, - totalTokens: 5, - }, - total: { - cachedInputTokens: 23, - inputTokens: 113, - outputTokens: 56, - reasoningOutputTokens: 12, - totalTokens: 169, - }, - }, - turnId: TURN_ID, - }); - - const snapshot = harness.snapshot(); - expect(snapshot.runs[0]?.usage).toEqual({ - cachedInput: 3, - input: 13, - output: 6, - reasoning: 2, - total: 19, - }); - expect(snapshot.items).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - command: "pwd", - exitCode: 0, - kind: "terminal", - status: "completed", - stdout: [{ text: "/workspace\n", type: "text" }], - }), - expect.objectContaining({ - changes: [ - { - diff: { text: "+hello", type: "text" }, - operation: "create", - path: "hello.txt", - }, - ], - kind: "change", - status: "completed", - }), - expect.objectContaining({ - input: { path: "README.md" }, - kind: "tool", - name: "read", - origin: "mcp", - output: [{ text: "ok", type: "text" }], - server: "files", - status: "completed", - structuredOutput: { bytes: 2 }, - }), - expect.objectContaining({ - input: { - action: { query: "protocol", queries: null, type: "search" }, - query: "protocol", - }, - kind: "tool", - name: "web_search", - structuredOutput: [{ title: "Result", type: "text_result", url: "https://example.com" }], - }), - expect.objectContaining({ - entries: [{ id: "0", status: "in_progress", text: "inspect" }], - explanation: "work", - id: "turn-plan", - kind: "plan", - status: "active", - }), - expect.objectContaining({ - entries: [{ id: "0", status: "completed", text: "Inspect the protocol" }], - id: "native-plan", - kind: "plan", - status: "completed", - }), - ]), - ); - - await harness.adapter.handleNotification("turn/completed", { - threadId: THREAD_ID, - turn: { - completedAt: Date.parse("2026-07-16T08:00:00.600Z") / 1_000, - error: null, - id: TURN_ID, - items: [], - itemsView: "notLoaded", - startedAt: Date.parse("2026-07-16T08:00:00.000Z") / 1_000, - status: "completed", - }, - }); - expect(harness.snapshot().runs[0]).toMatchObject({ status: "completed" }); - expect(harness.snapshot().items.find((item) => item.id === "turn-plan")).toMatchObject({ - status: "completed", - }); - }); - - test.each([ - { - error: { message: "command failed explicitly" }, - expectedError: "command failed explicitly", - expectedStatus: "failed", - label: "explicit failure", - nativeStatus: "failed", - }, - { - error: null, - expectedError: "commandExecution failed.", - expectedStatus: "failed", - label: "fallback failure", - nativeStatus: "failed", - }, - { - error: null, - expectedError: null, - expectedStatus: "cancelled", - label: "declined command", - nativeStatus: "declined", - }, - ] as const)( - "projects $label as $expectedStatus", - async ({ error, expectedError, expectedStatus, nativeStatus }) => { - const harness = createHarness(); - await registerTurn(harness.adapter); - await harness.adapter.handleNotification("item/completed", { - completedAtMs: Date.parse("2026-07-16T08:00:00.100Z"), - item: { - aggregatedOutput: "command output", - command: "false", - error, - exitCode: nativeStatus === "failed" ? 1 : null, - id: `command-${nativeStatus}-${expectedError ?? "none"}`, - status: nativeStatus, - type: "commandExecution", - }, - threadId: THREAD_ID, - turnId: TURN_ID, - }); - - const item = harness.snapshot().items[0]; - expect(item).toMatchObject({ kind: "terminal", status: expectedStatus }); - - if (expectedError === null) { - expect(item).not.toHaveProperty("error"); - } else { - expect(item).toMatchObject({ error: { message: expectedError, retryable: false } }); - } - }, - ); -}); diff --git a/tests/openai-contract-adapter-items.test.ts b/tests/openai-contract-adapter-items.test.ts deleted file mode 100644 index 846ef47..0000000 --- a/tests/openai-contract-adapter-items.test.ts +++ /dev/null @@ -1,672 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - AuthorityOutcomeUnknownError, - applyCommittedMutation, - interactionSchema, - itemSchema, - previewUpdateSchema, - validateSessionSnapshot, -} from "../src/contract"; -import type { - AuthorityOperation, - CommittedMutation, - InteractionResolution, - ProtocolAdmissionLimits, - Run, - SessionSnapshot, -} from "../src/contract"; -import { - OPENAI_APP_SERVER_MCP_ELICITATION_EXTENSION, - OpenAiContractAdapter, - type OpenAiAuthorityUpdate, -} from "../src/runtimes/openai/contract-adapter"; -import type { ContractPreviewUpdate } from "../src/runtimes/contract-projection"; - -const SESSION_ID = protocolId(1); -const RUN_ID = protocolId(2); -const COMMAND_ID = protocolId(3); -const THREAD_ID = "thread-1"; -const TURN_ID = "turn-1"; - -function protocolId(value: number): string { - return value.toString().padStart(26, "0"); -} - -function activeRun(startedAt: string): Run { - return { - id: RUN_ID, - input: [{ text: "hello", type: "text" }], - origin: "user", - startedAt, - status: "active", - }; -} - -function createInitialSnapshot(capturedAt: string): SessionSnapshot { - return validateSessionSnapshot({ - capturedAt, - interactions: [], - items: [], - protocolVersion: 2, - revision: 0, - runs: [activeRun(capturedAt)], - session: { - capabilities: { - [OPENAI_APP_SERVER_MCP_ELICITATION_EXTENSION]: {}, - "interaction.input": {}, - "interaction.permission": {}, - "interaction.tool": {}, - "item.change": {}, - "item.plan": {}, - "item.reasoning": {}, - "item.terminal": {}, - "openai.app-server/thread-item": {}, - }, - config: [], - createdAt: capturedAt, - id: SESSION_ID, - status: "open", - updatedAt: capturedAt, - }, - }); -} - -function createHarness( - options: { - admissionLimits?: ProtocolAdmissionLimits; - holdAuthority?: boolean; - interactionTimeoutMs?: number; - maxPendingServerRequestBytes?: number; - previewCheckpointBytes?: number; - previewReplaceIntervalMs?: number; - } = {}, -) { - let nowMs = Date.parse("2026-07-16T08:00:00.000Z"); - let snapshot = createInitialSnapshot(new Date(nowMs).toISOString()); - let nextId = 100; - let rejectNextAuthorityAfterCommit = false; - const authorityEntered = Promise.withResolvers(); - const authorityGate = Promise.withResolvers(); - const authority: OpenAiAuthorityUpdate[] = []; - const committedMutationIds = new Set(); - const previews: ContractPreviewUpdate[] = []; - const commit = ( - cause: CommittedMutation["cause"], - operations: AuthorityOperation[], - mutationId = protocolId(1_000 + snapshot.revision + 1), - ): void => { - if (committedMutationIds.has(mutationId)) { - return; - } - - const revision = snapshot.revision + 1; - const mutation: CommittedMutation = { - baseRevision: snapshot.revision, - cause, - committedAt: new Date(nowMs).toISOString(), - mutationId, - operations, - revision, - sessionId: SESSION_ID, - }; - snapshot = applyCommittedMutation(snapshot, mutation); - committedMutationIds.add(mutationId); - }; - const adapter = new OpenAiContractAdapter({ - admissionLimits: options.admissionLimits, - authority: async (update) => { - authority.push(update); - if (options.holdAuthority === true) { - authorityEntered.resolve(); - await authorityGate.promise; - } - commit(update.cause, [...update.operations] as AuthorityOperation[], update.mutationId); - if (rejectNextAuthorityAfterCommit) { - rejectNextAuthorityAfterCommit = false; - throw new AuthorityOutcomeUnknownError("authority result lost"); - } - }, - createId: () => protocolId(nextId++), - interactionTimeoutMs: options.interactionTimeoutMs, - maxPendingServerRequestBytes: options.maxPendingServerRequestBytes, - now: () => new Date(nowMs), - preview: (update) => previews.push(update), - previewCheckpointBytes: options.previewCheckpointBytes, - previewReplaceIntervalMs: options.previewReplaceIntervalMs, - sessionId: SESSION_ID, - }); - - return { - adapter, - advance(milliseconds: number) { - nowMs += milliseconds; - }, - authority, - authorityEntered: authorityEntered.promise, - previews, - rejectNextAuthorityAfterCommit() { - rejectNextAuthorityAfterCommit = true; - }, - releaseAuthority() { - authorityGate.resolve(); - }, - settleInteraction(interactionId: string, resolution?: InteractionResolution) { - const interaction = snapshot.interactions.find((entry) => entry.id === interactionId); - - if (interaction === undefined || interaction.status !== "open") { - throw new Error("The test interaction must be open."); - } - - if (resolution !== undefined && resolution.kind !== interaction.kind) { - throw new Error("The test resolution kind must match the interaction kind."); - } - - const endedAt = new Date(nowMs).toISOString(); - const authoritativeResolution = - resolution?.kind === "input" && resolution.value.type === "answered" - ? { - answeredQuestionIds: Object.keys(resolution.value.answers), - type: "answered" as const, - } - : resolution?.value; - commit({ commandId: protocolId(2_000 + snapshot.revision + 1), type: "command" }, [ - { - entity: "interaction", - op: "put", - value: interactionSchema.parse( - resolution === undefined - ? { ...interaction, endedAt, status: "expired" } - : { - ...interaction, - endedAt, - resolution: authoritativeResolution, - status: "resolved", - }, - ), - }, - ]); - }, - snapshot: () => snapshot, - }; -} - -function turnAttachment() { - return { - cause: { commandId: COMMAND_ID, type: "command" as const }, - run: activeRun("2026-07-16T08:00:00.000Z"), - threadId: THREAD_ID, - turnId: TURN_ID, - }; -} - -async function registerTurn(adapter: OpenAiContractAdapter): Promise { - await adapter.attachTurn(turnAttachment()); -} - -function userInputRequest(autoResolutionMs: number | null = null) { - return { - autoResolutionMs, - itemId: "input-1", - questions: [ - { - id: "name", - isOther: false, - isSecret: false, - options: null, - question: "Name?", - }, - ], - threadId: THREAD_ID, - turnId: TURN_ID, - }; -} - -describe("OpenAI Contract adapter", () => { - test("binds each Run to exactly one native Turn", async () => { - const harness = createHarness(); - await registerTurn(harness.adapter); - - await expect( - harness.adapter.attachTurn({ - cause: { commandId: protocolId(4), type: "command" }, - run: activeRun("2026-07-16T08:00:00.000Z"), - threadId: THREAD_ID, - turnId: "turn-2", - }), - ).rejects.toThrow("already attached"); - }); - - test("replays a terminal Turn that arrives before attachment", async () => { - const harness = createHarness(); - const terminal = { - threadId: THREAD_ID, - turn: { - completedAt: Date.parse("2026-07-16T08:00:00.200Z") / 1_000, - durationMs: 200, - error: null, - id: TURN_ID, - items: [], - itemsView: "notLoaded", - startedAt: Date.parse("2026-07-16T08:00:00.000Z") / 1_000, - status: "completed", - }, - }; - - await harness.adapter.handleNotification("turn/completed", terminal); - - expect(harness.snapshot().runs[0]).toMatchObject({ status: "active" }); - await registerTurn(harness.adapter); - expect(harness.snapshot().runs[0]).toMatchObject({ - finishReason: "success", - status: "completed", - }); - await expect(registerTurn(harness.adapter)).resolves.toBeUndefined(); - await expect( - harness.adapter.handleNotification("turn/completed", terminal), - ).resolves.toBeUndefined(); - expect(harness.snapshot().runs).toHaveLength(1); - await expect( - harness.adapter.attachTurn({ - ...turnAttachment(), - run: { - ...turnAttachment().run, - input: [{ text: "changed", type: "text" }], - }, - }), - ).rejects.toThrow("different state"); - }); - - test("replays item events before a pre-attachment terminal Turn", async () => { - const harness = createHarness(); - - await harness.adapter.handleNotification("item/started", { - item: { id: "message-1", text: "", type: "agentMessage" }, - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleNotification("item/agentMessage/delta", { - delta: "hello", - itemId: "message-1", - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleNotification("item/completed", { - item: { id: "message-1", text: "hello", type: "agentMessage" }, - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleNotification("turn/completed", { - threadId: THREAD_ID, - turn: { - id: TURN_ID, - items: [], - itemsView: "notLoaded", - status: "completed", - }, - }); - - await registerTurn(harness.adapter); - - expect(harness.snapshot()).toMatchObject({ - items: [ - { - content: [{ text: "hello", type: "text" }], - id: "message-1", - status: "completed", - }, - ], - runs: [{ id: RUN_ID, status: "completed" }], - }); - }); - - test("fails closed for a server request received before Turn attachment", async () => { - const harness = createHarness(); - - await expect( - harness.adapter.handleServerRequest("item/tool/requestUserInput", 1, userInputRequest()), - ).rejects.toThrow("before attachment"); - expect(harness.authority).toHaveLength(0); - }); - - test("bounds non-terminal Turn events waiting for attachment", async () => { - const harness = createHarness(); - - for (let index = 0; index < 1_024; index += 1) { - await harness.adapter.handleNotification("item/agentMessage/delta", { - delta: "x", - itemId: "message-1", - threadId: THREAD_ID, - turnId: `turn-${String(index)}`, - }); - } - - await expect( - harness.adapter.handleNotification("item/agentMessage/delta", { - delta: "x", - itemId: "message-1", - threadId: THREAD_ID, - turnId: "turn-overflow", - }), - ).rejects.toThrow("pending Turn event limit"); - harness.adapter.dispose(); - }); - - test("bounds terminal Turns waiting for attachment", async () => { - const harness = createHarness(); - const terminal = (turnId: string) => - harness.adapter.handleNotification("turn/completed", { - threadId: THREAD_ID, - turn: { - completedAt: null, - durationMs: null, - error: null, - id: turnId, - items: [], - itemsView: "notLoaded", - startedAt: null, - status: "completed", - }, - }); - - for (let index = 0; index < 1_024; index += 1) { - await terminal(`turn-${String(index)}`); - } - - await expect(terminal("turn-overflow")).rejects.toThrow("pending terminal Turn limit"); - harness.adapter.dispose(); - }); - - test("keeps a pre-attachment terminal Turn identity stable", async () => { - const harness = createHarness(); - const terminal = { - threadId: THREAD_ID, - turn: { - completedAt: null, - durationMs: null, - error: null, - id: TURN_ID, - items: [], - itemsView: "notLoaded", - startedAt: null, - status: "completed", - }, - }; - - await harness.adapter.handleNotification("turn/completed", terminal); - await expect( - harness.adapter.handleNotification("turn/completed", structuredClone(terminal)), - ).resolves.toBeUndefined(); - await expect( - harness.adapter.handleNotification("turn/completed", { - ...terminal, - turn: { ...terminal.turn, status: "failed" }, - }), - ).rejects.toThrow("changed before attachment"); - harness.adapter.dispose(); - }); - - test("rejects changed Run state on an attachment retry", async () => { - const harness = createHarness(); - await registerTurn(harness.adapter); - - await expect( - harness.adapter.attachTurn({ - cause: { commandId: protocolId(4), type: "command" }, - run: { - ...activeRun("2026-07-16T08:00:00.000Z"), - input: [{ text: "changed", type: "text" }], - }, - threadId: THREAD_ID, - turnId: TURN_ID, - }), - ).rejects.toThrow("different state"); - expect(harness.authority).toHaveLength(1); - }); - - test("reuses the attachment mutation ID after an ambiguous Authority result", async () => { - const harness = createHarness(); - harness.rejectNextAuthorityAfterCommit(); - - await expect(harness.adapter.attachTurn(turnAttachment())).rejects.toThrow("result lost"); - const mutationId = harness.authority[0]?.mutationId; - await expect(harness.adapter.attachTurn(turnAttachment())).resolves.toBeUndefined(); - - expect(harness.authority.map((update) => update.mutationId)).toEqual([mutationId, mutationId]); - }); - - test("does not enter Authority when disposal wins an attachment race", async () => { - const harness = createHarness(); - const attachment = harness.adapter.attachTurn(turnAttachment()); - harness.adapter.dispose(); - - await expect(attachment).rejects.toThrow("disposed"); - expect(harness.authority).toHaveLength(0); - }); - - test("does not revive local state when disposal follows an entered Authority write", async () => { - const harness = createHarness({ holdAuthority: true }); - const attachment = harness.adapter.attachTurn(turnAttachment()); - await harness.authorityEntered; - harness.adapter.dispose(); - harness.releaseAuthority(); - - await expect(attachment).resolves.toBeUndefined(); - expect(harness.authority).toHaveLength(1); - await expect( - harness.adapter.handleNotification("turn/completed", { - threadId: THREAD_ID, - turn: { id: TURN_ID, status: "completed" }, - }), - ).rejects.toThrow("disposed"); - }); - - test.each(["item", "patch", "plan", "resolved request", "terminal"] as const)( - "reuses the first %s intent after an ambiguous Authority result", - async (kind: "item" | "patch" | "plan" | "resolved request" | "terminal") => { - const harness = createHarness(); - await registerTurn(harness.adapter); - let method: string; - let params: Record; - let expectedEntity: AuthorityOperation["entity"]; - - switch (kind) { - case "item": - method = "item/completed"; - params = { - item: { id: "message-1", text: "hello", type: "agentMessage" }, - threadId: THREAD_ID, - turnId: TURN_ID, - }; - expectedEntity = "item"; - break; - case "patch": - method = "item/fileChange/patchUpdated"; - params = { - changes: [{ diff: "+hello", kind: { type: "add" }, path: "hello.txt" }], - itemId: "change-1", - threadId: THREAD_ID, - turnId: TURN_ID, - }; - expectedEntity = "item"; - break; - case "plan": - method = "turn/plan/updated"; - params = { - explanation: "work", - plan: [{ status: "inProgress", step: "inspect" }], - threadId: THREAD_ID, - turnId: TURN_ID, - }; - expectedEntity = "item"; - break; - case "resolved request": - await harness.adapter.handleServerRequest( - "item/tool/requestUserInput", - 61, - userInputRequest(), - ); - method = "serverRequest/resolved"; - params = { requestId: 61, threadId: THREAD_ID }; - expectedEntity = "interaction"; - break; - case "terminal": - method = "turn/completed"; - params = { - threadId: THREAD_ID, - turn: { - id: TURN_ID, - items: [], - itemsView: "notLoaded", - status: "completed", - }, - }; - expectedEntity = "run"; - break; - } - - harness.rejectNextAuthorityAfterCommit(); - await expect(harness.adapter.handleNotification(method, params)).rejects.toThrow( - "result lost", - ); - const first = harness.authority.at(-1)!; - harness.advance(1_000); - await expect( - harness.adapter.handleNotification(method, structuredClone(params)), - ).resolves.toBeUndefined(); - - const retries = harness.authority.slice(-2); - expect(retries.map((update) => update.mutationId)).toEqual([ - first.mutationId, - first.mutationId, - ]); - expect(retries.map((update) => update.operations)).toEqual([ - first.operations, - first.operations, - ]); - expect(first.operations.some((operation) => operation.entity === expectedEntity)).toBe(true); - expect(JSON.stringify(first.operations)).toContain("2026-07-16T08:00:00.000Z"); - }, - ); - - test.each([ - [ - "encoded Run", - { maxBytes: 1_500, maxInlineBytes: 1_024 }, - [{ text: "x".repeat(2_048), type: "text" as const }], - "encoded byte limit", - ], - [ - "inline Blob", - { maxBytes: 10_000, maxInlineBytes: 1 }, - [{ data: "aGVsbG8=", mediaType: "text/plain", type: "inline_blob" as const }], - "inline Blob", - ], - ] as const)( - "rejects an oversized attachment %s before Authority", - async (_, limits, input, error) => { - const harness = createHarness({ admissionLimits: limits }); - - await expect( - harness.adapter.attachTurn({ - ...turnAttachment(), - run: { ...turnAttachment().run, input: [...input] }, - }), - ).rejects.toThrow(error); - expect(harness.authority).toHaveLength(0); - }, - ); - - test("projects streaming text as Preview and repairs dropped Preview with the final Item", async () => { - const harness = createHarness({ previewReplaceIntervalMs: 1_000 }); - await registerTurn(harness.adapter); - await harness.adapter.handleNotification("item/started", { - item: { id: "message-1", phase: "final_answer", text: "", type: "agentMessage" }, - startedAtMs: Date.parse("2026-07-16T08:00:00.100Z"), - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleNotification("item/agentMessage/delta", { - delta: "hel", - itemId: "message-1", - threadId: THREAD_ID, - turnId: TURN_ID, - }); - harness.advance(1_100); - await harness.adapter.handleNotification("item/agentMessage/delta", { - delta: "lo", - itemId: "message-1", - threadId: THREAD_ID, - turnId: TURN_ID, - }); - - expect(harness.previews.map((entry) => previewUpdateSchema.parse(entry.update))).toEqual([ - { - channel: "message.text", - fromSequence: 1, - itemId: "message-1", - op: "append", - segment: 0, - streamId: "message.text", - text: "hel", - throughSequence: 1, - }, - { - channel: "message.text", - itemId: "message-1", - op: "replace", - segment: 0, - streamId: "message.text", - text: "hello", - throughSequence: 2, - }, - ]); - - await harness.adapter.handleNotification("item/completed", { - completedAtMs: Date.parse("2026-07-16T08:00:01.300Z"), - item: { - id: "message-1", - phase: "final_answer", - text: "hello", - type: "agentMessage", - }, - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleNotification("turn/completed", { - threadId: THREAD_ID, - turn: { - completedAt: Date.parse("2026-07-16T08:00:01.400Z") / 1_000, - error: null, - id: TURN_ID, - items: [], - itemsView: "notLoaded", - startedAt: Date.parse("2026-07-16T08:00:00.000Z") / 1_000, - status: "completed", - }, - }); - - const snapshot = harness.snapshot(); - expect(snapshot.runs).toMatchObject([ - { finishReason: "success", id: RUN_ID, status: "completed" }, - ]); - expect(snapshot.items.map((item) => itemSchema.parse(item))).toMatchObject([ - { - content: [{ text: "hello", type: "text" }], - id: "message-1", - kind: "message", - phase: "final", - role: "agent", - status: "completed", - }, - ]); - - await harness.adapter.handleNotification("item/agentMessage/delta", { - delta: "late", - itemId: "message-1", - threadId: THREAD_ID, - turnId: TURN_ID, - }); - expect(harness.previews).toHaveLength(2); - }); -}); diff --git a/tests/openai-contract-adapter-run-lifecycle.test.ts b/tests/openai-contract-adapter-run-lifecycle.test.ts deleted file mode 100644 index 51722bd..0000000 --- a/tests/openai-contract-adapter-run-lifecycle.test.ts +++ /dev/null @@ -1,632 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - AuthorityOutcomeUnknownError, - applyCommittedMutation, - interactionSchema, - validateSessionSnapshot, -} from "../src/contract"; -import type { - AuthorityOperation, - CommittedMutation, - InteractionResolution, - ProtocolAdmissionLimits, - Run, - SessionSnapshot, -} from "../src/contract"; -import { - OPENAI_APP_SERVER_MCP_ELICITATION_EXTENSION, - OpenAiContractAdapter, - type OpenAiAuthorityUpdate, -} from "../src/runtimes/openai/contract-adapter"; -import type { ContractPreviewUpdate } from "../src/runtimes/contract-projection"; - -const SESSION_ID = protocolId(1); -const RUN_ID = protocolId(2); -const COMMAND_ID = protocolId(3); -const THREAD_ID = "thread-1"; -const TURN_ID = "turn-1"; - -function protocolId(value: number): string { - return value.toString().padStart(26, "0"); -} - -function activeRun(startedAt: string): Run { - return { - id: RUN_ID, - input: [{ text: "hello", type: "text" }], - origin: "user", - startedAt, - status: "active", - }; -} - -function createInitialSnapshot(capturedAt: string): SessionSnapshot { - return validateSessionSnapshot({ - capturedAt, - interactions: [], - items: [], - protocolVersion: 2, - revision: 0, - runs: [activeRun(capturedAt)], - session: { - capabilities: { - [OPENAI_APP_SERVER_MCP_ELICITATION_EXTENSION]: {}, - "interaction.input": {}, - "interaction.permission": {}, - "interaction.tool": {}, - "item.change": {}, - "item.plan": {}, - "item.reasoning": {}, - "item.terminal": {}, - "openai.app-server/thread-item": {}, - }, - config: [], - createdAt: capturedAt, - id: SESSION_ID, - status: "open", - updatedAt: capturedAt, - }, - }); -} - -function createHarness( - options: { - admissionLimits?: ProtocolAdmissionLimits; - holdAuthority?: boolean; - interactionTimeoutMs?: number; - maxPendingServerRequestBytes?: number; - previewCheckpointBytes?: number; - previewReplaceIntervalMs?: number; - } = {}, -) { - let nowMs = Date.parse("2026-07-16T08:00:00.000Z"); - let snapshot = createInitialSnapshot(new Date(nowMs).toISOString()); - let nextId = 100; - let rejectNextAuthorityAfterCommit = false; - const authorityEntered = Promise.withResolvers(); - const authorityGate = Promise.withResolvers(); - const authority: OpenAiAuthorityUpdate[] = []; - const committedMutationIds = new Set(); - const previews: ContractPreviewUpdate[] = []; - const commit = ( - cause: CommittedMutation["cause"], - operations: AuthorityOperation[], - mutationId = protocolId(1_000 + snapshot.revision + 1), - ): void => { - if (committedMutationIds.has(mutationId)) { - return; - } - - const revision = snapshot.revision + 1; - const mutation: CommittedMutation = { - baseRevision: snapshot.revision, - cause, - committedAt: new Date(nowMs).toISOString(), - mutationId, - operations, - revision, - sessionId: SESSION_ID, - }; - snapshot = applyCommittedMutation(snapshot, mutation); - committedMutationIds.add(mutationId); - }; - const adapter = new OpenAiContractAdapter({ - admissionLimits: options.admissionLimits, - authority: async (update) => { - authority.push(update); - if (options.holdAuthority === true) { - authorityEntered.resolve(); - await authorityGate.promise; - } - commit(update.cause, [...update.operations] as AuthorityOperation[], update.mutationId); - if (rejectNextAuthorityAfterCommit) { - rejectNextAuthorityAfterCommit = false; - throw new AuthorityOutcomeUnknownError("authority result lost"); - } - }, - createId: () => protocolId(nextId++), - interactionTimeoutMs: options.interactionTimeoutMs, - maxPendingServerRequestBytes: options.maxPendingServerRequestBytes, - now: () => new Date(nowMs), - preview: (update) => previews.push(update), - previewCheckpointBytes: options.previewCheckpointBytes, - previewReplaceIntervalMs: options.previewReplaceIntervalMs, - sessionId: SESSION_ID, - }); - - return { - adapter, - advance(milliseconds: number) { - nowMs += milliseconds; - }, - authority, - authorityEntered: authorityEntered.promise, - previews, - rejectNextAuthorityAfterCommit() { - rejectNextAuthorityAfterCommit = true; - }, - releaseAuthority() { - authorityGate.resolve(); - }, - settleInteraction(interactionId: string, resolution?: InteractionResolution) { - const interaction = snapshot.interactions.find((entry) => entry.id === interactionId); - - if (interaction === undefined || interaction.status !== "open") { - throw new Error("The test interaction must be open."); - } - - if (resolution !== undefined && resolution.kind !== interaction.kind) { - throw new Error("The test resolution kind must match the interaction kind."); - } - - const endedAt = new Date(nowMs).toISOString(); - const authoritativeResolution = - resolution?.kind === "input" && resolution.value.type === "answered" - ? { - answeredQuestionIds: Object.keys(resolution.value.answers), - type: "answered" as const, - } - : resolution?.value; - commit({ commandId: protocolId(2_000 + snapshot.revision + 1), type: "command" }, [ - { - entity: "interaction", - op: "put", - value: interactionSchema.parse( - resolution === undefined - ? { ...interaction, endedAt, status: "expired" } - : { - ...interaction, - endedAt, - resolution: authoritativeResolution, - status: "resolved", - }, - ), - }, - ]); - }, - snapshot: () => snapshot, - }; -} - -function turnAttachment() { - return { - cause: { commandId: COMMAND_ID, type: "command" as const }, - run: activeRun("2026-07-16T08:00:00.000Z"), - threadId: THREAD_ID, - turnId: TURN_ID, - }; -} - -async function registerTurn(adapter: OpenAiContractAdapter): Promise { - await adapter.attachTurn(turnAttachment()); -} - -function userInputRequest(autoResolutionMs: number | null = null) { - return { - autoResolutionMs, - itemId: "input-1", - questions: [ - { - id: "name", - isOther: false, - isSecret: false, - options: null, - question: "Name?", - }, - ], - threadId: THREAD_ID, - turnId: TURN_ID, - }; -} - -describe("OpenAI Contract adapter", () => { - test("snapshots protected native request data before waiting for a decision", async () => { - const harness = createHarness(); - await registerTurn(harness.adapter); - const params = { - environmentId: null, - itemId: "permission-1", - permissions: { fileSystem: { read: ["/safe"] } }, - reason: "Read a directory", - threadId: THREAD_ID, - turnId: TURN_ID, - }; - const interactionId = await harness.adapter.handleServerRequest( - "item/permissions/requestApproval", - 53, - params, - ); - params.permissions.fileSystem.read[0] = "/changed"; - const resolution = { - kind: "permission", - value: { optionId: "accept_once", type: "selected" }, - } satisfies InteractionResolution; - harness.settleInteraction(interactionId!, resolution); - - expect(harness.adapter.resolveInteraction(interactionId!, resolution)).toEqual({ - id: 53, - result: { - permissions: { fileSystem: { read: ["/safe"] } }, - scope: "turn", - }, - }); - }); - - test("rejects a native request before commit when protected pending data exceeds its budget", async () => { - const harness = createHarness({ maxPendingServerRequestBytes: 1 }); - await registerTurn(harness.adapter); - const before = harness.authority.length; - - await expect( - harness.adapter.handleServerRequest("item/tool/requestUserInput", 52, userInputRequest()), - ).rejects.toThrow("pending request budget"); - expect(harness.authority).toHaveLength(before); - expect(harness.snapshot().interactions).toHaveLength(0); - }); - - test("projects generated images as stable Tool items with renderable output", async () => { - const harness = createHarness(); - await registerTurn(harness.adapter); - await harness.adapter.handleNotification("item/started", { - item: { - id: "image-1", - result: "", - revisedPrompt: null, - status: "inProgress", - type: "imageGeneration", - }, - startedAtMs: Date.parse("2026-07-16T08:00:00.100Z"), - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleNotification("item/completed", { - completedAtMs: Date.parse("2026-07-16T08:00:00.200Z"), - item: { - id: "image-1", - result: "aGVsbG8=", - revisedPrompt: "A blue whale", - savedPath: "/workspace/whale.png", - status: "completed", - type: "imageGeneration", - }, - threadId: THREAD_ID, - turnId: TURN_ID, - }); - - expect(harness.snapshot().items.find((item) => item.id === "image-1")).toMatchObject({ - input: { revisedPrompt: "A blue whale" }, - kind: "tool", - locations: [{ path: "/workspace/whale.png" }], - name: "image_generation", - output: [{ data: "aGVsbG8=", mediaType: "image/png", type: "inline_blob" }], - status: "completed", - }); - }); - - test.each([ - [ - "encoded Item", - { maxBytes: 1_500, maxInlineBytes: 1_024 }, - { - item: { id: "message-oversized", text: "x".repeat(2_048), type: "agentMessage" }, - threadId: THREAD_ID, - turnId: TURN_ID, - }, - "encoded byte limit", - ], - [ - "inline Blob", - { maxBytes: 10_000, maxInlineBytes: 1 }, - { - item: { - id: "image-oversized", - result: "aGVsbG8=", - revisedPrompt: null, - status: "completed", - type: "imageGeneration", - }, - threadId: THREAD_ID, - turnId: TURN_ID, - }, - "inline Blob", - ], - ] as const)( - "rejects an oversized projected %s before Authority", - async (_, limits, params, error) => { - const harness = createHarness({ admissionLimits: limits }); - await registerTurn(harness.adapter); - const before = harness.authority.length; - - await expect(harness.adapter.handleNotification("item/completed", params)).rejects.toThrow( - error, - ); - expect(harness.authority).toHaveLength(before); - expect(harness.snapshot().items).toHaveLength(0); - }, - ); - - test("preserves multi-agent routing and status in Agent Tool items", async () => { - const harness = createHarness(); - await registerTurn(harness.adapter); - const base = { - agentsStates: {}, - id: "agent-call-1", - model: "gpt-5", - prompt: "Inspect the adapter", - reasoningEffort: "high", - receiverThreadIds: ["child-1"], - senderThreadId: THREAD_ID, - tool: "spawnAgent", - type: "collabAgentToolCall", - }; - await harness.adapter.handleNotification("item/started", { - item: { ...base, status: "inProgress" }, - startedAtMs: Date.parse("2026-07-16T08:00:00.100Z"), - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleNotification("item/completed", { - completedAtMs: Date.parse("2026-07-16T08:00:00.200Z"), - item: { - ...base, - agentsStates: { "child-1": { message: "Done", status: "completed" } }, - status: "completed", - }, - threadId: THREAD_ID, - turnId: TURN_ID, - }); - - expect(harness.snapshot().items.find((item) => item.id === "agent-call-1")).toMatchObject({ - category: "agent", - input: { - prompt: "Inspect the adapter", - receiverThreadIds: ["child-1"], - senderThreadId: THREAD_ID, - }, - kind: "tool", - name: "spawnAgent", - status: "completed", - structuredOutput: { "child-1": { message: "Done", status: "completed" } }, - }); - }); - - test("atomically expires unresolved interactions before a cancelled Run", async () => { - const harness = createHarness(); - await registerTurn(harness.adapter); - await harness.adapter.handleNotification("item/started", { - item: { - aggregatedOutput: "", - command: "sleep 10", - id: "command-1", - status: "inProgress", - type: "commandExecution", - }, - startedAtMs: Date.parse("2026-07-16T08:00:00.100Z"), - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleServerRequest("item/commandExecution/requestApproval", 9, { - command: "sleep 10", - itemId: "command-1", - startedAtMs: Date.parse("2026-07-16T08:00:00.200Z"), - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleServerRequest("mcpServer/elicitation/request", 90, { - _meta: null, - message: "Confirm", - mode: "url", - elicitationId: "external-1", - serverName: "deployment", - threadId: THREAD_ID, - turnId: TURN_ID, - url: "https://example.com/confirm", - }); - await harness.adapter.handleNotification("turn/completed", { - threadId: THREAD_ID, - turn: { - completedAt: Date.parse("2026-07-16T08:00:00.500Z") / 1_000, - error: null, - id: TURN_ID, - items: [], - itemsView: "notLoaded", - startedAt: Date.parse("2026-07-16T08:00:00.000Z") / 1_000, - status: "interrupted", - }, - }); - - expect(harness.snapshot().runs[0]).toMatchObject({ id: RUN_ID, status: "cancelled" }); - expect(harness.snapshot().items[0]).toMatchObject({ - id: "command-1", - status: "cancelled", - }); - expect(harness.snapshot().interactions).toEqual([ - expect.objectContaining({ kind: "permission", status: "expired" }), - expect.objectContaining({ kind: "extension", status: "expired" }), - ]); - expect( - harness.snapshot().interactions.every((interaction) => interaction.resolution === undefined), - ).toBe(true); - }); - - test("expires an overdue interaction while closing its Run", async () => { - const harness = createHarness({ interactionTimeoutMs: 100 }); - await registerTurn(harness.adapter); - await harness.adapter.handleServerRequest("item/tool/requestUserInput", 10, { - autoResolutionMs: null, - itemId: "input-1", - questions: [ - { - header: "Name", - id: "name", - isOther: false, - isSecret: false, - options: null, - question: "Your name?", - }, - ], - threadId: THREAD_ID, - turnId: TURN_ID, - }); - harness.advance(101); - await harness.adapter.handleNotification("turn/completed", { - threadId: THREAD_ID, - turn: { - completedAt: Date.parse("2026-07-16T08:00:00.101Z") / 1_000, - error: null, - id: TURN_ID, - items: [], - itemsView: "full", - startedAt: Date.parse("2026-07-16T08:00:00.000Z") / 1_000, - status: "completed", - }, - }); - - expect(harness.snapshot().interactions[0]).toMatchObject({ - status: "expired", - }); - expect(harness.snapshot().interactions[0]).not.toHaveProperty("resolution"); - }); - - test("closes an Interaction when app-server reports its request resolved elsewhere", async () => { - const harness = createHarness(); - await registerTurn(harness.adapter); - const interactionId = await harness.adapter.handleServerRequest( - "item/tool/requestUserInput", - 11, - { - autoResolutionMs: null, - itemId: "input-1", - questions: [ - { - header: "Name", - id: "name", - isOther: false, - isSecret: false, - options: [], - question: "Your name?", - }, - ], - threadId: THREAD_ID, - turnId: TURN_ID, - }, - ); - - expect( - harness.snapshot().interactions.find((interaction) => interaction.id === interactionId), - ).toMatchObject({ - request: { questions: [{ id: "name", type: "text" }] }, - status: "open", - }); - - await harness.adapter.handleNotification("serverRequest/resolved", { - requestId: 11, - threadId: THREAD_ID, - }); - - expect( - harness.snapshot().interactions.find((interaction) => interaction.id === interactionId), - ).toMatchObject({ status: "expired" }); - expect( - harness.adapter.resolveInteraction(interactionId!, { - kind: "input", - value: { type: "cancelled" }, - }), - ).toBeNull(); - }); - - test("fails closed when a successful Turn omits an active Item snapshot", async () => { - const harness = createHarness(); - await registerTurn(harness.adapter); - await harness.adapter.handleNotification("item/started", { - item: { - aggregatedOutput: "", - command: "pwd", - id: "command-1", - status: "inProgress", - type: "commandExecution", - }, - startedAtMs: Date.parse("2026-07-16T08:00:00.100Z"), - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleNotification("turn/completed", { - threadId: THREAD_ID, - turn: { - completedAt: Date.parse("2026-07-16T08:00:00.200Z") / 1_000, - error: null, - id: TURN_ID, - items: [], - itemsView: "full", - startedAt: Date.parse("2026-07-16T08:00:00.000Z") / 1_000, - status: "completed", - }, - }); - - expect(harness.snapshot().items[0]).toMatchObject({ - error: { code: "openai.turn.incomplete" }, - status: "failed", - }); - expect(harness.snapshot().runs[0]).toMatchObject({ - error: { code: "openai.turn.incomplete" }, - status: "failed", - }); - }); - - test("uses receipt time instead of untrusted provider lifecycle clocks", async () => { - const harness = createHarness(); - await registerTurn(harness.adapter); - await harness.adapter.handleNotification("item/started", { - item: { id: "message-1", text: "", type: "agentMessage" }, - startedAtMs: Date.parse("2099-01-01T00:00:00.000Z"), - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleNotification("item/completed", { - completedAtMs: Date.parse("2000-01-01T00:00:00.000Z"), - item: { id: "message-1", text: "done", type: "agentMessage" }, - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleNotification("turn/completed", { - threadId: THREAD_ID, - turn: { - completedAt: Date.parse("1999-01-01T00:00:00.000Z") / 1_000, - error: null, - id: TURN_ID, - items: [], - itemsView: "notLoaded", - startedAt: Date.parse("2099-01-01T00:00:00.000Z") / 1_000, - status: "completed", - }, - }); - - expect(harness.snapshot().items[0]).toMatchObject({ - createdAt: "2026-07-16T08:00:00.000Z", - endedAt: "2026-07-16T08:00:00.000Z", - updatedAt: "2026-07-16T08:00:00.000Z", - }); - expect(harness.snapshot().runs[0]).toMatchObject({ - endedAt: "2026-07-16T08:00:00.000Z", - status: "completed", - }); - }); - - test("disposal releases all in-memory projection state", async () => { - const harness = createHarness(); - await registerTurn(harness.adapter); - harness.adapter.dispose(); - - await expect( - harness.adapter.handleNotification("turn/completed", { - threadId: THREAD_ID, - turn: { id: TURN_ID, status: "completed" }, - }), - ).rejects.toThrow("disposed"); - }); - - test.each([Number.POSITIVE_INFINITY, 1.5])("rejects invalid resource limit %p", (value) => { - expect(() => createHarness({ previewCheckpointBytes: value })).toThrow("finite and positive"); - }); -}); diff --git a/tests/openai-contract-adapter-terminal.test.ts b/tests/openai-contract-adapter-terminal.test.ts deleted file mode 100644 index eb9442e..0000000 --- a/tests/openai-contract-adapter-terminal.test.ts +++ /dev/null @@ -1,620 +0,0 @@ -import { describe, expect, test } from "bun:test"; - -import { - AuthorityOutcomeUnknownError, - applyCommittedMutation, - interactionSchema, - validateSessionSnapshot, -} from "../src/contract"; -import type { - AuthorityOperation, - CommittedMutation, - InteractionResolution, - ProtocolAdmissionLimits, - Run, - SessionSnapshot, -} from "../src/contract"; -import { - OPENAI_APP_SERVER_MCP_ELICITATION_EXTENSION, - OpenAiContractAdapter, - type OpenAiAuthorityUpdate, -} from "../src/runtimes/openai/contract-adapter"; -import type { ContractPreviewUpdate } from "../src/runtimes/contract-projection"; - -const SESSION_ID = protocolId(1); -const RUN_ID = protocolId(2); -const COMMAND_ID = protocolId(3); -const THREAD_ID = "thread-1"; -const TURN_ID = "turn-1"; - -function protocolId(value: number): string { - return value.toString().padStart(26, "0"); -} - -function activeRun(startedAt: string): Run { - return { - id: RUN_ID, - input: [{ text: "hello", type: "text" }], - origin: "user", - startedAt, - status: "active", - }; -} - -function createInitialSnapshot(capturedAt: string): SessionSnapshot { - return validateSessionSnapshot({ - capturedAt, - interactions: [], - items: [], - protocolVersion: 2, - revision: 0, - runs: [activeRun(capturedAt)], - session: { - capabilities: { - [OPENAI_APP_SERVER_MCP_ELICITATION_EXTENSION]: {}, - "interaction.input": {}, - "interaction.permission": {}, - "interaction.tool": {}, - "item.change": {}, - "item.plan": {}, - "item.reasoning": {}, - "item.terminal": {}, - "openai.app-server/thread-item": {}, - }, - config: [], - createdAt: capturedAt, - id: SESSION_ID, - status: "open", - updatedAt: capturedAt, - }, - }); -} - -function createHarness( - options: { - admissionLimits?: ProtocolAdmissionLimits; - holdAuthority?: boolean; - interactionTimeoutMs?: number; - maxPendingServerRequestBytes?: number; - previewCheckpointBytes?: number; - previewReplaceIntervalMs?: number; - } = {}, -) { - let nowMs = Date.parse("2026-07-16T08:00:00.000Z"); - let snapshot = createInitialSnapshot(new Date(nowMs).toISOString()); - let nextId = 100; - let rejectNextAuthorityAfterCommit = false; - const authorityEntered = Promise.withResolvers(); - const authorityGate = Promise.withResolvers(); - const authority: OpenAiAuthorityUpdate[] = []; - const committedMutationIds = new Set(); - const previews: ContractPreviewUpdate[] = []; - const commit = ( - cause: CommittedMutation["cause"], - operations: AuthorityOperation[], - mutationId = protocolId(1_000 + snapshot.revision + 1), - ): void => { - if (committedMutationIds.has(mutationId)) { - return; - } - - const revision = snapshot.revision + 1; - const mutation: CommittedMutation = { - baseRevision: snapshot.revision, - cause, - committedAt: new Date(nowMs).toISOString(), - mutationId, - operations, - revision, - sessionId: SESSION_ID, - }; - snapshot = applyCommittedMutation(snapshot, mutation); - committedMutationIds.add(mutationId); - }; - const adapter = new OpenAiContractAdapter({ - admissionLimits: options.admissionLimits, - authority: async (update) => { - authority.push(update); - if (options.holdAuthority === true) { - authorityEntered.resolve(); - await authorityGate.promise; - } - commit(update.cause, [...update.operations] as AuthorityOperation[], update.mutationId); - if (rejectNextAuthorityAfterCommit) { - rejectNextAuthorityAfterCommit = false; - throw new AuthorityOutcomeUnknownError("authority result lost"); - } - }, - createId: () => protocolId(nextId++), - interactionTimeoutMs: options.interactionTimeoutMs, - maxPendingServerRequestBytes: options.maxPendingServerRequestBytes, - now: () => new Date(nowMs), - preview: (update) => previews.push(update), - previewCheckpointBytes: options.previewCheckpointBytes, - previewReplaceIntervalMs: options.previewReplaceIntervalMs, - sessionId: SESSION_ID, - }); - - return { - adapter, - advance(milliseconds: number) { - nowMs += milliseconds; - }, - authority, - authorityEntered: authorityEntered.promise, - previews, - rejectNextAuthorityAfterCommit() { - rejectNextAuthorityAfterCommit = true; - }, - releaseAuthority() { - authorityGate.resolve(); - }, - settleInteraction(interactionId: string, resolution?: InteractionResolution) { - const interaction = snapshot.interactions.find((entry) => entry.id === interactionId); - - if (interaction === undefined || interaction.status !== "open") { - throw new Error("The test interaction must be open."); - } - - if (resolution !== undefined && resolution.kind !== interaction.kind) { - throw new Error("The test resolution kind must match the interaction kind."); - } - - const endedAt = new Date(nowMs).toISOString(); - const authoritativeResolution = - resolution?.kind === "input" && resolution.value.type === "answered" - ? { - answeredQuestionIds: Object.keys(resolution.value.answers), - type: "answered" as const, - } - : resolution?.value; - commit({ commandId: protocolId(2_000 + snapshot.revision + 1), type: "command" }, [ - { - entity: "interaction", - op: "put", - value: interactionSchema.parse( - resolution === undefined - ? { ...interaction, endedAt, status: "expired" } - : { - ...interaction, - endedAt, - resolution: authoritativeResolution, - status: "resolved", - }, - ), - }, - ]); - }, - snapshot: () => snapshot, - }; -} - -function turnAttachment() { - return { - cause: { commandId: COMMAND_ID, type: "command" as const }, - run: activeRun("2026-07-16T08:00:00.000Z"), - threadId: THREAD_ID, - turnId: TURN_ID, - }; -} - -async function registerTurn(adapter: OpenAiContractAdapter): Promise { - await adapter.attachTurn(turnAttachment()); -} - -function userInputRequest(autoResolutionMs: number | null = null) { - return { - autoResolutionMs, - itemId: "input-1", - questions: [ - { - id: "name", - isOther: false, - isSecret: false, - options: null, - question: "Name?", - }, - ], - threadId: THREAD_ID, - turnId: TURN_ID, - }; -} - -describe("OpenAI Contract adapter", () => { - test("uses an empty file-change snapshot to clear prior active changes", async () => { - const harness = createHarness(); - await registerTurn(harness.adapter); - await harness.adapter.handleNotification("item/fileChange/patchUpdated", { - changes: [{ diff: "+old", kind: { type: "add" }, path: "old.txt" }], - itemId: "change-clear", - threadId: THREAD_ID, - turnId: TURN_ID, - }); - await harness.adapter.handleNotification("item/fileChange/patchUpdated", { - changes: [], - itemId: "change-clear", - threadId: THREAD_ID, - turnId: TURN_ID, - }); - - expect(harness.snapshot().items).toContainEqual( - expect.objectContaining({ changes: [], id: "change-clear", status: "active" }), - ); - }); - - test("retains sub-agent activity input", async () => { - const harness = createHarness(); - await registerTurn(harness.adapter); - await harness.adapter.handleNotification("item/started", { - item: { - agentPath: "researcher", - agentThreadId: "child-thread", - id: "sub-agent-1", - kind: "spawn", - status: "inProgress", - type: "subAgentActivity", - }, - threadId: THREAD_ID, - turnId: TURN_ID, - }); - - expect(harness.snapshot().items).toContainEqual( - expect.objectContaining({ - input: { - agentPath: "researcher", - agentThreadId: "child-thread", - kind: "spawn", - }, - name: "sub_agent_activity", - }), - ); - }); - - test("preserves unrecognized native Item data in an Extension Item", async () => { - const harness = createHarness(); - await registerTurn(harness.adapter); - await harness.adapter.handleNotification("item/completed", { - item: { - id: "review-1", - review: "Check authentication boundaries.", - type: "enteredReviewMode", - }, - threadId: THREAD_ID, - turnId: TURN_ID, - }); - - expect(harness.snapshot().items[0]).toMatchObject({ - kind: "extension", - status: "completed", - value: { - id: "review-1", - review: "Check authentication boundaries.", - type: "enteredReviewMode", - }, - }); - }); - - test("projects interactive server requests and maps protected resolutions back to app-server", async () => { - const harness = createHarness(); - await registerTurn(harness.adapter); - await harness.adapter.handleNotification("item/started", { - item: { - aggregatedOutput: null, - command: "git status", - id: "command-1", - status: "inProgress", - type: "commandExecution", - }, - startedAtMs: Date.parse("2026-07-16T08:00:00.100Z"), - threadId: THREAD_ID, - turnId: TURN_ID, - }); - const approvalId = await harness.adapter.handleServerRequest( - "item/commandExecution/requestApproval", - 7, - { - command: "git status", - itemId: "command-1", - reason: "Needs approval", - startedAtMs: Date.parse("2026-07-16T08:00:00.200Z"), - threadId: THREAD_ID, - turnId: TURN_ID, - }, - ); - - expect(approvalId).not.toBeNull(); - expect( - interactionSchema.parse( - harness.snapshot().interactions.find((interaction) => interaction.id === approvalId), - ), - ).toMatchObject({ - itemId: "command-1", - kind: "permission", - status: "open", - }); - const approvalResolution = { - kind: "permission", - value: { optionId: "accept_session", type: "selected" }, - } satisfies InteractionResolution; - harness.settleInteraction(approvalId!, approvalResolution); - expect(harness.adapter.resolveInteraction(approvalId!, approvalResolution)).toEqual({ - id: 7, - result: { decision: "acceptForSession" }, - }); - - const restrictedApprovalId = await harness.adapter.handleServerRequest( - "item/commandExecution/requestApproval", - 70, - { - availableDecisions: ["accept", "decline"], - command: "git status", - itemId: "command-1", - reason: "Needs approval", - startedAtMs: Date.parse("2026-07-16T08:00:00.200Z"), - threadId: THREAD_ID, - turnId: TURN_ID, - }, - ); - expect( - harness - .snapshot() - .interactions.find((interaction) => interaction.id === restrictedApprovalId), - ).toMatchObject({ - request: { - options: [{ id: "accept_once" }, { id: "decline" }], - }, - }); - expect(() => - harness.adapter.resolveInteraction(restrictedApprovalId!, { - kind: "permission", - value: { optionId: "accept_session", type: "selected" }, - }), - ).toThrow("unavailable option"); - const restrictedResolution = { - kind: "permission", - value: { optionId: "accept_once", type: "selected" }, - } satisfies InteractionResolution; - harness.settleInteraction(restrictedApprovalId!, restrictedResolution); - expect(harness.adapter.resolveInteraction(restrictedApprovalId!, restrictedResolution)).toEqual( - { id: 70, result: { decision: "accept" } }, - ); - - const inputId = await harness.adapter.handleServerRequest("item/tool/requestUserInput", 8, { - autoResolutionMs: 60_000, - itemId: "command-1", - questions: [ - { - header: "Mode", - id: "mode", - isOther: true, - isSecret: false, - options: [{ description: "Fast", label: "quick" }], - question: "Which mode?", - }, - ], - threadId: THREAD_ID, - turnId: TURN_ID, - }); - const inputResolution: InteractionResolution = { - kind: "input", - value: { answers: { mode: ["0"] }, type: "answered" }, - }; - expect( - harness.snapshot().interactions.find((interaction) => interaction.id === inputId), - ).toMatchObject({ - kind: "input", - request: { - questions: [ - { - allowOther: true, - options: [{ id: "0", label: "quick" }], - }, - ], - }, - }); - harness.settleInteraction(inputId!, inputResolution); - expect(harness.adapter.resolveInteraction(inputId!, inputResolution)).toEqual({ - id: 8, - result: { answers: { mode: { answers: ["quick"] } } }, - }); - - await harness.adapter.handleNotification("item/started", { - item: { - arguments: { path: "README.md" }, - contentItems: null, - durationMs: null, - id: "call-1", - namespace: "fs", - status: "inProgress", - success: null, - tool: "read_file", - type: "dynamicToolCall", - }, - startedAtMs: Date.parse("2026-07-16T08:00:00.300Z"), - threadId: THREAD_ID, - turnId: TURN_ID, - }); - const toolId = await harness.adapter.handleServerRequest("item/tool/call", "tool-request", { - arguments: { path: "README.md" }, - callId: "call-1", - namespace: "fs", - threadId: THREAD_ID, - tool: "read_file", - turnId: TURN_ID, - }); - expect( - harness.snapshot().interactions.find((interaction) => interaction.id === toolId), - ).toMatchObject({ - itemId: "call-1", - }); - const toolResolution = { - kind: "tool", - value: { - output: [{ text: "contents", type: "text" }], - type: "completed", - }, - } satisfies InteractionResolution; - harness.settleInteraction(toolId!, toolResolution); - expect(harness.adapter.resolveInteraction(toolId!, toolResolution)).toEqual({ - id: "tool-request", - result: { - contentItems: [{ text: "contents", type: "inputText" }], - success: true, - }, - }); - await harness.adapter.handleNotification("item/completed", { - completedAtMs: Date.parse("2026-07-16T08:00:00.400Z"), - item: { - arguments: { path: "README.md" }, - contentItems: [{ text: "contents", type: "inputText" }], - durationMs: 100, - id: "call-1", - namespace: "fs", - status: "completed", - success: true, - tool: "read_file", - type: "dynamicToolCall", - }, - threadId: THREAD_ID, - turnId: TURN_ID, - }); - expect(harness.snapshot().items.find((item) => item.id === "call-1")).toMatchObject({ - kind: "tool", - name: "fs/read_file", - output: [{ text: "contents", type: "text" }], - status: "completed", - }); - - const elicitationId = await harness.adapter.handleServerRequest( - "mcpServer/elicitation/request", - "elicitation-request", - { - _meta: null, - message: "Choose a region", - mode: "form", - requestedSchema: { - properties: { region: { type: "string" } }, - required: ["region"], - type: "object", - }, - serverName: "deployment", - threadId: THREAD_ID, - turnId: TURN_ID, - }, - ); - expect( - harness.snapshot().interactions.find((interaction) => interaction.id === elicitationId), - ).toMatchObject({ - kind: "extension", - name: OPENAI_APP_SERVER_MCP_ELICITATION_EXTENSION, - request: { message: "Choose a region", mode: "form", serverName: "deployment" }, - }); - const elicitationResolution = { - kind: "extension", - name: OPENAI_APP_SERVER_MCP_ELICITATION_EXTENSION, - value: { _meta: null, action: "accept", content: { region: "eu" } }, - } satisfies InteractionResolution; - harness.settleInteraction(elicitationId!, elicitationResolution); - expect(harness.adapter.resolveInteraction(elicitationId!, elicitationResolution)).toEqual({ - id: "elicitation-request", - result: { _meta: null, action: "accept", content: { region: "eu" } }, - }); - expect( - harness.snapshot().interactions.every((interaction) => interaction.status !== "open"), - ).toBe(true); - }); - - test.each([ - { expectedMs: 500, requestedMs: 500 }, - { expectedMs: 1_000, requestedMs: 5_000 }, - { expectedMs: 1_000, requestedMs: 1.5 }, - { expectedMs: 1_000, requestedMs: null }, - ])( - "bounds provider interaction timeout $requestedMs -> $expectedMs ms", - async ({ expectedMs, requestedMs }) => { - const harness = createHarness({ interactionTimeoutMs: 1_000 }); - await registerTurn(harness.adapter); - await harness.adapter.handleServerRequest( - "item/tool/requestUserInput", - 50, - userInputRequest(requestedMs), - ); - - expect(harness.snapshot().interactions[0]?.expiresAt).toBe( - new Date(Date.parse("2026-07-16T08:00:00.000Z") + expectedMs).toISOString(), - ); - }, - ); - - test("deduplicates a pending native request before creating Authority state", async () => { - const harness = createHarness(); - await registerTurn(harness.adapter); - const before = harness.authority.length; - const first = await harness.adapter.handleServerRequest( - "item/tool/requestUserInput", - 51, - userInputRequest(), - ); - const second = await harness.adapter.handleServerRequest( - "item/tool/requestUserInput", - 51, - userInputRequest(), - ); - - expect(second).toBe(first); - expect(harness.authority).toHaveLength(before + 1); - expect(harness.snapshot().interactions).toHaveLength(1); - }); - - test("keeps a native request identity after an ambiguous Authority result", async () => { - const harness = createHarness(); - await registerTurn(harness.adapter); - const request = userInputRequest(); - harness.rejectNextAuthorityAfterCommit(); - - await expect( - harness.adapter.handleServerRequest("item/tool/requestUserInput", 52, request), - ).rejects.toThrow("result lost"); - const interactionId = harness.snapshot().interactions[0]?.id; - await expect( - harness.adapter.handleServerRequest("item/tool/requestUserInput", 52, request), - ).resolves.toBe(interactionId); - - expect(harness.snapshot().interactions).toHaveLength(1); - expect( - harness.authority - .slice(-2) - .map((update) => update.operations[0]) - .map((operation) => (operation?.op === "put" ? operation.value.id : null)), - ).toEqual([interactionId, interactionId]); - }); - - test.each([ - [ - "item identity", - (request: ReturnType) => ({ ...request, itemId: "input-2" }), - ], - [ - "question payload", - (request: ReturnType) => ({ - ...request, - questions: [{ ...request.questions[0]!, question: "Changed?" }], - }), - ], - ] as const)( - "rejects a replay that changes the pending native request %s", - async (_name, change) => { - const harness = createHarness(); - await registerTurn(harness.adapter); - const request = userInputRequest(); - await harness.adapter.handleServerRequest("item/tool/requestUserInput", 51, request); - const before = harness.authority.length; - - await expect( - harness.adapter.handleServerRequest("item/tool/requestUserInput", 51, change(request)), - ).rejects.toThrow("changed identity"); - expect(harness.authority).toHaveLength(before); - expect(harness.snapshot().interactions).toHaveLength(1); - }, - ); -}); diff --git a/tests/opencode-acp-contract.test.ts b/tests/opencode-acp-contract.test.ts index a217183..bc621ba 100644 --- a/tests/opencode-acp-contract.test.ts +++ b/tests/opencode-acp-contract.test.ts @@ -21,8 +21,8 @@ import { } from "../src/runtimes/acp/acp-configuration"; import { limitAcpInput } from "../src/runtimes/acp/acp-input-limit"; import { setupAcpSession } from "../src/runtimes/acp/acp-session-setup"; -import { createBufferedSinkLogger } from "../src/observability"; -import { exposeNativeSkillAliases } from "../src/runtimes/skill-materialization"; +import { createDisabledLogger } from "../src/observability"; +import { exposeNativeSkillAliases } from "../src/runtimes/skill-bootstrap"; import type { DriverStartInput } from "../src/protocol/start"; import { settlePromiseWithTimeout } from "../src/utils/async"; import { driverBootPayload, driverStartInput } from "./driver-boot-payload-fixture"; @@ -108,7 +108,7 @@ async function stopOpenCode( } } -test("OpenCode creates a session after unadvertised additional directories are dropped", async () => { +test("OpenCode rejects unadvertised additional directories before session creation", async () => { expect(existsSync(OPENCODE_COMMAND)).toBe(true); const root = await mkdtemp(join(tmpdir(), "agent-driver-opencode-acp-contract-")); @@ -176,6 +176,7 @@ test("OpenCode creates a session after unadvertised additional directories are d session: { ...driverStartInput.execution.session, additionalDirectories: [additionalDirectory], + context: sessionContext, cwd, homePath, sharedRootPath: cwd, @@ -184,20 +185,15 @@ test("OpenCode creates a session after unadvertised additional directories are d runtime: "acp-fallback", runtimeTransport: "acp-fallback", }; - const setup = await requestWithTimeout(connection, "OpenCode ACP session/new", () => + await expect( setupAcpSession({ agentCapabilities: initialize.agentCapabilities ?? null, connection: connection.agent, currentSessionId: null, payload, - sessionContext, replaySession: async (operation) => operation(), }), - ); - - expect(setup.mode).toBe("created"); - expect(setup.sessionId.trim().length).toBeGreaterThan(0); - expect(setup.droppedAdditionalDirectories).toEqual([additionalDirectory]); + ).rejects.toThrow("does not advertise additionalDirectories support"); } finally { await stopOpenCode(connection, child, closed); await rm(root, { force: true, recursive: true }); @@ -231,22 +227,23 @@ Check the diff.`, sharedRootPath: root, }, }; - const logger = createBufferedSinkLogger({ - level: "debug", - service: "opencode-acp-contract-test", - sink: async () => {}, - }); + const logger = createDisabledLogger(); try { - await exposeNativeSkillAliases(execution, logger, [ - { - mountPath, - skillId: "skill-1", - skillMarkdownPath, - skillName: "review", - snapshotId: "snapshot-1", - }, - ]); + await exposeNativeSkillAliases( + execution, + logger, + [ + { + mountPath, + skillId: "skill-1", + skillMarkdownPath, + skillName: "review", + snapshotId: "snapshot-1", + }, + ], + new AbortController().signal, + ); const expectedSkillPath = join(await realpath(root), ".agents", "skills", "review", "SKILL.md"); expect(discoverOpenCodeSkills(root, homePath)).toContainEqual( @@ -256,13 +253,12 @@ Check the diff.`, }), ); - await exposeNativeSkillAliases(execution, logger, []); + await exposeNativeSkillAliases(execution, logger, [], new AbortController().signal); expect(discoverOpenCodeSkills(root, homePath).some((skill) => skill.name === "review")).toBe( false, ); } finally { - await logger.destroy(); await rm(root, { force: true, recursive: true }); } }); diff --git a/tests/process-tree-watchdog.test.ts b/tests/process-tree-watchdog.test.ts index 05e76cb..09ca867 100644 --- a/tests/process-tree-watchdog.test.ts +++ b/tests/process-tree-watchdog.test.ts @@ -137,19 +137,37 @@ describe.skipIf(process.platform !== "linux")("Linux process-tree watchdog", () test.skipIf(process.getuid?.() === 0)( "keeps a newer unreadable process indeterminate", async () => { - const marker = createProcessTreeEnvironment(process.env).marker; + const processTree = createProcessTreeEnvironment(process.env); + const root = spawn("sleep", ["30"], { env: processTree.env }); + expect(root.pid).toBeDefined(); + processIds.add(root.pid!); + bindSpawnedProcess(root, process.platform, processTree); + root.kill("SIGKILL"); + await expectExited(root.pid!); const unreadable = await spawnNonDumpableProcess(); expect(() => readFileSync(`/proc/${unreadable.pid}/environ`)).toThrow(); - await expect(waitForLinuxProcessMarkerExit(marker, 100)).rejects.toThrow( + await expect(waitForLinuxProcessMarkerExit(processTree.marker, 100)).rejects.toThrow( "Supervised process tree did not exit", ); process.kill(unreadable.pid!, "SIGKILL"); await expectExited(unreadable.pid!); - await expect(waitForLinuxProcessMarkerExit(marker)).resolves.toBeUndefined(); + await expect(waitForLinuxProcessMarkerExit(processTree.marker)).resolves.toBeUndefined(); + releaseLinuxProcessMarker(processTree.marker); }, ); + test("does not reserve process state until a root is bound", async () => { + const processTrees = Array.from({ length: 64 }, () => + createProcessTreeEnvironment(process.env), + ); + + for (const processTree of processTrees) { + expect(spawnLinuxProcessTreeWatchdog(process.pid, processTree.marker)).toBeNull(); + await expect(waitForLinuxProcessMarkerExit(processTree.marker, 1)).resolves.toBeUndefined(); + } + }); + test("keeps a confirmed process after it clears the marker environment", async () => { const processTree = createProcessTreeEnvironment(process.env); const child = spawn("/bin/sh", ["-c", "kill -STOP $$; exec env -i /bin/sleep 30"], { @@ -159,6 +177,7 @@ describe.skipIf(process.platform !== "linux")("Linux process-tree watchdog", () }); expect(child.pid).toBeDefined(); processIds.add(child.pid!); + bindSpawnedProcess(child, process.platform, processTree); const stoppedDeadline = Date.now() + 3_000; let stopped = false; while (Date.now() < stoppedDeadline) { @@ -183,6 +202,7 @@ describe.skipIf(process.platform !== "linux")("Linux process-tree watchdog", () expect(signalLinuxProcessMarker(processTree.marker, "SIGKILL")).toBe(true); await waitForLinuxProcessMarkerExit(processTree.marker); await expectExited(child.pid!); + releaseLinuxProcessMarker(processTree.marker); }); test("rejects raw signals after the bound root identity changes", async () => { @@ -209,18 +229,17 @@ describe.skipIf(process.platform !== "linux")("Linux process-tree watchdog", () await expectExited(child.pid!); }); - test("does not trust a watchdog root without the marker", async () => { + test("does not supervise a root before marker ownership is bound", async () => { const marker = createProcessTreeEnvironment(process.env).marker; const child = spawn("sleep", ["30"]); expect(child.pid).toBeDefined(); processIds.add(child.pid!); const watchdog = spawnLinuxProcessTreeWatchdog(child.pid!, marker); - expect(watchdog).not.toBeNull(); + expect(watchdog).toBeNull(); expect(signalLinuxProcessMarker(marker, 0)).toBe(false); expect(isRunning(child.pid!)).toBe(true); child.kill("SIGKILL"); - await watchdog!.cleanup; await expectExited(child.pid!); }); diff --git a/tests/protocol-id.test.ts b/tests/protocol-id.test.ts new file mode 100644 index 0000000..54f439b --- /dev/null +++ b/tests/protocol-id.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "bun:test"; + +import { + createDriverIdFromBytes, + driverIdTimeMs, + isDriverId, + normalizeDriverId, + parseDriverId, +} from "../src/protocol/id"; +import { createRuntimeAssistantMessageId } from "../src/runtimes/runtime-turn-transcript"; +import { DRIVER_TEST_IDS } from "./driver-boot-payload-fixture"; + +describe("Driver IDs", () => { + test("accepts the full ULID range and canonicalizes lowercase input", () => { + const maximum = "7ZZZZZZZZZZZZZZZZZZZZZZZZZ"; + + expect(parseDriverId(maximum)).toBe(maximum); + expect(normalizeDriverId(maximum.toLowerCase())).toBe(maximum); + expect(isDriverId(maximum)).toBe(true); + }); + + test.each(["80000000000000000000000000", "Z0000000000000000000000000"])( + "rejects an overflowing ULID %s", + (value) => { + expect(() => parseDriverId(value)).toThrow("must be a valid ULID"); + expect(isDriverId(value)).toBe(false); + }, + ); + + test("encodes deterministic 128-bit identities as canonical Driver IDs", () => { + expect(createDriverIdFromBytes(new Uint8Array(16))).toBe("00000000000000000000000000"); + expect(createDriverIdFromBytes(new Uint8Array(16).fill(0xff))).toBe( + "7ZZZZZZZZZZZZZZZZZZZZZZZZZ", + ); + expect(driverIdTimeMs(parseDriverId("7ZZZZZZZZZ0000000000000000"))).toBe(281_474_976_710_655); + expect(() => createDriverIdFromBytes(new Uint8Array(15))).toThrow("exactly 16 bytes"); + }); + + test("domain-separates deterministic runtime message identities", () => { + const key = 'item:["a","b:c"]'; + const messageId = createRuntimeAssistantMessageId( + DRIVER_TEST_IDS.sessionId, + "openai-message", + key, + ); + + expect(messageId).toBe("10DF94GHQDTF5928TMBEZSWHQ4"); + expect( + createRuntimeAssistantMessageId(DRIVER_TEST_IDS.sessionId, "openai-reasoning", key), + ).not.toBe(messageId); + expect( + createRuntimeAssistantMessageId(DRIVER_TEST_IDS.sessionId, "openai-message", `${key}:next`), + ).not.toBe(messageId); + }); +}); diff --git a/tests/provider-fixture-test-helpers.ts b/tests/provider-fixture-test-helpers.ts new file mode 100644 index 0000000..632acb4 --- /dev/null +++ b/tests/provider-fixture-test-helpers.ts @@ -0,0 +1,179 @@ +import { readFileSync } from "node:fs"; + +import type { DriverEventInput } from "../src/protocol/events"; +import { isDriverId } from "../src/protocol/id"; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function readProviderFixture( + path: string, + required: { readonly arrays: readonly string[]; readonly strings?: readonly string[] }, +): T { + const value: unknown = JSON.parse(readFileSync(new URL(path, import.meta.url), "utf8")); + + if ( + !isRecord(value) || + required.arrays.some((field) => !Array.isArray(value[field])) || + required.strings?.some((field) => typeof value[field] !== "string") === true + ) { + throw new TypeError(`Provider fixture ${path} is malformed.`); + } + + return value as T; +} + +type ProviderFixtureKind = "acp" | "claude" | "openai"; + +function collectDriverIds( + value: unknown, + aliases: Map, + provider: ProviderFixtureKind, + fieldName?: string, +): void { + if (typeof value === "string") { + const candidates = + provider === "openai" && fieldName === "sourceEventId" ? value.split(":") : [value]; + + for (const candidate of candidates) { + if (isDriverId(candidate) && !aliases.has(candidate)) { + aliases.set( + candidate, + aliases.size === 0 ? "" : ``, + ); + } + } + return; + } + + if (Array.isArray(value)) { + for (const entry of value) { + collectDriverIds(entry, aliases, provider); + } + return; + } + + if (isRecord(value)) { + for (const [key, entry] of Object.entries(value)) { + collectDriverIds(entry, aliases, provider, key); + } + } +} + +function normalizeValue( + value: unknown, + aliases: ReadonlyMap, + provider: ProviderFixtureKind, + fieldName?: string, +): unknown { + if (typeof value === "string") { + if (provider === "claude") { + for (const [driverId, alias] of aliases) { + if (value === driverId || value.startsWith(`${driverId}:`)) { + return `${alias}${value.slice(driverId.length)}`; + } + } + } else { + const alias = aliases.get(value); + if (alias !== undefined) { + return alias; + } + } + + if ( + fieldName?.endsWith("At") === true && + value.endsWith("Z") && + !Number.isNaN(Date.parse(value)) + ) { + return ""; + } + + return provider === "openai" && fieldName === "sourceEventId" + ? value + .split(":") + .map((part) => aliases.get(part) ?? part) + .join(":") + : value; + } + + if (Array.isArray(value)) { + return value.map((entry) => normalizeValue(entry, aliases, provider)); + } + + if (!isRecord(value)) { + return value; + } + + const entries = Object.entries(value); + return Object.fromEntries( + provider !== "openai" + ? entries.flatMap(([key, entry]) => + entry === undefined ? [] : [[key, normalizeValue(entry, aliases, provider, key)]], + ) + : entries.map(([key, entry]) => [key, normalizeValue(entry, aliases, provider, key)]), + ); +} + +function normalizeProviderEvents( + events: readonly DriverEventInput[], + provider: ProviderFixtureKind, +): Record[] { + const aliases = new Map(); + + for (const event of events) { + if (provider === "acp") { + const payload = isRecord(event.payload) ? event.payload : null; + const messageId = payload?.["messageId"]; + + if ( + event.kind === "message.started" && + payload?.["role"] === "agent" && + typeof messageId === "string" && + !aliases.has(messageId) + ) { + aliases.set(messageId, `assistant-message-${aliases.size + 1}`); + } + } else { + collectDriverIds(event, aliases, provider); + } + } + + return events.map((event) => { + if (provider !== "openai") { + return normalizeValue(event, aliases, provider) as Record; + } + + const eventRecord = event as unknown as Record; + const normalized: Record = { + kind: event.kind, + payload: normalizeValue(event.payload, aliases, provider), + }; + + for (const field of ["delivery", "native", "runId", "sourceEventId", "visibility"] as const) { + if (eventRecord[field] !== undefined) { + normalized[field] = normalizeValue(eventRecord[field], aliases, provider, field); + } + } + + return normalized; + }); +} + +export function normalizeAcpProviderEvents( + events: readonly DriverEventInput[], +): Record[] { + return normalizeProviderEvents(events, "acp"); +} + +export function normalizeClaudeProviderEvents( + events: readonly DriverEventInput[], +): Record[] { + return normalizeProviderEvents(events, "claude"); +} + +export function normalizeOpenAiProviderEvents( + events: readonly DriverEventInput[], +): Record[] { + return normalizeProviderEvents(events, "openai"); +} diff --git a/tests/provider-registry.test.ts b/tests/provider-registry.test.ts index b54fb32..60eb039 100644 --- a/tests/provider-registry.test.ts +++ b/tests/provider-registry.test.ts @@ -6,7 +6,6 @@ import { createDriverStartInputFromBootPayload } from "../src/protocol/start"; import { AGENT_DRIVER_PROVIDER_REGISTRY, createAgentDriverProviderCapabilities, - createAgentDriverProviderRegistry, } from "../src/runtimes/provider-registry"; import { driverBootPayload } from "./driver-boot-payload-fixture"; @@ -79,39 +78,18 @@ describe("provider registry", () => { { id: "input_start", status: "supported", version: 1 }, { id: "permission_request", status: "unsupported", version: 1 }, { id: "session_stop", status: "supported", version: 1 }, - { id: "thinking_stream", status: "unsupported", version: 1 }, + { id: "thinking_stream", status: "supported", version: 1 }, ]), ); }); - test("declares provider host port requirements", () => { - expect( - AGENT_DRIVER_PROVIDER_REGISTRY.list().map((provider) => ({ - id: provider.id, - requiredHostPorts: provider.requiredHostPorts, - })), - ).toEqual([ - { - id: "openai-app-server", - requiredHostPorts: ["event_sink", "permission", "mcp", "skill"], - }, - { - id: "claude-agent-sdk", - requiredHostPorts: ["event_sink", "permission", "mcp", "skill"], - }, - { - id: "acp-fallback", - requiredHostPorts: ["event_sink", "permission", "mcp", "skill", "file", "host_integration"], - }, - ]); - }); - test("fails fast when no provider owns the transport", () => { - const registry = createAgentDriverProviderRegistry([]); - - expect(() => registry.createBackend(startInputFor("openai-app-server"))).toThrow( - "Unsupported runtime transport: openai-app-server.", - ); + expect(() => + AGENT_DRIVER_PROVIDER_REGISTRY.createBackend({ + ...startInputFor("openai-app-server"), + runtimeTransport: "unknown" as DriverRuntimeTransport, + }), + ).toThrow("Unsupported runtime transport: unknown."); }); test("fails fast when the start input runtime does not match the provider transport", () => { @@ -194,16 +172,4 @@ describe("provider registry", () => { ).toBe(input.runtime); }, ); - - test("rejects duplicate provider transports", () => { - const [provider] = AGENT_DRIVER_PROVIDER_REGISTRY.list(); - - if (!provider) { - throw new Error("Expected provider fixture."); - } - - expect(() => createAgentDriverProviderRegistry([provider, provider])).toThrow( - "Runtime transport openai-app-server is already registered by provider openai-app-server.", - ); - }); }); diff --git a/tests/public-api-consumer.ts b/tests/public-api-consumer.ts index 2104240..eb6f3dc 100644 --- a/tests/public-api-consumer.ts +++ b/tests/public-api-consumer.ts @@ -1,25 +1,16 @@ -import type { +export type { AgentDriverBackend, AgentDriverKernel, CmaStore, DriverStartInput, } from "@mosoo/agent-driver"; -import type { SessionSnapshot } from "@mosoo/agent-driver/contract"; -import type { CmaHttpHandler } from "@mosoo/agent-driver/cma-http"; -import type { CmaSdkClient } from "@mosoo/agent-driver/cma-sdk"; -import type { DriverEventInput } from "@mosoo/agent-driver/events"; +export type { DriverBootPayload } from "@mosoo/agent-driver/boot"; +export type { SessionSnapshot } from "@mosoo/agent-driver/contract"; +export type { CmaHttpHandler } from "@mosoo/agent-driver/cma-http"; +export type { CmaSdkClient } from "@mosoo/agent-driver/cma-sdk"; +export type { DriverEventInput } from "@mosoo/agent-driver/events"; +export type { DriverHeartbeatInput } from "@mosoo/agent-driver/orpc"; +export type { OpenAiPrivateCitationFilterResult } from "@mosoo/agent-driver/provider-output"; +export type { DriverRuntime } from "@mosoo/agent-driver/runtime"; -export interface PublicApiConsumer { - readonly backend: AgentDriverBackend; - readonly cmaClient: CmaSdkClient; - readonly cmaHandler: CmaHttpHandler; - readonly cmaStore: CmaStore; - readonly event: DriverEventInput; - readonly kernel: AgentDriverKernel; - readonly snapshot: SessionSnapshot; - readonly startInput: DriverStartInput; -} - -export function consumePublicApi(api: PublicApiConsumer): PublicApiConsumer { - return api; -} +export type SandboxMemoryPath = typeof import("@mosoo/agent-driver/paths").SANDBOX_MEMORY_PATH; diff --git a/tests/public-api-exports.snapshot.json b/tests/public-api-exports.snapshot.json deleted file mode 100644 index e9e629a..0000000 --- a/tests/public-api-exports.snapshot.json +++ /dev/null @@ -1,562 +0,0 @@ -{ - ".": { - "types": [ - "AgentDriverBackend", - "AgentDriverBackendFactory", - "AgentDriverCommandSource", - "AgentDriverContext", - "AgentDriverContextInput", - "AgentDriverContextPortOverrides", - "AgentDriverEventSink", - "AgentDriverFilePort", - "AgentDriverHostIntegrationPort", - "AgentDriverHostPortName", - "AgentDriverHostPorts", - "AgentDriverKernel", - "AgentDriverKernelCore", - "AgentDriverKernelOptions", - "AgentDriverKernelStartInput", - "AgentDriverMaterializedSkill", - "AgentDriverMcpPort", - "AgentDriverPermissionPort", - "AgentDriverProviderDescriptor", - "AgentDriverProviderRegistry", - "AgentDriverRuntimeEvent", - "AgentDriverSkillPort", - "CmaAgentRecord", - "CmaClaimInboundEventInput", - "CmaClaimInboundEventResult", - "CmaCreateAgentInput", - "CmaCreateEnvironmentInput", - "CmaCreateSessionInput", - "CmaEnvironmentConfig", - "CmaEnvironmentLimitedNetworking", - "CmaEnvironmentNetworking", - "CmaEnvironmentPackageManager", - "CmaEnvironmentPackages", - "CmaEnvironmentRecord", - "CmaEnvironmentUnrestrictedNetworking", - "CmaHttpAuthorizationContext", - "CmaHttpAuthorizer", - "CmaHttpBetaHeaderRequirement", - "CmaHttpDriverCommandDispatchInput", - "CmaHttpDriverCommandDispatcher", - "CmaHttpHandler", - "CmaHttpHandlerOptions", - "CmaInboundEvent", - "CmaInboundEventLease", - "CmaInvalidEventError", - "CmaMemoryStoreIdFactory", - "CmaMemoryStoreOptions", - "CmaOutboundEvent", - "CmaRenewInboundEventClaimInput", - "CmaSdkBetaHeader", - "CmaSdkClient", - "CmaSdkClientOptions", - "CmaSdkError", - "CmaSdkFetch", - "CmaSessionEventDispatchRecord", - "CmaSessionEventRecord", - "CmaSessionRecord", - "CmaSessionStatus", - "CmaSessionTerminatedError", - "CmaSettleInboundEventInput", - "CmaStore", - "CmaStoreConflictError", - "CmaStoreNotFoundError", - "CmaStoreResourceKind", - "CmaUnsupportedFieldError", - "CmaUserCustomToolResultEvent", - "CmaUserInterruptEvent", - "CmaUserMessageEvent", - "CmaUserToolConfirmationEvent", - "DriverCapability", - "DriverCapabilityId", - "DriverDiagnosticCode", - "DriverDiagnosticInput", - "DriverDiagnosticSeverity", - "DriverEvent", - "DriverEventEnvelope", - "DriverEventInput", - "DriverExecutionInput", - "DriverExecutionRunInput", - "DriverExecutionSessionInput", - "DriverHostIntegrationSnapshot", - "DriverId", - "DriverInstanceId", - "DriverNativeRuntimeRef", - "DriverNativeRuntimeRefKind", - "DriverRuntime", - "DriverRuntimeTransport", - "DriverStartInput", - "EventId", - "InputStartCommand", - "InputStartCommandResult", - "McpExecuteCommand", - "McpExecuteCommandResult", - "MessageId", - "PermissionResolveCommand", - "RunError", - "RunId", - "RuntimeCommand", - "RuntimeCommandInput", - "RuntimeCommandResult", - "RuntimeCommandStatus", - "SemanticDriverId", - "SessionId", - "SessionStopCommand", - "TurnCancelCommand" - ], - "values": [ - "AGENT_DRIVER_PROVIDER_REGISTRY", - "AgentDriverKernelCore", - "CMA_DEFAULT_BETA_HEADER_NAME", - "CMA_DEFAULT_BETA_HEADER_VALUE", - "CmaInvalidEventError", - "CmaSdkError", - "CmaSessionTerminatedError", - "CmaStoreConflictError", - "CmaStoreNotFoundError", - "CmaUnsupportedFieldError", - "DRIVER_ID_INPUT_PATTERN", - "DRIVER_ID_PATTERN", - "OPENAI_DEFAULT_MODEL_ID", - "SUPPORTED_DRIVER_NATIVE_RUNTIME_REF_KINDS", - "SUPPORTED_DRIVER_RUNTIMES", - "SUPPORTED_DRIVER_RUNTIME_TRANSPORTS", - "createAgentDriverContext", - "createAgentDriverProviderCapabilities", - "createAgentDriverProviderRegistry", - "createCmaHttpHandler", - "createCmaMemoryStore", - "createCmaSdkClient", - "createDriverDiagnosticEvent", - "createDriverId", - "getExpectedDriverNativeRuntimeRefKind", - "isDriverId", - "isSupportedDriverRuntime", - "isSupportedDriverRuntimeTransport", - "normalizeDriverId", - "parseCmaInboundEvent", - "parseDriverEventEnvelope", - "parseDriverId", - "parseDriverNativeRuntimeRef", - "parseRuntimeCommand", - "projectCmaInboundToDriverCommand", - "projectDriverEventToCma", - "pushDriverDiagnosticEvent" - ] - }, - "./boot": { - "types": [ - "AccountId", - "AgentDeploymentVersionId", - "AgentId", - "AuthorizedDriverBootMcpServer", - "CredentialId", - "DriverBootMcpServer", - "DriverBootPayload", - "DriverBuiltInToolConfig", - "DriverBuiltInToolName", - "DriverConfigRevision", - "DriverExecutionEnvironment", - "DriverExecutionSessionContext", - "DriverExecutionSessionSpec", - "DriverExecutionSpec", - "DriverNativeRuntimeRef", - "DriverNativeRuntimeRefKind", - "DriverOrigin", - "DriverPermissionPolicy", - "DriverRecoveryMessage", - "DriverResolvedSkill", - "DriverRuntime", - "DriverRuntimeTransport", - "DriverSkillCatalogEntry", - "DriverSkillCatalogFrontmatterSummary", - "EnvironmentId", - "EnvironmentRevisionId", - "McpServerId", - "SandboxId", - "SandboxSessionId", - "SkillId", - "SkillSnapshotId", - "UnavailableDriverBootMcpServer" - ], - "values": [ - "DEFAULT_DRIVER_PERMISSION_POLICY", - "DRIVER_BOOT_PAYLOAD_ENV_NAME", - "DRIVER_BOOT_PAYLOAD_FILE_ENV_NAME", - "DRIVER_CONTROL_PORT_MAX", - "DRIVER_CONTROL_PORT_MIN", - "DRIVER_PROTOCOL_VERSION", - "SUPPORTED_DRIVER_NATIVE_RUNTIME_REF_KINDS", - "SUPPORTED_DRIVER_RUNTIMES", - "SUPPORTED_DRIVER_RUNTIME_TRANSPORTS", - "isSupportedDriverRuntime", - "isSupportedDriverRuntimeTransport", - "parseDriverBootPayload", - "parseDriverBootPayloadJson" - ] - }, - "./cma-http": { - "types": [ - "CmaHttpAuthorizationContext", - "CmaHttpAuthorizer", - "CmaHttpBetaHeaderRequirement", - "CmaHttpDriverCommandDispatchInput", - "CmaHttpDriverCommandDispatcher", - "CmaHttpHandler", - "CmaHttpHandlerOptions" - ], - "values": [ - "CMA_DEFAULT_BETA_HEADER_NAME", - "CMA_DEFAULT_BETA_HEADER_VALUE", - "createCmaHttpHandler" - ] - }, - "./cma-sdk": { - "types": [ - "CmaSdkBetaHeader", - "CmaSdkClient", - "CmaSdkClientOptions", - "CmaSdkError", - "CmaSdkFetch", - "CmaSessionEventDispatchRecord" - ], - "values": ["CmaSdkError", "createCmaSdkClient"] - }, - "./contract": { - "types": [ - "AppendPreview", - "ArtifactItem", - "Audience", - "AuthorityOperation", - "AuthorityOutcomeUnknownError", - "BlobManifestEntry", - "BlobRef", - "BlobRefContent", - "BlobReferenceKey", - "Capabilities", - "CapabilityName", - "ChangeItem", - "CleanupObligation", - "Command", - "CommandGetParams", - "CommandKind", - "CommandReceipt", - "CommandRecord", - "CommandStatus", - "CommittedMutation", - "ConfigOption", - "ContentBlock", - "ContractInvariantCode", - "ContractInvariantError", - "CoreCapability", - "CoreMethod", - "CoreResourceKind", - "ExecutorAttachParams", - "ExecutorAttachResult", - "ExecutorCommandParams", - "ExecutorCommandResultParams", - "ExecutorMutateParams", - "ExecutorPreviewSubmission", - "ExecutorRenewParams", - "ExtensionContent", - "ExtensionInteraction", - "ExtensionItem", - "Extensions", - "FileChange", - "Implementation", - "InitializeParams", - "InitializeResult", - "InlineBlobContent", - "InputInteraction", - "InputQuestion", - "InputResolution", - "Interaction", - "InteractionKind", - "InteractionResolution", - "Item", - "ItemKind", - "ItemStatus", - "JsonContent", - "JsonObject", - "JsonRpcError", - "JsonRpcId", - "JsonRpcNotification", - "JsonRpcRequest", - "JsonRpcSuccess", - "JsonValue", - "Lease", - "LeaseFence", - "MessageItem", - "MutationCause", - "MutationReceipt", - "MutationSync", - "PeerLimits", - "PermissionInteraction", - "PermissionOption", - "PermissionResolution", - "PlanEntry", - "PlanItem", - "PreviewApplyResult", - "PreviewBatch", - "PreviewBuffer", - "PreviewBufferOptions", - "PreviewStreamState", - "PreviewUpdate", - "ProposedMutation", - "ProtocolAdmissionLimits", - "ProtocolError", - "ProtocolLimits", - "Provenance", - "PutOperation", - "ReasoningItem", - "RemoveOperation", - "ReplacePreview", - "RequestDigest", - "ResourceKey", - "ResourceKind", - "ResourceLinkContent", - "Run", - "RunStatus", - "Session", - "SessionActivity", - "SessionSnapshot", - "SnapshotSync", - "SubscribeParams", - "SubscribeResult", - "SubscriptionUpdate", - "SyncAdmissionLimits", - "SyncPayload", - "TerminalItem", - "TextContent", - "TokenUsage", - "ToolInteraction", - "ToolItem", - "ToolResolution", - "UnsubscribeParams" - ], - "values": [ - "AuthorityOutcomeUnknownError", - "COMMAND_KINDS", - "CORE_CAPABILITIES", - "CORE_METHODS", - "CORE_PREVIEW_CHANNELS", - "ContractInvariantError", - "JSON_RPC_ERROR_CODES", - "PROTOCOL_VERSION", - "appendPreviewSchema", - "applyCommittedMutation", - "applyPreviewUpdate", - "applySyncPayload", - "artifactItemSchema", - "assertFrameAdmission", - "assertProtocolAdmission", - "audienceSchema", - "authorityContent", - "authorityOperationSchema", - "blobManifestEntrySchema", - "blobRefContentSchema", - "blobRefSchema", - "blobReferenceKeySchema", - "capabilitiesSchema", - "capabilityNameSchema", - "changeItemSchema", - "cleanupObligationSchema", - "coalescePreviewUpdates", - "commandGetParamsSchema", - "commandKindSchema", - "commandReceiptSchema", - "commandRecordSchema", - "commandSchema", - "commandStatusSchema", - "committedMutationSchema", - "compareTimestamps", - "configOptionSchema", - "contentBlockSchema", - "contentExtensionNames", - "coreCapabilitySchema", - "coreMethodSchema", - "corePreviewChannelSchema", - "coreResourceKindSchema", - "createPreviewBuffer", - "deriveSessionActivity", - "executorAttachParamsSchema", - "executorAttachResultSchema", - "executorCommandParamsSchema", - "executorCommandResultParamsSchema", - "executorMutateParamsSchema", - "executorPreviewSubmissionSchema", - "executorRenewParamsSchema", - "extensionContentSchema", - "extensionInteractionSchema", - "extensionItemSchema", - "extensionNameSchema", - "extensionsSchema", - "fileChangeSchema", - "hasBlobRef", - "implementationSchema", - "initializeParamsSchema", - "initializeResultSchema", - "inlineBlobContentSchema", - "inputInteractionSchema", - "inputQuestionSchema", - "inputResolutionSchema", - "interactionResolutionSchema", - "interactionSchema", - "itemSchema", - "itemStatusSchema", - "jsonByteLength", - "jsonContentSchema", - "jsonObjectSchema", - "jsonRpcErrorSchema", - "jsonRpcIdSchema", - "jsonRpcNotificationSchema", - "jsonRpcRequestSchema", - "jsonRpcSuccessSchema", - "jsonValueSchema", - "leaseFenceSchema", - "leaseSchema", - "messageItemSchema", - "methodSchemas", - "mutationCauseSchema", - "mutationReceiptSchema", - "mutationSyncSchema", - "normalizeExecutorMutation", - "opaqueIdSchema", - "peerLimitsSchema", - "permissionInteractionSchema", - "permissionOptionSchema", - "permissionResolutionSchema", - "planEntrySchema", - "planItemSchema", - "previewBatchSchema", - "previewChannelSchema", - "previewUpdateSchema", - "proposedMutationSchema", - "protocolErrorSchema", - "protocolIdSchema", - "protocolLimitsSchema", - "protocolVersionSchema", - "provenanceSchema", - "putOperationSchema", - "reasoningItemSchema", - "removeOperationSchema", - "replacePreviewSchema", - "requestDigestSchema", - "resourceKeySchema", - "resourceKindSchema", - "resourceLinkContentSchema", - "revisionSchema", - "runSchema", - "sessionSchema", - "sessionSnapshotSchema", - "sha256Schema", - "snapshotSyncSchema", - "subscribeParamsSchema", - "subscribeResultSchema", - "subscriptionUpdateSchema", - "syncPayloadSchema", - "terminalItemSchema", - "textContentSchema", - "timestampSchema", - "tokenUsageSchema", - "toolInteractionSchema", - "toolItemSchema", - "toolLocationSchema", - "toolResolutionSchema", - "unsubscribeParamsSchema", - "validateCommand", - "validateExecutorMutation", - "validatePreviewBatch", - "validateSessionSnapshot" - ] - }, - "./events": { - "types": ["DriverEvent", "DriverEventEnvelope", "DriverEventInput"], - "values": ["parseDriverEventEnvelope"] - }, - "./orpc": { - "types": [ - "DriverCommandUpdateInput", - "DriverCompletionInput", - "DriverEventBatchInput", - "DriverEventBatchOutput", - "DriverEventReceipt", - "DriverExternalToolEffectClaimInput", - "DriverExternalToolEffectClaimOutput", - "DriverExternalToolEffectCompleteInput", - "DriverExternalToolEffectUnknownInput", - "DriverFailureInput", - "DriverHeartbeatInput", - "DriverHeartbeatOutput", - "DriverHelloInput", - "DriverHelloOutput", - "DriverLogBatchInput", - "DriverLogBatchOutput", - "DriverLogContext", - "DriverLogEntry", - "DriverLogError", - "DriverNextCommandInput", - "DriverNextCommandOutput", - "DriverReadyInput", - "DriverRpcOptions", - "DriverRuntimeClient" - ], - "values": [ - "parseDriverCommandUpdateInput", - "parseDriverCompletionInput", - "parseDriverEventBatchInput", - "parseDriverFailureInput", - "parseDriverHeartbeatInput", - "parseDriverHelloInput", - "parseDriverLogBatchInput", - "parseDriverNextCommandInput", - "parseDriverReadyInput" - ] - }, - "./paths": { - "types": ["SandboxFileBrowserPathPurpose"], - "values": [ - "SANDBOX_CACHE_PATH", - "SANDBOX_MEMORY_PATH", - "SANDBOX_ORGANIZATION_ROOT", - "SANDBOX_SESSION_ROOT", - "SANDBOX_SESSION_STATE_DIR", - "SANDBOX_WORKSPACE_ROOT", - "getSessionOrganizationPath", - "getSessionResourceRootPath", - "getSessionRuntimeStatePath", - "getSessionStateRootPath", - "getSessionWorkspacePath", - "isSandboxCachePath", - "isSandboxMemoryPath", - "isSandboxOrganizationPath", - "isSandboxSessionPath", - "isSandboxSessionStatePath", - "normalizeSandboxFileBrowserPath" - ] - }, - "./provider-output": { - "types": ["OpenAiPrivateCitationFilterResult", "OpenAiPrivateCitationStreamFilter"], - "values": ["OpenAiPrivateCitationStreamFilter", "filterOpenAiPrivateCitations"] - }, - "./runtime": { - "types": [ - "DriverNativeRuntimeRef", - "DriverNativeRuntimeRefKind", - "DriverRuntime", - "DriverRuntimeTransport" - ], - "values": [ - "SUPPORTED_DRIVER_NATIVE_RUNTIME_REF_KINDS", - "SUPPORTED_DRIVER_RUNTIMES", - "SUPPORTED_DRIVER_RUNTIME_TRANSPORTS", - "getExpectedDriverNativeRuntimeRefKind", - "isSupportedDriverRuntime", - "isSupportedDriverRuntimeTransport", - "parseDriverNativeRuntimeRef" - ] - } -} diff --git a/tests/public-api-exports.test.ts b/tests/public-api-exports.test.ts deleted file mode 100644 index 710bd84..0000000 --- a/tests/public-api-exports.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { expect, test } from "bun:test"; -import { readFileSync, writeFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -import * as ts from "typescript"; - -const repositoryRoot = fileURLToPath(new URL("../", import.meta.url)); -const snapshotPath = resolve(repositoryRoot, "tests/public-api-exports.snapshot.json"); - -interface PackageManifest { - readonly exports: Readonly< - Record< - string, - { - readonly default: string; - } - > - >; -} - -interface EntryExports { - readonly types: readonly string[]; - readonly values: readonly string[]; -} - -type ExportSnapshot = Readonly>; - -function compilerProgram(): ts.Program { - const configPath = resolve(repositoryRoot, "tsconfig.json"); - const config = ts.readConfigFile(configPath, (path) => readFileSync(path, "utf8")); - - if (config.error !== undefined) { - throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, "\n")); - } - - const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, repositoryRoot); - return ts.createProgram(parsed.fileNames, parsed.options); -} - -function isTypeOnlyAlias(symbol: ts.Symbol): boolean { - const declarations = symbol.declarations; - - return ( - declarations !== undefined && - declarations.length > 0 && - declarations.every((declaration) => { - if (!ts.isExportSpecifier(declaration)) { - return false; - } - - return declaration.isTypeOnly || declaration.parent.parent.isTypeOnly; - }) - ); -} - -function collectEntryExports(program: ts.Program, entryPath: string): EntryExports { - const sourceFile = program.getSourceFile(entryPath); - - if (sourceFile === undefined) { - throw new Error(`Public package entry is missing from the TypeScript program: ${entryPath}.`); - } - - const checker = program.getTypeChecker(); - const moduleSymbol = checker.getSymbolAtLocation(sourceFile); - - if (moduleSymbol === undefined) { - return { types: [], values: [] }; - } - - const types = new Set(); - const values = new Set(); - - for (const exported of checker.getExportsOfModule(moduleSymbol)) { - const target = - (exported.flags & ts.SymbolFlags.Alias) === 0 ? exported : checker.getAliasedSymbol(exported); - const typeOnly = isTypeOnlyAlias(exported); - - if ((target.flags & ts.SymbolFlags.Type) !== 0) { - types.add(exported.name); - } - - if (!typeOnly && (target.flags & ts.SymbolFlags.Value) !== 0) { - values.add(exported.name); - } - } - - return { - types: [...types].toSorted(), - values: [...values].toSorted(), - }; -} - -function collectPublicExports(): ExportSnapshot { - const manifest = JSON.parse( - readFileSync(resolve(repositoryRoot, "package.json"), "utf8"), - ) as PackageManifest; - const program = compilerProgram(); - - return Object.fromEntries( - Object.entries(manifest.exports) - .map(([entry, target]): [string, EntryExports] => [ - entry, - collectEntryExports(program, resolve(repositoryRoot, target.default)), - ]) - .toSorted(([left], [right]) => left.localeCompare(right)), - ); -} - -test("public package entries preserve their complete value and type export sets", () => { - const current = collectPublicExports(); - - if (process.env["UPDATE_PUBLIC_EXPORT_SNAPSHOT"] === "1") { - writeFileSync(snapshotPath, `${JSON.stringify(current, null, 2)}\n`); - } - - const expected = JSON.parse(readFileSync(snapshotPath, "utf8")) as ExportSnapshot; - expect(current).toEqual(expected); -}); diff --git a/tests/public-api.test.ts b/tests/public-api.test.ts index e790b6e..250b9e9 100644 --- a/tests/public-api.test.ts +++ b/tests/public-api.test.ts @@ -1,57 +1,33 @@ import { describe, expect, test } from "bun:test"; -import { DRIVER_PROTOCOL_VERSION as DRIVER_PROTOCOL_VERSION_FROM_BOOT_SUBPATH } from "../src/boot"; -import { createCmaHttpHandler as createCmaHttpHandlerFromSubpath } from "../src/cma-http"; -import { createCmaSdkClient as createCmaSdkClientFromSubpath } from "../src/cma-sdk"; -import { - PROTOCOL_VERSION as PROTOCOL_VERSION_FROM_CONTRACT, - protocolVersionSchema as protocolVersionSchemaFromContract, - sessionSnapshotSchema as sessionSnapshotSchemaFromContract, -} from "../src/contract"; -import { parseDriverEventEnvelope as parseDriverEventEnvelopeFromSubpath } from "../src/events"; -import { - AGENT_DRIVER_PROVIDER_REGISTRY, - AgentDriverKernelCore, - CMA_DEFAULT_BETA_HEADER_VALUE, - CmaSdkError, - SUPPORTED_DRIVER_RUNTIMES, - createAgentDriverContext, - createDriverDiagnosticEvent, - createAgentDriverProviderCapabilities, - createCmaHttpHandler, - createCmaMemoryStore, - createCmaSdkClient, - parseDriverNativeRuntimeRef, - pushDriverDiagnosticEvent, - projectCmaInboundToDriverCommand, - projectDriverEventToCma, -} from "../src/index"; -import { - parseDriverHeartbeatInput as parseDriverHeartbeatInputFromOrpcSubpath, - parseDriverHelloInput as parseDriverHelloInputFromOrpcSubpath, - parseDriverReadyInput as parseDriverReadyInputFromOrpcSubpath, -} from "../src/orpc"; -import type { DriverHeartbeatInput as DriverHeartbeatInputFromOrpcSubpath } from "../src/orpc"; -import { SANDBOX_MEMORY_PATH as SANDBOX_MEMORY_PATH_FROM_PATHS_SUBPATH } from "../src/paths"; -import { isSupportedDriverRuntime as isSupportedDriverRuntimeFromSubpath } from "../src/runtime"; +import * as agentDriver from "@mosoo/agent-driver"; +import * as boot from "@mosoo/agent-driver/boot"; +import * as cmaHttp from "@mosoo/agent-driver/cma-http"; +import * as cmaSdk from "@mosoo/agent-driver/cma-sdk"; +import * as contract from "@mosoo/agent-driver/contract"; +import * as events from "@mosoo/agent-driver/events"; +import * as orpc from "@mosoo/agent-driver/orpc"; +import * as paths from "@mosoo/agent-driver/paths"; +import * as providerOutput from "@mosoo/agent-driver/provider-output"; +import * as runtime from "@mosoo/agent-driver/runtime"; describe("public API", () => { test("imports without starting the driver process", () => { - expect(AgentDriverKernelCore).toBeFunction(); - expect(createAgentDriverContext).toBeFunction(); - expect(createDriverDiagnosticEvent).toBeFunction(); - expect(createAgentDriverProviderCapabilities).toBeFunction(); - expect(createCmaHttpHandler).toBeFunction(); - expect(createCmaMemoryStore).toBeFunction(); - expect(createCmaSdkClient).toBeFunction(); - expect(CmaSdkError).toBeFunction(); - expect(CMA_DEFAULT_BETA_HEADER_VALUE).toBe("managed-agents-2026-04-01"); - expect(projectCmaInboundToDriverCommand).toBeFunction(); - expect(projectDriverEventToCma).toBeFunction(); - expect(pushDriverDiagnosticEvent).toBeFunction(); - expect(parseDriverNativeRuntimeRef).toBeFunction(); - expect(AGENT_DRIVER_PROVIDER_REGISTRY.list()).toHaveLength(3); - expect(SUPPORTED_DRIVER_RUNTIMES).toEqual([ + expect(agentDriver.AgentDriverKernelCore).toBeFunction(); + expect(agentDriver.createAgentDriverContext).toBeFunction(); + expect(agentDriver.createDriverDiagnosticEvent).toBeFunction(); + expect(agentDriver.createAgentDriverProviderCapabilities).toBeFunction(); + expect(agentDriver.createCmaHttpHandler).toBeFunction(); + expect(agentDriver.createCmaMemoryStore).toBeFunction(); + expect(agentDriver.CmaSdkClient).toBeFunction(); + expect(agentDriver.CmaSdkError).toBeFunction(); + expect(agentDriver.CMA_DEFAULT_BETA_HEADER_VALUE).toBe("managed-agents-2026-04-01"); + expect(agentDriver.projectCmaInboundToDriverCommand).toBeFunction(); + expect(agentDriver.projectDriverEventToCma).toBeFunction(); + expect(agentDriver.pushDriverDiagnosticEvent).toBeFunction(); + expect(agentDriver.parseDriverNativeRuntimeRef).toBeFunction(); + expect(agentDriver.AGENT_DRIVER_PROVIDER_REGISTRY.list()).toHaveLength(3); + expect(agentDriver.SUPPORTED_DRIVER_RUNTIMES).toEqual([ "openai-runtime", "claude-agent-sdk", "acp-fallback", @@ -59,29 +35,30 @@ describe("public API", () => { }); test("imports public subpath entries without process side effects", () => { - const heartbeatReason = "ping" satisfies DriverHeartbeatInputFromOrpcSubpath["reason"]; + const heartbeatReason = "ping" satisfies orpc.DriverHeartbeatInput["reason"]; - expect(DRIVER_PROTOCOL_VERSION_FROM_BOOT_SUBPATH).toBe(2); - expect(PROTOCOL_VERSION_FROM_CONTRACT).toBe(2); - expect(protocolVersionSchemaFromContract.parse(2)).toBe(2); - expect(sessionSnapshotSchemaFromContract.parse).toBeFunction(); - expect(createCmaHttpHandlerFromSubpath).toBe(createCmaHttpHandler); - expect(createCmaSdkClientFromSubpath).toBe(createCmaSdkClient); - expect(parseDriverEventEnvelopeFromSubpath).toBeFunction(); - expect(heartbeatReason).toBe("ping"); - expect(parseDriverHeartbeatInputFromOrpcSubpath({ at: "now", pid: 1, reason: "ping" })).toEqual( - { - at: "now", - pid: 1, - reason: "ping", - }, - ); + expect(boot.DRIVER_PROTOCOL_VERSION).toBe(3); + expect(contract.PROTOCOL_VERSION).toBe(3); + expect(contract.protocolVersionSchema.parse(3)).toBe(3); + expect(contract.protocolVersionSchema.safeParse(2).success).toBe(false); + expect(contract.sessionSnapshotSchema.parse).toBeFunction(); + expect(cmaHttp.createCmaHttpHandler).toBe(agentDriver.createCmaHttpHandler); + expect(cmaSdk.CmaSdkClient).toBe(agentDriver.CmaSdkClient); + expect(events.parseDriverEventEnvelope).toBeFunction(); + expect(events.toRuntimeEventInput).toBeFunction(); + expect(events.RUNTIME_EVENT_SCHEMA_VERSION).toBe("2026-08-29"); + expect(events.RUNTIME_EVENT_KINDS).toContain("agent.tasks.replaced"); + expect(orpc.parseDriverHeartbeatInput({ at: "now", pid: 1, reason: heartbeatReason })).toEqual({ + at: "now", + pid: 1, + reason: "ping", + }); expect( - parseDriverHelloInputFromOrpcSubpath({ + orpc.parseDriverHelloInput({ capabilities: [], driverVersion: "0.1.0", pid: 1, - protocolVersion: 2, + protocolVersion: boot.DRIVER_PROTOCOL_VERSION, runtime: "openai-runtime", startedAt: "now", }), @@ -90,7 +67,7 @@ describe("public API", () => { runtime: "openai-runtime", }); expect( - parseDriverReadyInputFromOrpcSubpath({ + orpc.parseDriverReadyInput({ at: "now", driverInstanceId: "driver-1", pid: 1, @@ -98,7 +75,40 @@ describe("public API", () => { ).toMatchObject({ driverInstanceId: "driver-1", }); - expect(isSupportedDriverRuntimeFromSubpath("openai-runtime")).toBe(true); - expect(SANDBOX_MEMORY_PATH_FROM_PATHS_SUBPATH).toBe("/workspace/memory"); + expect(runtime.isSupportedDriverRuntime("openai-runtime")).toBe(true); + expect(paths.SANDBOX_MEMORY_PATH).toBe("/workspace/memory"); + expect(paths.getSessionResourceRootPath("session-1")).toBe( + "/workspace/se/session-1/session-files", + ); + expect(paths.getSessionResourceBackingPath("session-1")).toBe( + "/workspace/.mosoo/session-files/session-1", + ); + expect( + paths.isSandboxSessionResourceBackingPath( + "/workspace/.mosoo/session-files/session-1/nested.txt", + ), + ).toBe(true); + expect( + paths.isSandboxSessionResourceBackingPath( + "/workspace/.mosoo/session-files-other/session-1/nested.txt", + ), + ).toBe(false); + expect( + paths.isSandboxSessionResourceBackingPath( + "/workspace/se/session-1/public/.mosoo-session-files-session-1", + ), + ).toBe(false); + expect(() => + paths.normalizeSandboxFileBrowserPath("/workspace/.mosoo/session-files/session-1/nested.txt"), + ).toThrow("Session resource backing is not visible"); + expect( + paths.normalizeSandboxFileBrowserPath( + "/workspace/se/session-1/.mosoo-session-files-session-2/nested.txt", + ), + ).toBe("/workspace/se/session-1/.mosoo-session-files-session-2/nested.txt"); + expect(providerOutput.filterOpenAiPrivateCitations("plain text")).toEqual({ + privateCitationCount: 0, + text: "plain text", + }); }); }); diff --git a/tests/remote-http-mcp-executor.test.ts b/tests/remote-http-mcp-executor.test.ts new file mode 100644 index 0000000..a3cfcb2 --- /dev/null +++ b/tests/remote-http-mcp-executor.test.ts @@ -0,0 +1,668 @@ +import { expect, spyOn, test } from "bun:test"; +import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/client"; + +import type { CredentialId, McpServerId } from "../src/protocol/boot"; +import type { DriverStartInput } from "../src/protocol/start"; +import type { McpExecuteCommand } from "../src/runtime-command"; +import { createDisabledLogger } from "../src/observability"; +import { prepareRemoteHttpMcpCommand } from "../src/runtimes/mcp/remote-http-mcp-executor"; +import { settlePromiseWithTimeout } from "../src/utils/async"; +import { DRIVER_TEST_IDS, driverStartInput } from "./driver-boot-payload-fixture"; + +const MCP_SERVER_ID = "01J00000000000000000000020" as McpServerId; +const MCP_CREDENTIAL_ID = "01J00000000000000000000021" as CredentialId; + +function payload(proxyUrl: string): DriverStartInput { + return { + ...driverStartInput, + execution: { + ...driverStartInput.execution, + session: { + ...driverStartInput.execution.session, + mcpServers: [ + { + authType: "bearer", + authorizationState: "active", + credentialId: MCP_CREDENTIAL_ID, + credentialScope: "session", + credentialStatus: "active", + name: "Test MCP", + proxyGrantId: "test-grant", + proxyUrl, + serverId: MCP_SERVER_ID, + }, + ], + }, + }, + }; +} + +function command(requestId: string): McpExecuteCommand { + return { + argumentsJson: "{}", + commandId: `command-${requestId}`, + kind: "mcp.execute", + requestId, + runId: DRIVER_TEST_IDS.runId, + serverId: MCP_SERVER_ID, + toolCallId: `tool-${requestId}`, + toolName: "lookup", + }; +} + +async function execute(proxyUrl: string, requestId: string, signal = new AbortController().signal) { + await using prepared = await prepareRemoteHttpMcpCommand( + payload(proxyUrl), + command(requestId), + signal, + createDisabledLogger(), + ); + const result = await prepared.execute({ + attempt: 1, + effectId: `effect-${requestId}`, + idempotencyKey: `key-${requestId}`, + kind: "claimed", + }); + return result; +} + +function sessionServer( + onDelete: (request: Request, attempt: number) => Response | Promise, +) { + let deleteRequests = 0; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + if (request.method === "DELETE") { + deleteRequests += 1; + return onDelete(request, deleteRequests); + } + if (request.method === "GET") { + return new Response(null, { status: 405 }); + } + + const message = (await request.json()) as { + id?: unknown; + method?: string; + params?: { protocolVersion?: string }; + }; + if (message.method === "server/discover") { + return new Response("not found", { status: 404 }); + } + if (message.method === "initialize") { + return Response.json( + { + id: message.id, + jsonrpc: "2.0", + result: { + capabilities: { tools: {} }, + protocolVersion: message.params?.protocolVersion, + serverInfo: { name: "test-mcp", version: "1" }, + }, + }, + { headers: { "mcp-session-id": "cleanup-session" } }, + ); + } + if (message.method === "notifications/initialized") { + return new Response(null, { status: 202 }); + } + if (message.method === "tools/call") { + return Response.json({ + id: message.id, + jsonrpc: "2.0", + result: { content: [{ text: "ok", type: "text" }] }, + }); + } + return new Response("unexpected request", { status: 400 }); + }, + }); + + return { + deleteRequests: () => deleteRequests, + server, + url: `http://${server.hostname}:${server.port}/mcp`, + }; +} + +function prepareSession(proxyUrl: string, requestId: string, logger = createDisabledLogger()) { + return prepareRemoteHttpMcpCommand( + payload(proxyUrl), + command(requestId), + new AbortController().signal, + logger, + ); +} + +test("classifies typed MCP HTTP failures by status", async () => { + const unauthorizedHeaders: Array = []; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + const status = Number(new URL(request.url).pathname.slice(1)); + if (status === 401) { + unauthorizedHeaders.push(request.headers.get("authorization")); + } + return new Response("failed", { status }); + }, + }); + + try { + for (const [status, message] of [ + [400, "rejected the request for lookup (HTTP 400)"], + [401, "authorization for Test MCP is no longer valid"], + [403, "rejected the credential for lookup"], + [404, "HTTP endpoint for Test MCP was not found"], + [408, "Timed out while calling MCP tool lookup"], + [409, "reported a conflict while calling lookup"], + [418, "rejected lookup with HTTP 418"], + [429, "rate limited lookup"], + [503, "failed while calling lookup (HTTP 503)"], + ] as const) { + await expect( + execute(`http://${server.hostname}:${server.port}/${status}`, `status-${status}`), + ).rejects.toThrow(message); + } + expect(unauthorizedHeaders).toEqual(["Bearer test-grant"]); + } finally { + await server.stop(true); + } +}); + +test("auto-negotiates the latest MCP era and falls back to legacy", async () => { + const idempotencyKeys: unknown[] = []; + const methods = new Map(); + const protocolHeaders: Array = []; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + if (request.method !== "POST") { + return new Response(null, { status: 405 }); + } + + const route = new URL(request.url).pathname.slice(1); + const message = (await request.json()) as { + id?: unknown; + method?: string; + params?: { + _meta?: Record; + protocolVersion?: string; + }; + }; + const method = message.method ?? "unknown"; + methods.set(route, [...(methods.get(route) ?? []), method]); + + if (route === "legacy" && method === "server/discover") { + return new Response("not found", { status: 404 }); + } + + if (method === "server/discover") { + return Response.json({ + id: message.id, + jsonrpc: "2.0", + result: { + capabilities: { tools: {} }, + supportedVersions: ["2026-07-28"], + }, + }); + } + + if (method === "initialize") { + return Response.json({ + id: message.id, + jsonrpc: "2.0", + result: { + capabilities: { tools: {} }, + protocolVersion: message.params?.protocolVersion, + serverInfo: { name: "test-mcp", version: "1" }, + }, + }); + } + + if (method === "notifications/initialized") { + return new Response(null, { status: 202 }); + } + + if (method === "tools/call") { + idempotencyKeys.push(message.params?._meta?.["io.mosoo/idempotency-key"]); + protocolHeaders.push(request.headers.get("mcp-protocol-version")); + if (route === "scope") { + return new Response("insufficient scope", { + headers: { + "www-authenticate": 'Bearer error="insufficient_scope", scope="tools:call"', + }, + status: 403, + }); + } + if (route === "mixed") { + return Response.json({ + id: message.id, + jsonrpc: "2.0", + result: { + _meta: { receipt: "private" }, + content: [ + { text: " exact text ", type: "text" }, + { data: "aA==", mimeType: "image/png", type: "image" }, + ], + isError: false, + resultType: "complete", + structuredContent: { count: 1 }, + }, + }); + } + if (route === "annotated") { + return Response.json({ + id: message.id, + jsonrpc: "2.0", + result: { + content: [ + { + _meta: { source: "provider" }, + annotations: { audience: ["assistant"] }, + text: "annotated", + type: "text", + }, + ], + resultType: "complete", + }, + }); + } + return Response.json({ + id: message.id, + jsonrpc: "2.0", + result: { + content: [{ text: route, type: "text" }], + ...(route === "modern" ? { resultType: "complete" } : {}), + }, + }); + } + + return new Response("unexpected request", { status: 400 }); + }, + }); + + try { + const baseUrl = `http://${server.hostname}:${server.port}`; + expect(await execute(`${baseUrl}/modern`, "modern")).toMatchObject({ outputText: "modern" }); + expect(await execute(`${baseUrl}/legacy`, "legacy")).toMatchObject({ outputText: "legacy" }); + const mixed = await execute(`${baseUrl}/mixed`, "mixed"); + expect(JSON.parse(mixed.outputText)).toEqual({ + content: [ + { text: " exact text ", type: "text" }, + { data: "aA==", mimeType: "image/png", type: "image" }, + ], + isError: false, + structuredContent: { count: 1 }, + }); + expect(mixed.providerReceiptJson).toBe('{"receipt":"private"}'); + expect(JSON.parse((await execute(`${baseUrl}/annotated`, "annotated")).outputText)).toEqual({ + content: [ + { + _meta: { source: "provider" }, + annotations: { audience: ["assistant"] }, + text: "annotated", + type: "text", + }, + ], + }); + await expect(execute(`${baseUrl}/scope`, "scope")).rejects.toThrow( + "credential for Test MCP lacks the access required for lookup", + ); + + expect(methods.get("modern")).toEqual(["server/discover", "tools/call"]); + expect(methods.get("mixed")).toEqual(["server/discover", "tools/call"]); + expect(methods.get("annotated")).toEqual(["server/discover", "tools/call"]); + expect(methods.get("legacy")).toEqual([ + "server/discover", + "initialize", + "notifications/initialized", + "tools/call", + ]); + expect(methods.get("scope")).toEqual(["server/discover", "tools/call"]); + expect(idempotencyKeys).toEqual([ + "key-modern", + "key-legacy", + "key-mixed", + "key-annotated", + "key-scope", + ]); + expect(protocolHeaders).toEqual([ + "2026-07-28", + expect.stringMatching(/^2025-/), + "2026-07-28", + "2026-07-28", + "2026-07-28", + ]); + } finally { + await server.stop(true); + } +}); + +test.each(["malformed-json", "oversized-content-length"] as const)( + "terminates a session exactly once after an initialize %s failure", + async (mode) => { + const deletes: Array<{ + authorization: string | null; + protocolVersion: string | null; + sessionId: string | null; + toolCallId: string | null; + }> = []; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + if (request.method === "DELETE") { + deletes.push({ + authorization: request.headers.get("authorization"), + protocolVersion: request.headers.get("mcp-protocol-version"), + sessionId: request.headers.get("mcp-session-id"), + toolCallId: request.headers.get("x-mosoo-tool-call-id"), + }); + return new Response(null, { status: 200 }); + } + if (request.method === "GET") { + return new Response(null, { status: 405 }); + } + + const message = (await request.json()) as { method?: string }; + if (message.method === "server/discover") { + return new Response("not found", { status: 404 }); + } + if (message.method === "initialize") { + return new Response( + mode === "malformed-json" ? "{" : new Uint8Array(8 * 1_024 * 1_024 + 1), + { + headers: { + "content-type": "application/json", + "mcp-session-id": `${mode}-session`, + }, + status: 200, + }, + ); + } + return new Response(null, { status: 202 }); + }, + }); + + try { + await expect( + prepareSession(`http://${server.hostname}:${server.port}/mcp`, mode), + ).rejects.toThrow( + mode === "malformed-json" ? "JSON Parse error" : "MCP response exceeds 8388608 bytes", + ); + expect(deletes).toEqual([ + { + authorization: "Bearer test-grant", + protocolVersion: LATEST_PROTOCOL_VERSION, + sessionId: `${mode}-session`, + toolCallId: `tool-${mode}`, + }, + ]); + } finally { + await server.stop(true); + } + }, +); + +test("does not interrupt a committed MCP call when the prepare signal is cancelled", async () => { + const callStarted = Promise.withResolvers(); + const releaseCall = Promise.withResolvers(); + const sessionDeleted = Promise.withResolvers(); + let cancellationNotifications = 0; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + if (request.method === "GET") { + return new Response(null, { status: 405 }); + } + + if (request.method === "DELETE") { + sessionDeleted.resolve(request.headers.get("mcp-session-id")); + return new Response(null, { status: 200 }); + } + + const message = (await request.json()) as { + id?: unknown; + method?: string; + params?: { protocolVersion?: string }; + }; + + if (message.method === "server/discover") { + return new Response("not found", { status: 404 }); + } + + if (message.method === "initialize") { + return Response.json( + { + id: message.id, + jsonrpc: "2.0", + result: { + capabilities: { tools: {} }, + protocolVersion: message.params?.protocolVersion, + serverInfo: { name: "test-mcp", version: "1" }, + }, + }, + { headers: { "mcp-session-id": "cancelled-session" } }, + ); + } + + if (message.method === "notifications/initialized") { + return new Response(null, { status: 202 }); + } + + if (message.method === "notifications/cancelled") { + cancellationNotifications += 1; + return new Response(null, { status: 202 }); + } + + if (message.method === "tools/call") { + callStarted.resolve(); + await releaseCall.promise; + return Response.json({ + id: message.id, + jsonrpc: "2.0", + result: { content: [{ text: "committed", type: "text" }] }, + }); + } + + return new Response("unexpected request", { status: 400 }); + }, + }); + + try { + const controller = new AbortController(); + const execution = execute( + `http://${server.hostname}:${server.port}/cancel`, + "cancel", + controller.signal, + ); + await callStarted.promise; + controller.abort(new Error("cancel requested")); + releaseCall.resolve(); + + await expect(execution).resolves.toMatchObject({ outputText: "committed" }); + expect(await sessionDeleted.promise).toBe("cancelled-session"); + expect(cancellationNotifications).toBe(0); + } finally { + releaseCall.resolve(); + await server.stop(true); + } +}); + +test("retries a settled MCP session termination failure once", async () => { + const harness = sessionServer( + (_request, attempt) => new Response(null, { status: attempt === 1 ? 503 : 200 }), + ); + + try { + const prepared = await prepareSession(harness.url, "cleanup-retry"); + await prepared[Symbol.asyncDispose](); + expect(harness.deleteRequests()).toBe(2); + } finally { + await harness.server.stop(true); + } +}); + +test("closes locally and reports an exhausted MCP session termination", async () => { + const harness = sessionServer(() => new Response(null, { status: 503 })); + const logger = createDisabledLogger(); + const warn = spyOn(logger, "warn"); + + try { + const prepared = await prepareSession(harness.url, "cleanup-failed", logger); + await expect(prepared[Symbol.asyncDispose]()).resolves.toBeUndefined(); + expect(harness.deleteRequests()).toBe(2); + expect(warn).toHaveBeenCalledWith( + "driver.mcp.session-termination.failed", + expect.objectContaining({ status: "failed" }), + ); + } finally { + warn.mockRestore(); + await harness.server.stop(true); + } +}); + +test("does not overlap MCP session termination after its deadline", async () => { + const releaseDelete = Promise.withResolvers(); + const harness = sessionServer(async () => { + await releaseDelete.promise; + return new Response(null, { status: 200 }); + }); + + try { + const prepared = await prepareSession(harness.url, "cleanup-timeout"); + const startedAt = performance.now(); + await prepared[Symbol.asyncDispose](); + expect(performance.now() - startedAt).toBeGreaterThanOrEqual(1_900); + expect(harness.deleteRequests()).toBe(1); + } finally { + releaseDelete.resolve(); + await harness.server.stop(true); + } +}, 10_000); + +test("joins concurrent MCP disposal into one cleanup transaction", async () => { + const harness = sessionServer(() => new Response(null, { status: 200 })); + + try { + const prepared = await prepareSession(harness.url, "cleanup-joined"); + await Promise.all([ + prepared[Symbol.asyncDispose](), + prepared[Symbol.asyncDispose](), + prepared[Symbol.asyncDispose](), + ]); + expect(harness.deleteRequests()).toBe(1); + await expect( + prepared.execute({ + attempt: 1, + effectId: "late-effect", + idempotencyKey: "late-key", + kind: "claimed", + }), + ).rejects.toThrow("can only be executed once"); + } finally { + await harness.server.stop(true); + } +}); + +test.each(["content-length", "stream"] as const)( + "rejects an oversized MCP %s response before SDK parsing", + async (mode) => { + const responseBytes = 8 * 1_024 * 1_024 + 1; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + async fetch(request) { + const message = (await request.json()) as { id?: unknown; method?: string }; + if (message.method === "server/discover") { + return Response.json({ + id: message.id, + jsonrpc: "2.0", + result: { + capabilities: { tools: {} }, + supportedVersions: ["2026-07-28"], + }, + }); + } + if (message.method === "tools/call") { + if (mode === "content-length") { + return new Response(new Uint8Array(responseBytes), { + headers: { + "content-length": String(responseBytes), + "content-type": "application/json", + }, + }); + } + + let remaining = responseBytes; + return new Response( + new ReadableStream({ + pull(controller) { + if (remaining === 0) { + controller.close(); + return; + } + const chunk = new Uint8Array(Math.min(1_024 * 1_024, remaining)); + remaining -= chunk.byteLength; + controller.enqueue(chunk); + }, + }), + { headers: { "content-type": "application/json" } }, + ); + } + return new Response("unexpected request", { status: 400 }); + }, + }); + + try { + await expect( + execute(`http://${server.hostname}:${server.port}/oversized`, `oversized-${mode}`), + ).rejects.toThrow("MCP response exceeds 8388608 bytes"); + } finally { + await server.stop(true); + } + }, + 10_000, +); + +test("does not await cancellation of an oversized Content-Length body", async () => { + let cancelCalled = false; + const fetch = spyOn(globalThis, "fetch").mockResolvedValue( + new Response( + new ReadableStream({ + cancel() { + cancelCalled = true; + return new Promise(() => {}); + }, + }), + { + headers: { + "content-length": String(8 * 1_024 * 1_024 + 1), + "content-type": "application/json", + }, + }, + ), + ); + + try { + const outcome = await settlePromiseWithTimeout( + prepareSession("https://mcp.invalid/oversized", "oversized-cancel"), + { + label: "oversized MCP Content-Length rejection", + timeoutMs: 1_000, + }, + ); + + expect(outcome.status).toBe("failed"); + if (outcome.status === "failed") { + expect(outcome.error).toBeInstanceOf(Error); + expect((outcome.error as Error).message).toContain("MCP response exceeds 8388608 bytes"); + } + expect(cancelCalled).toBe(true); + } finally { + fetch.mockRestore(); + } +}); diff --git a/tests/skill-bootstrap.test.ts b/tests/skill-bootstrap.test.ts new file mode 100644 index 0000000..d755b4e --- /dev/null +++ b/tests/skill-bootstrap.test.ts @@ -0,0 +1,484 @@ +import { describe, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + open, + readFile, + readdir, + readlink, + rename, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { FileHandle } from "node:fs/promises"; + +import type { DriverExecutionInput } from "../src/protocol/execution"; +import type { AgentDriverMaterializedSkill } from "../src/host-ports"; +import { closeFileHandles } from "../src/runtimes/atomic-file"; +import { + buildNativeRuntimeSystemPrompt, + writeNativeRuntimeSystemPrompt, + writeSkillBootstrapArtifacts, +} from "../src/runtimes/skill-bootstrap"; +import { bootPayload } from "./driver-runtime-boundary-fixtures"; + +function createExecution(root: string, systemPrompt: string): DriverExecutionInput { + return { + ...bootPayload.execution, + session: { + ...bootPayload.execution.session, + cwd: root, + homePath: root, + sharedRootPath: root, + }, + skillCatalog: [], + skills: [], + systemPrompt, + }; +} + +function createCatalogExecution(root: string): DriverExecutionInput { + return { + ...createExecution(root, ""), + skillCatalog: [ + { + frontmatter: { + author: null, + description: "Review code changes.", + version: null, + }, + mountPath: join(root, ".mosoo", "skill", "review"), + resolutionMode: "explicit", + skillId: "skill-1" as DriverExecutionInput["skillCatalog"][number]["skillId"], + skillName: "review", + }, + ], + }; +} + +function createMaterializedSkill(root: string): AgentDriverMaterializedSkill { + const mountPath = join(root, ".mosoo", "skill", "review"); + + return { + mountPath, + skillId: "skill-1", + skillMarkdownPath: join(mountPath, "SKILL.md"), + skillName: "review", + snapshotId: "snapshot-1", + }; +} + +async function interceptAtomicTemporarySync( + probePath: string, + directoryPath: string, + temporaryPrefix: string, + action: () => Promise, +): Promise<{ didRun: () => boolean; restore: () => void }> { + const probe = await open(probePath, "r"); + const prototype = Object.getPrototypeOf(probe) as { + sync(this: FileHandle): Promise; + }; + const nativeSync = prototype.sync; + let ran = false; + await probe.close(); + + prototype.sync = async function (this: FileHandle) { + await nativeSync.call(this); + if (ran) { + return; + } + let names: string[]; + try { + names = await readdir(directoryPath); + } catch { + // The watched directory may not exist until the writer creates it. + return; + } + if (names.some((name) => name.startsWith(temporaryPrefix))) { + ran = true; + await action(); + } + }; + + return { + didRun: () => ran, + restore: () => { + prototype.sync = nativeSync; + }, + }; +} + +describe("skill bootstrap", () => { + test("attempts every owned handle close when an earlier close fails", async () => { + const calls: string[] = []; + const first = { + async close() { + calls.push("first"); + throw new Error("first close failed"); + }, + } as unknown as FileHandle; + const second = { + async close() { + calls.push("second"); + }, + } as unknown as FileHandle; + + const failures = await closeFileHandles([first, second]); + + expect(calls).toEqual(["first", "second"]); + expect(failures).toHaveLength(1); + expect(failures[0]).toMatchObject({ message: "first close failed" }); + }); + + test("preserves bootstrap control sentences inside the profile prompt", () => { + const execution = createExecution( + "/workspace", + "Keep this literal.\nReply with exactly READY.", + ); + + expect(buildNativeRuntimeSystemPrompt(execution)).toBe( + [ + "Runtime context for this session.", + "", + "Agent profile prompt:", + "Keep this literal.", + "Reply with exactly READY.", + ].join("\n"), + ); + }); + + test("atomically replaces native instructions with mode 0600", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-bootstrap-")); + const path = join(root, "runtime-instructions.md"); + await writeFile(path, "old instructions\n", "utf8"); + await chmod(path, 0o644); + const oldFile = await open(path, "r"); + + try { + const execution = createExecution(root, "new instructions"); + + await expect( + writeNativeRuntimeSystemPrompt(execution, [], new AbortController().signal), + ).resolves.toBe(path); + expect(await readFile(path, "utf8")).toBe(`${buildNativeRuntimeSystemPrompt(execution)}\n`); + expect(await oldFile.readFile({ encoding: "utf8" })).toBe("old instructions\n"); + expect((await stat(path)).mode & 0o777).toBe(0o600); + expect(await readdir(root)).toEqual(["runtime-instructions.md"]); + } finally { + await oldFile.close(); + await rm(root, { force: true, recursive: true }); + } + }); + + test("does not replace native instructions after startup cancellation", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-bootstrap-")); + const path = join(root, "runtime-instructions.md"); + await writeFile(path, "previous instructions\n", "utf8"); + const controller = new AbortController(); + controller.abort(new Error("startup cancelled")); + + try { + await expect( + writeNativeRuntimeSystemPrompt( + createExecution(root, "new instructions"), + [], + controller.signal, + ), + ).rejects.toThrow("startup cancelled"); + await expect(readFile(path, "utf8")).resolves.toBe("previous instructions\n"); + await expect(readdir(root)).resolves.toEqual(["runtime-instructions.md"]); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + test("atomically replaces a native instructions symlink without changing its target", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-bootstrap-")); + const outsideRoot = await mkdtemp(join(tmpdir(), "mosoo-skill-bootstrap-outside-")); + const path = join(root, "runtime-instructions.md"); + const target = join(outsideRoot, "target.md"); + await writeFile(target, "outside contents\n", "utf8"); + await symlink(target, path, "file"); + + try { + await expect( + writeNativeRuntimeSystemPrompt( + createExecution(root, "new instructions"), + [], + new AbortController().signal, + ), + ).resolves.toBe(path); + expect(await readFile(target, "utf8")).toBe("outside contents\n"); + await expect(readlink(path)).rejects.toThrow(); + expect((await stat(path)).mode & 0o777).toBe(0o600); + expect(await readdir(root)).toEqual(["runtime-instructions.md"]); + } finally { + await rm(root, { force: true, recursive: true }); + await rm(outsideRoot, { force: true, recursive: true }); + } + }); + + test("rejects a native instructions symlink ancestor without writing through it", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-bootstrap-")); + const outsideRoot = await mkdtemp(join(tmpdir(), "mosoo-skill-bootstrap-outside-")); + const homePath = join(root, "linked-home", "runtime"); + await symlink(outsideRoot, join(root, "linked-home"), "dir"); + const baseExecution = createExecution(root, "new instructions"); + const execution = { + ...baseExecution, + session: { ...baseExecution.session, homePath }, + }; + + try { + await expect( + writeNativeRuntimeSystemPrompt(execution, [], new AbortController().signal), + ).rejects.toThrow("must be a real directory"); + await expect(readdir(outsideRoot)).resolves.toEqual([]); + } finally { + await rm(root, { force: true, recursive: true }); + await rm(outsideRoot, { force: true, recursive: true }); + } + }); + + test("rejects a native home directory replaced while instructions are written", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-bootstrap-")); + const outsideRoot = await mkdtemp(join(tmpdir(), "mosoo-skill-bootstrap-outside-")); + const homePath = join(root, "home"); + const detachedHome = join(root, "detached-home"); + await mkdir(homePath); + await writeFile(join(outsideRoot, "runtime-instructions.md"), "ATTACKER\n", "utf8"); + const baseExecution = createExecution(root, "x".repeat(8 * 1024 * 1024)); + const execution = { + ...baseExecution, + session: { ...baseExecution.session, homePath }, + }; + const interception = await interceptAtomicTemporarySync( + root, + homePath, + ".runtime-instructions.md.", + async () => { + await rename(homePath, detachedHome); + await symlink(outsideRoot, homePath, "dir"); + }, + ); + + try { + await expect( + writeNativeRuntimeSystemPrompt(execution, [], new AbortController().signal), + ).rejects.toThrow("Runtime home changed while managed files were being written"); + expect(interception.didRun()).toBe(true); + await expect(readFile(join(outsideRoot, "runtime-instructions.md"), "utf8")).resolves.toBe( + "ATTACKER\n", + ); + await expect(readFile(join(detachedHome, "runtime-instructions.md"), "utf8")).resolves.toBe( + `${buildNativeRuntimeSystemPrompt(execution)}\n`, + ); + } finally { + interception.restore(); + await rm(root, { force: true, recursive: true }); + await rm(outsideRoot, { force: true, recursive: true }); + } + }); + + test("concurrent native instruction writers never share a temporary file", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-bootstrap-")); + const first = createExecution(root, `first-${"a".repeat(2 * 1024 * 1024)}`); + const second = createExecution(root, `second-${"b".repeat(2 * 1024 * 1024)}`); + + try { + await Promise.all([ + writeNativeRuntimeSystemPrompt(first, [], new AbortController().signal), + writeNativeRuntimeSystemPrompt(second, [], new AbortController().signal), + ]); + + const contents = await readFile(join(root, "runtime-instructions.md"), "utf8"); + expect([ + `${buildNativeRuntimeSystemPrompt(first)}\n`, + `${buildNativeRuntimeSystemPrompt(second)}\n`, + ]).toContain(contents); + expect(await readdir(root)).toEqual(["runtime-instructions.md"]); + expect((await stat(join(root, "runtime-instructions.md"))).mode & 0o777).toBe(0o600); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + test("atomically replaces catalog leaf symlinks without changing their targets", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-bootstrap-")); + const outsideRoot = await mkdtemp(join(tmpdir(), "mosoo-skill-bootstrap-outside-")); + const catalogRoot = join(root, ".mosoo", "skills"); + const manifestPath = join(catalogRoot, "manifest.json"); + const readmePath = join(catalogRoot, "README.md"); + const manifestTarget = join(outsideRoot, "manifest-target.json"); + const readmeTarget = join(outsideRoot, "readme-target.md"); + await mkdir(catalogRoot, { recursive: true }); + await writeFile(manifestTarget, "outside manifest", "utf8"); + await writeFile(readmeTarget, "outside readme", "utf8"); + await symlink(manifestTarget, manifestPath, "file"); + await symlink(readmeTarget, readmePath, "file"); + + try { + await expect( + writeSkillBootstrapArtifacts( + createCatalogExecution(root), + [createMaterializedSkill(root)], + new AbortController().signal, + ), + ).resolves.toEqual({ manifestPath, readmePath }); + expect(await readFile(manifestTarget, "utf8")).toBe("outside manifest"); + expect(await readFile(readmeTarget, "utf8")).toBe("outside readme"); + await expect(readlink(manifestPath)).rejects.toThrow(); + await expect(readlink(readmePath)).rejects.toThrow(); + } finally { + await rm(root, { force: true, recursive: true }); + await rm(outsideRoot, { force: true, recursive: true }); + } + }); + + test("rejects a catalog directory replaced while bootstrap files are written", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-bootstrap-")); + const outsideRoot = await mkdtemp(join(tmpdir(), "mosoo-skill-bootstrap-outside-")); + const catalogRoot = join(root, ".mosoo", "skills"); + const detachedCatalog = join(root, ".mosoo", "detached-skills"); + await mkdir(catalogRoot, { recursive: true }); + await writeFile(join(outsideRoot, "manifest.json"), "ATTACKER", "utf8"); + await writeFile(join(outsideRoot, "README.md"), "ATTACKER", "utf8"); + const execution = createCatalogExecution(root); + execution.skillCatalog[0] = { + ...execution.skillCatalog[0]!, + frontmatter: { + ...execution.skillCatalog[0]!.frontmatter, + description: "x".repeat(8 * 1024 * 1024), + }, + }; + const interception = await interceptAtomicTemporarySync( + root, + catalogRoot, + ".manifest.json.", + async () => { + await rename(catalogRoot, detachedCatalog); + await symlink(outsideRoot, catalogRoot, "dir"); + }, + ); + + try { + await expect( + writeSkillBootstrapArtifacts( + execution, + [createMaterializedSkill(root)], + new AbortController().signal, + ), + ).rejects.toThrow("catalog root changed while managed files were being written"); + expect(interception.didRun()).toBe(true); + await expect(readFile(join(outsideRoot, "manifest.json"), "utf8")).resolves.toBe("ATTACKER"); + await expect(readFile(join(outsideRoot, "README.md"), "utf8")).resolves.toBe("ATTACKER"); + await expect(readFile(join(detachedCatalog, "manifest.json"), "utf8")).resolves.toContain( + '"skillName": "review"', + ); + } finally { + interception.restore(); + await rm(root, { force: true, recursive: true }); + await rm(outsideRoot, { force: true, recursive: true }); + } + }); + + test("rejects a catalog symlink ancestor without writing outside the session", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-bootstrap-")); + const outsideRoot = await mkdtemp(join(tmpdir(), "mosoo-skill-bootstrap-outside-")); + await mkdir(join(root, ".mosoo")); + await symlink(outsideRoot, join(root, ".mosoo", "skills"), "dir"); + + try { + await expect( + writeSkillBootstrapArtifacts( + createCatalogExecution(root), + [createMaterializedSkill(root)], + new AbortController().signal, + ), + ).rejects.toThrow("must be a real directory"); + expect(await readdir(outsideRoot)).toEqual([]); + } finally { + await rm(root, { force: true, recursive: true }); + await rm(outsideRoot, { force: true, recursive: true }); + } + }); + + test("does not expose a catalog entry that was not materialized", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-bootstrap-")); + const execution = createCatalogExecution(root); + execution.skillCatalog[0] = { + ...execution.skillCatalog[0]!, + mountPath: join(root, ".mosoo", "skill", "other"), + skillName: "other", + }; + + try { + await expect( + writeSkillBootstrapArtifacts( + execution, + [createMaterializedSkill(root)], + new AbortController().signal, + ), + ).rejects.toThrow("does not match materialized skill"); + await expect(readdir(root)).resolves.toEqual([]); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + test("does not write native instructions for an unmaterialized catalog entry", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-bootstrap-")); + const execution = createCatalogExecution(root); + + try { + await expect( + writeNativeRuntimeSystemPrompt(execution, [], new AbortController().signal), + ).rejects.toThrow("does not match materialized skill"); + await expect(readFile(join(root, "runtime-instructions.md"), "utf8")).rejects.toThrow(); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + test("removes stale managed artifacts when the runtime context becomes empty", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-bootstrap-")); + const catalogRoot = join(root, ".mosoo", "skills"); + const nativeInstructionsPath = join(root, "runtime-instructions.md"); + const nativeTemporaryPath = join( + root, + ".runtime-instructions.md.11111111-1111-4111-8111-111111111111.tmp", + ); + await mkdir(catalogRoot, { recursive: true }); + await writeFile(join(catalogRoot, "manifest.json"), "stale manifest", "utf8"); + await writeFile(join(catalogRoot, "README.md"), "stale readme", "utf8"); + await writeFile( + join(catalogRoot, ".manifest.json.22222222-2222-4222-8222-222222222222.tmp"), + "partial manifest", + "utf8", + ); + await writeFile( + join(catalogRoot, ".README.md.33333333-3333-4333-8333-333333333333.tmp"), + "partial readme", + "utf8", + ); + await writeFile(nativeInstructionsPath, "stale instructions", "utf8"); + await writeFile(nativeTemporaryPath, "partial instructions", "utf8"); + const execution = createExecution(root, ""); + const signal = new AbortController().signal; + + try { + await expect(writeSkillBootstrapArtifacts(execution, [], signal)).resolves.toBeNull(); + await expect(writeNativeRuntimeSystemPrompt(execution, [], signal)).resolves.toBeNull(); + await expect(readdir(catalogRoot)).resolves.toEqual([]); + await expect(readFile(nativeInstructionsPath, "utf8")).rejects.toThrow(); + await expect(readFile(nativeTemporaryPath, "utf8")).rejects.toThrow(); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); +}); diff --git a/tests/skill-materialization.test.ts b/tests/skill-materialization.test.ts index b514432..fb43032 100644 --- a/tests/skill-materialization.test.ts +++ b/tests/skill-materialization.test.ts @@ -1,29 +1,31 @@ import { describe, expect, test } from "bun:test"; import { Buffer } from "node:buffer"; import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; import { mkdir, mkdtemp, + open, readFile, readdir, readlink, + rename, rm, symlink, writeFile, } from "node:fs/promises"; +import type { FileHandle } from "node:fs/promises"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; +import { zipSync as createZipArchive } from "fflate"; import type { AgentDriverMaterializedSkill } from "../src/host-ports"; -import { createBufferedSinkLogger } from "../src/observability"; +import { createDisabledLogger as createTestLogger } from "../src/observability"; import type { DriverResolvedSkill } from "../src/protocol/boot"; import type { DriverExecutionInput } from "../src/protocol/execution"; -import { - exposeNativeSkillAliases, - materializeResolvedSkills, -} from "../src/runtimes/skill-materialization"; -import { createZipArchive } from "../src/skill-package"; -import type { SkillPackageEntry } from "../src/skill-package"; +import { exposeNativeSkillAliases } from "../src/runtimes/skill-bootstrap"; +import { materializeResolvedSkills } from "../src/runtimes/skill-materialization"; +import { promiseWithTimeout } from "../src/utils/async"; import { bootPayload } from "./driver-runtime-boundary-fixtures"; const textEncoder = new TextEncoder(); @@ -36,7 +38,12 @@ function toDataUrl(bytes: Uint8Array): string { return `data:application/zip;base64,${Buffer.from(bytes).toString("base64")}`; } -function createExecution(root: string, skill: DriverResolvedSkill): DriverExecutionInput { +function createExecution( + root: string, + skills: DriverResolvedSkill | DriverResolvedSkill[], +): DriverExecutionInput { + const resolvedSkills = Array.isArray(skills) ? skills : [skills]; + return { ...bootPayload.execution, session: { @@ -44,12 +51,26 @@ function createExecution(root: string, skill: DriverResolvedSkill): DriverExecut cwd: root, sharedRootPath: root, }, - skillCatalog: [], - skills: [skill], + skillCatalog: resolvedSkills.map((skill) => ({ + frontmatter: { + author: null, + description: null, + version: null, + }, + mountPath: skill.mountPath, + resolutionMode: skill.resolutionMode, + skillId: skill.skillId, + skillName: skill.skillName, + })), + skills: resolvedSkills, }; } -function createSkill(root: string, archive: Uint8Array): DriverResolvedSkill { +function createSkill( + root: string, + archive: Uint8Array, + overrides: Partial = {}, +): DriverResolvedSkill { return { archiveFormat: "zip", blobSha256: sha256(archive), @@ -62,26 +83,39 @@ function createSkill(root: string, archive: Uint8Array): DriverResolvedSkill { skillName: "review", snapshotId: "snapshot-1" as DriverResolvedSkill["snapshotId"], warningCode: null, + ...overrides, }; } -function createTestLogger() { - return createBufferedSinkLogger({ - level: "debug", - service: "skill-materialization-test", - sink: async () => {}, - }); +function materialize(execution: DriverExecutionInput, logger: ReturnType) { + return materializeResolvedSkills(execution, logger, new AbortController().signal); } -function createMarkdownSkillEntries(markdown: string): SkillPackageEntry[] { - return [ - { - body: textEncoder.encode(markdown), - entryKind: "file", - isExecutable: false, - path: "SKILL.md", - }, - ]; +function exposeAliases( + execution: DriverExecutionInput, + logger: ReturnType, + skills: readonly AgentDriverMaterializedSkill[], + signal = new AbortController().signal, +) { + return exposeNativeSkillAliases(execution, logger, skills, signal); +} + +function createMarkdownSkillEntries(markdown: string) { + return { "SKILL.md": textEncoder.encode(markdown) }; +} + +async function createActiveTransaction(root: string, committed = false) { + const activeRoot = join(root, ".mosoo", ".skill-transactions", "active"); + const newRoot = join(activeRoot, "new"); + const oldRoot = join(activeRoot, "old"); + await mkdir(activeRoot, { recursive: true }); + if (committed) { + await mkdir(oldRoot); + await writeFile(join(activeRoot, "COMMITTED"), ""); + } else { + await mkdir(newRoot); + } + return { activeRoot, newRoot, oldRoot }; } describe("skill materialization", () => { @@ -99,9 +133,9 @@ Check the diff.`), const skill = createSkill(root, archive); try { - const [materialized] = await materializeResolvedSkills(createExecution(root, skill), logger); + const [materializedSkill] = await materialize(createExecution(root, skill), logger); - expect(materialized).toEqual({ + expect(materializedSkill).toEqual({ mountPath: skill.mountPath, skillId: "skill-1", skillMarkdownPath: join(skill.mountPath, "SKILL.md"), @@ -112,7 +146,6 @@ Check the diff.`), "Check the diff.", ); } finally { - await logger.destroy(); await rm(root, { force: true, recursive: true }); } }); @@ -134,34 +167,831 @@ Check the diff.`), }; try { - await expect(materializeResolvedSkills(createExecution(root, skill), logger)).rejects.toThrow( + await expect(materialize(createExecution(root, skill), logger)).rejects.toThrow( "outside the allowed root", ); } finally { - await logger.destroy(); await rm(root, { force: true, recursive: true }); } }); - test("fails malformed packages before reporting materialization success", async () => { + test("rejects duplicate resolved mounts before touching either skill", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-")); + const logger = createTestLogger(); + const archive = createZipArchive(createMarkdownSkillEntries("canonical")); + const first = createSkill(root, archive); + const duplicate = createSkill(root, archive, { + mountPath: join(root, ".mosoo", "skill", "unused", "..", "review"), + skillId: "skill-2" as DriverResolvedSkill["skillId"], + skillName: "duplicate", + snapshotId: "snapshot-2" as DriverResolvedSkill["snapshotId"], + }); + await mkdir(first.mountPath, { recursive: true }); + await writeFile(join(first.mountPath, "KEEP"), "untouched", "utf8"); + + try { + await expect(materialize(createExecution(root, [first, duplicate]), logger)).rejects.toThrow( + "duplicate mount path", + ); + await expect(readFile(join(first.mountPath, "KEEP"), "utf8")).resolves.toBe("untouched"); + await expect(readdir(join(root, ".mosoo"))).resolves.toEqual(["skill"]); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + test("rejects duplicate resolved skill names before downloading either skill", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-")); + const logger = createTestLogger(); + const archive = createZipArchive(createMarkdownSkillEntries("canonical")); + const first = createSkill(root, archive); + const duplicate = createSkill(root, archive, { + mountPath: join(root, ".mosoo", "skill", "second"), + skillId: "skill-2" as DriverResolvedSkill["skillId"], + snapshotId: "snapshot-2" as DriverResolvedSkill["snapshotId"], + }); + + try { + await expect(materialize(createExecution(root, [first, duplicate]), logger)).rejects.toThrow( + "duplicate skill name", + ); + await expect(readdir(root)).resolves.toEqual([]); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + test("rejects a catalog that advertises a different active skill", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-")); + const logger = createTestLogger(); + const archive = createZipArchive(createMarkdownSkillEntries("canonical")); + const skill = createSkill(root, archive); + const execution = createExecution(root, skill); + execution.skillCatalog[0] = { + ...execution.skillCatalog[0]!, + mountPath: join(root, ".mosoo", "skill", "other"), + skillName: "other", + }; + + try { + await expect(materialize(execution, logger)).rejects.toThrow("does not match resolved skill"); + await expect(readdir(root)).resolves.toEqual([]); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + test("rejects a symbolic-link mount ancestor without touching its target", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-")); + const outsideRoot = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-outside-")); + const logger = createTestLogger(); + const archive = createZipArchive(createMarkdownSkillEntries("new contents")); + const skill = createSkill(root, archive); + const outsideMount = join(outsideRoot, "review"); + await mkdir(join(root, ".mosoo")); + await mkdir(outsideMount, { recursive: true }); + await writeFile(join(outsideMount, "KEEP"), "outside", "utf8"); + await symlink(outsideRoot, join(root, ".mosoo", "skill"), "dir"); + + try { + await expect(materialize(createExecution(root, skill), logger)).rejects.toThrow( + "Skill mount root must be a real directory", + ); + await expect(readFile(join(outsideMount, "KEEP"), "utf8")).resolves.toBe("outside"); + await expect(readlink(join(root, ".mosoo", "skill"))).resolves.toBe(outsideRoot); + } finally { + await rm(root, { force: true, recursive: true }); + await rm(outsideRoot, { force: true, recursive: true }); + } + }); + + test("rejects a symbolic-link mount leaf without touching its target", async () => { const root = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-")); + const outsideRoot = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-outside-")); const logger = createTestLogger(); - const archive = createZipArchive([ - { - body: textEncoder.encode("missing skill markdown"), - entryKind: "file", - isExecutable: false, - path: "references/README.md", - } satisfies SkillPackageEntry, - ]); + const archive = createZipArchive(createMarkdownSkillEntries("new contents")); const skill = createSkill(root, archive); + await mkdir(dirname(skill.mountPath), { recursive: true }); + await writeFile(join(outsideRoot, "KEEP"), "outside", "utf8"); + await symlink(outsideRoot, skill.mountPath, "dir"); + + try { + await expect(materialize(createExecution(root, skill), logger)).rejects.toThrow( + "must be a real directory or absent", + ); + await expect(readFile(join(outsideRoot, "KEEP"), "utf8")).resolves.toBe("outside"); + await expect(readlink(skill.mountPath)).resolves.toBe(outsideRoot); + } finally { + await rm(root, { force: true, recursive: true }); + await rm(outsideRoot, { force: true, recursive: true }); + } + }); + + test("an empty catalog fails closed for a symbolic-link live root", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-")); + const outsideRoot = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-outside-")); + const logger = createTestLogger(); + await mkdir(join(root, ".mosoo")); + await writeFile(join(outsideRoot, "KEEP"), "outside", "utf8"); + await symlink(outsideRoot, join(root, ".mosoo", "skill"), "dir"); + + try { + await expect(materialize(createExecution(root, []), logger)).rejects.toThrow( + "must be a real directory", + ); + await expect(readFile(join(outsideRoot, "KEEP"), "utf8")).resolves.toBe("outside"); + await expect(readlink(join(root, ".mosoo", "skill"))).resolves.toBe(outsideRoot); + } finally { + await rm(root, { force: true, recursive: true }); + await rm(outsideRoot, { force: true, recursive: true }); + } + }); + + test("an empty catalog fails closed for a non-directory live root", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-")); + const logger = createTestLogger(); + const mountRoot = join(root, ".mosoo", "skill"); + await mkdir(join(root, ".mosoo")); + await writeFile(mountRoot, "user-controlled", "utf8"); + + try { + await expect(materialize(createExecution(root, []), logger)).rejects.toThrow( + "must be a real directory", + ); + await expect(readFile(mountRoot, "utf8")).resolves.toBe("user-controlled"); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + test("recovery does not accept a symbolic-link untouched live root", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-")); + const outsideRoot = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-outside-")); + const logger = createTestLogger(); + await mkdir(join(root, ".mosoo")); + await writeFile(join(outsideRoot, "KEEP"), "outside", "utf8"); + await symlink(outsideRoot, join(root, ".mosoo", "skill"), "dir"); + await createActiveTransaction(root); + + try { + await expect(materialize(createExecution(root, []), logger)).rejects.toThrow( + "must be a real directory", + ); + await expect(readFile(join(outsideRoot, "KEEP"), "utf8")).resolves.toBe("outside"); + await expect(readlink(join(root, ".mosoo", "skill"))).resolves.toBe(outsideRoot); + } finally { + await rm(root, { force: true, recursive: true }); + await rm(outsideRoot, { force: true, recursive: true }); + } + }); + + test("does not follow a mount ancestor replaced after admission", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-")); + const outsideRoot = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-outside-")); + const logger = createTestLogger(); + const archive = createZipArchive(createMarkdownSkillEntries("new contents")); + const skill = createSkill(root, archive, { + downloadUrl: "https://skills.test/raced-ancestor.zip", + }); + const requestStarted = Promise.withResolvers(); + const releaseResponse = Promise.withResolvers(); + const nativeFetch = globalThis.fetch; + globalThis.fetch = (async () => { + requestStarted.resolve(); + await releaseResponse.promise; + return new Response(archive); + }) as unknown as typeof fetch; + await mkdir(skill.mountPath, { recursive: true }); + await writeFile(join(skill.mountPath, "SKILL.md"), "previous contents", "utf8"); + + try { + const result = materialize(createExecution(root, skill), logger); + const rejection = result.then( + () => null, + (error: unknown) => error, + ); + await requestStarted.promise; + const mountRoot = dirname(skill.mountPath); + const detachedMountRoot = join(root, ".mosoo", "detached-skill"); + await rename(mountRoot, detachedMountRoot); + await symlink(outsideRoot, mountRoot, "dir"); + releaseResponse.resolve(); + + await expect(rejection).resolves.toMatchObject({ + message: expect.stringContaining("must be a real directory"), + }); + await expect(readdir(outsideRoot)).resolves.toEqual([]); + await expect(readFile(join(detachedMountRoot, "review", "SKILL.md"), "utf8")).resolves.toBe( + "previous contents", + ); + } finally { + globalThis.fetch = nativeFetch; + releaseResponse.resolve(); + await rm(root, { force: true, recursive: true }); + await rm(outsideRoot, { force: true, recursive: true }); + } + }); + + test("does not follow a staged archive ancestor replaced during extraction", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-")); + const outsideRoot = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-outside-")); + const logger = createTestLogger(); + const archive = createZipArchive({ + ...createMarkdownSkillEntries("canonical"), + "nested/first.txt": textEncoder.encode("first"), + "nested/second.txt": textEncoder.encode("second"), + }); + const skill = createSkill(root, archive); + const probe = await open(root, "r"); + const handlePrototype = Object.getPrototypeOf(probe) as { + sync(this: FileHandle): Promise; + }; + const nativeSync = handlePrototype.sync; + let swapped = false; + await probe.close(); + await writeFile(join(outsideRoot, "KEEP"), "outside", "utf8"); + + try { + handlePrototype.sync = async function (this: FileHandle) { + await nativeSync.call(this); + if (swapped) { + return; + } + const transactionRoot = join(root, ".mosoo", ".skill-transactions"); + let ownerNames: string[]; + try { + ownerNames = await readdir(transactionRoot); + } catch { + return; + } + const ownerName = ownerNames.find((name) => name.startsWith("stage-")); + if (ownerName === undefined) { + return; + } + const reviewRoot = join(transactionRoot, ownerName, "new", "review"); + const nestedPath = join(reviewRoot, "nested"); + if (!existsSync(join(nestedPath, "first.txt"))) { + return; + } + + swapped = true; + await rename(nestedPath, join(reviewRoot, "detached-nested")); + await symlink(outsideRoot, nestedPath, "dir"); + }; + + await expect(materialize(createExecution(root, skill), logger)).rejects.toThrow( + "must be a real directory", + ); + expect(swapped).toBe(true); + await expect(readFile(join(outsideRoot, "KEEP"), "utf8")).resolves.toBe("outside"); + await expect(readFile(join(outsideRoot, "second.txt"), "utf8")).rejects.toThrow(); + await expect(readFile(join(skill.mountPath, "SKILL.md"), "utf8")).rejects.toThrow(); + } finally { + handlePrototype.sync = nativeSync; + await rm(root, { force: true, recursive: true }); + await rm(outsideRoot, { force: true, recursive: true }); + } + }); + + test("preserves the previous tree when a replacement package is malformed", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-")); + const logger = createTestLogger(); + const validArchive = createZipArchive(createMarkdownSkillEntries("previous contents")); + const malformedArchive = createZipArchive({ + "references/README.md": textEncoder.encode("missing skill markdown"), + }); + const validSkill = createSkill(root, validArchive); + const malformedSkill = createSkill(root, malformedArchive, { + snapshotId: "snapshot-2" as DriverResolvedSkill["snapshotId"], + }); + + try { + await materialize(createExecution(root, validSkill), logger); + await expect(materialize(createExecution(root, malformedSkill), logger)).rejects.toThrow( + "does not contain SKILL.md", + ); + await expect(readFile(join(validSkill.mountPath, "SKILL.md"), "utf8")).resolves.toBe( + "previous contents", + ); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + test("does not trust cache metadata in the agent-writable skill tree", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-")); + const logger = createTestLogger(); + const archive = createZipArchive(createMarkdownSkillEntries("canonical contents")); + const skill = createSkill(root, archive); + + try { + await materialize(createExecution(root, skill), logger); + await writeFile(join(skill.mountPath, "SKILL.md"), "tampered", "utf8"); + await writeFile( + join(skill.mountPath, ".mosoo-skill-cache.json"), + JSON.stringify({ blobSha256: skill.blobSha256, snapshotId: skill.snapshotId }), + "utf8", + ); + + await materialize(createExecution(root, skill), logger); + + await expect(readFile(join(skill.mountPath, "SKILL.md"), "utf8")).resolves.toBe( + "canonical contents", + ); + await expect( + readFile(join(skill.mountPath, ".mosoo-skill-cache.json"), "utf8"), + ).rejects.toThrow(); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + test("quarantine cleanup unlinks old symlinks without traversing their targets", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-")); + const outsideRoot = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-outside-")); + const logger = createTestLogger(); + const archive = createZipArchive(createMarkdownSkillEntries("replacement contents")); + const skill = createSkill(root, archive); + await mkdir(skill.mountPath, { recursive: true }); + await writeFile(join(skill.mountPath, "SKILL.md"), "previous contents", "utf8"); + await writeFile(join(outsideRoot, "KEEP"), "outside", "utf8"); + await symlink(outsideRoot, join(skill.mountPath, "escape"), "dir"); + + try { + await materialize(createExecution(root, skill), logger); + + await expect(readFile(join(skill.mountPath, "SKILL.md"), "utf8")).resolves.toBe( + "replacement contents", + ); + await expect(readFile(join(outsideRoot, "KEEP"), "utf8")).resolves.toBe("outside"); + } finally { + await rm(root, { force: true, recursive: true }); + await rm(outsideRoot, { force: true, recursive: true }); + } + }); + + test("quarantines managed mounts that are absent from an empty catalog", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-")); + const logger = createTestLogger(); + const skill = createSkill( + root, + createZipArchive(createMarkdownSkillEntries("previous contents")), + ); + + try { + await materialize(createExecution(root, skill), logger); + await expect(materialize(createExecution(root, []), logger)).resolves.toEqual([]); + + await expect(readFile(join(skill.mountPath, "SKILL.md"), "utf8")).rejects.toThrow(); + await expect(readdir(join(root, ".mosoo", ".skill-transactions"))).resolves.toEqual([]); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + test("does not create managed roots for an already empty session", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-")); + const logger = createTestLogger(); + const execution = createExecution(root, []); + + try { + await expect(materialize(execution, logger)).resolves.toEqual([]); + await expect(exposeAliases(execution, logger, [])).resolves.toEqual([]); + await expect(readdir(root)).resolves.toEqual([]); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + test("an empty catalog supersedes an older download for the whole mount root", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-")); + const logger = createTestLogger(); + const archive = createZipArchive(createMarkdownSkillEntries("obsolete contents")); + const requestStarted = Promise.withResolvers(); + const releaseResponse = Promise.withResolvers(); + const nativeFetch = globalThis.fetch; + globalThis.fetch = (async (input: Parameters[0]) => { + if (String(input) !== "https://skills.test/obsolete.zip") { + return nativeFetch(input); + } + + requestStarted.resolve(); + await releaseResponse.promise; + return new Response(archive); + }) as typeof fetch; + const oldSkill = createSkill(root, archive, { + downloadUrl: "https://skills.test/obsolete.zip", + }); + + try { + const oldResult = materialize(createExecution(root, oldSkill), logger).then( + () => null, + (error: unknown) => error, + ); + await requestStarted.promise; + + await expect(materialize(createExecution(root, []), logger)).resolves.toEqual([]); + releaseResponse.resolve(); + + await expect(oldResult).resolves.toMatchObject({ + message: "Skill materialization was superseded by a newer generation.", + }); + await expect(readFile(join(oldSkill.mountPath, "SKILL.md"), "utf8")).rejects.toThrow(); + } finally { + releaseResponse.resolve(); + globalThis.fetch = nativeFetch; + await rm(root, { force: true, recursive: true }); + } + }); + + test("cancellation after old mounts move still rolls back the whole catalog", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-")); + const logger = createTestLogger(); + const skill = createSkill( + root, + createZipArchive(createMarkdownSkillEntries("previous contents")), + ); + const controller = new AbortController(); + const probe = await open(root, "r"); + const handlePrototype = Object.getPrototypeOf(probe) as { + sync(this: FileHandle): Promise; + }; + const nativeSync = handlePrototype.sync; + await probe.close(); + + try { + await materialize(createExecution(root, skill), logger); + handlePrototype.sync = async function (this: FileHandle) { + await nativeSync.call(this); + if (!existsSync(skill.mountPath)) { + controller.abort(new Error("cancel after backup")); + } + }; + + await expect( + materializeResolvedSkills(createExecution(root, []), logger, controller.signal), + ).rejects.toThrow("cancel after backup"); + await expect(readFile(join(skill.mountPath, "SKILL.md"), "utf8")).resolves.toBe( + "previous contents", + ); + await expect(readdir(join(root, ".mosoo", ".skill-transactions"))).resolves.toEqual([]); + } finally { + handlePrototype.sync = nativeSync; + await rm(root, { force: true, recursive: true }); + } + }); + + test("recovers a bounded orphan transaction owner", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-")); + const logger = createTestLogger(); + const transactionRoot = join(root, ".mosoo", ".skill-transactions"); + const transactionId = "44444444-4444-4444-8444-444444444444"; + const stagePath = join(transactionRoot, `stage-${transactionId}`); + await mkdir(join(root, ".mosoo", "skill"), { recursive: true }); + await mkdir(stagePath, { recursive: true }); + await writeFile(join(stagePath, "partial"), "orphan", "utf8"); + + try { + await expect(materialize(createExecution(root, []), logger)).resolves.toEqual([]); + await expect(readdir(transactionRoot)).resolves.toEqual([]); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + test("makes bounded progress cleaning a deeply nested orphan owner", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-")); + const logger = createTestLogger(); + const transactionRoot = join(root, ".mosoo", ".skill-transactions"); + const stagePath = join(transactionRoot, "stage-55555555-5555-4555-8555-555555555555"); + const deepPath = join( + stagePath, + ...Array.from({ length: 80 }, (_, index) => `level-${String(index)}`), + ); + await mkdir(join(root, ".mosoo", "skill"), { recursive: true }); + await mkdir(deepPath, { recursive: true }); + await writeFile(join(deepPath, "leaf"), "orphan", "utf8"); + + try { + await expect(materialize(createExecution(root, []), logger)).resolves.toEqual([]); + expect(await readdir(transactionRoot)).toHaveLength(1); + await expect(materialize(createExecution(root, []), logger)).resolves.toEqual([]); + await expect(readdir(transactionRoot)).resolves.toEqual([]); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + test("fsyncs the shared root after creating the managed .mosoo entry", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-")); + const logger = createTestLogger(); + const skill = createSkill( + root, + createZipArchive(createMarkdownSkillEntries("durable contents")), + ); + const probe = await open(root, "r"); + const rootStats = await probe.stat(); + const handlePrototype = Object.getPrototypeOf(probe) as { + sync(this: FileHandle): Promise; + }; + const nativeSync = handlePrototype.sync; + let sharedRootSynced = false; + await probe.close(); try { - await expect(materializeResolvedSkills(createExecution(root, skill), logger)).rejects.toThrow( + handlePrototype.sync = async function (this: FileHandle) { + const stats = await this.stat(); + if (stats.dev === rootStats.dev && stats.ino === rootStats.ino) { + sharedRootSynced = true; + } + await nativeSync.call(this); + }; + await materialize(createExecution(root, skill), logger); + expect(sharedRootSynced).toBe(true); + } finally { + handlePrototype.sync = nativeSync; + await rm(root, { force: true, recursive: true }); + } + }); + + test("restores an interrupted whole-catalog swap before its commit marker", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-")); + const logger = createTestLogger(); + const mountPath = join(root, ".mosoo", "skill", "review"); + const malformedSkill = createSkill( + root, + createZipArchive({ "README.md": textEncoder.encode("missing skill markdown") }), + ); + await mkdir(mountPath, { recursive: true }); + await writeFile(join(mountPath, "SKILL.md"), "previous contents", "utf8"); + const { newRoot, oldRoot } = await createActiveTransaction(root); + await mkdir(join(newRoot, "review")); + await writeFile(join(newRoot, "review", "SKILL.md"), "staged contents", "utf8"); + await rename(dirname(mountPath), oldRoot); + + try { + await expect(materialize(createExecution(root, malformedSkill), logger)).rejects.toThrow( "does not contain SKILL.md", ); + await expect(readFile(join(mountPath, "SKILL.md"), "utf8")).resolves.toBe( + "previous contents", + ); + await expect(readdir(join(root, ".mosoo", ".skill-transactions"))).resolves.toEqual([]); } finally { - await logger.destroy(); + await rm(root, { force: true, recursive: true }); + } + }); + + test("finishes a committed first catalog installation after a crash", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-")); + const logger = createTestLogger(); + const mountPath = join(root, ".mosoo", "skill", "review"); + const malformedSkill = createSkill( + root, + createZipArchive({ "README.md": textEncoder.encode("missing skill markdown") }), + ); + const { activeRoot, newRoot } = await createActiveTransaction(root); + await mkdir(join(newRoot, "review")); + await writeFile(join(newRoot, "review", "SKILL.md"), "committed contents", "utf8"); + await writeFile(join(activeRoot, "COMMITTED"), ""); + + try { + await expect(materialize(createExecution(root, malformedSkill), logger)).rejects.toThrow( + "does not contain SKILL.md", + ); + await expect(readFile(join(mountPath, "SKILL.md"), "utf8")).resolves.toBe( + "committed contents", + ); + await expect(readdir(join(root, ".mosoo", ".skill-transactions"))).resolves.toEqual([]); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + test("finishes a committed whole-catalog replacement after a crash", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-")); + const logger = createTestLogger(); + const mountRoot = join(root, ".mosoo", "skill"); + const mountPath = join(mountRoot, "review"); + const malformedSkill = createSkill( + root, + createZipArchive({ "README.md": textEncoder.encode("missing skill markdown") }), + ); + await mkdir(mountPath, { recursive: true }); + await writeFile(join(mountPath, "SKILL.md"), "previous contents", "utf8"); + const { activeRoot, newRoot, oldRoot } = await createActiveTransaction(root); + await mkdir(join(newRoot, "review")); + await writeFile(join(newRoot, "review", "SKILL.md"), "committed contents", "utf8"); + await rename(mountRoot, oldRoot); + await writeFile(join(activeRoot, "COMMITTED"), ""); + + try { + await expect(materialize(createExecution(root, malformedSkill), logger)).rejects.toThrow( + "does not contain SKILL.md", + ); + await expect(readFile(join(mountPath, "SKILL.md"), "utf8")).resolves.toBe( + "committed contents", + ); + await expect(readdir(join(root, ".mosoo", ".skill-transactions"))).resolves.toEqual([]); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + test("a rolled-back transaction does not conflict with the next catalog", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-")); + const logger = createTestLogger(); + const mountPath = join(root, ".mosoo", "skill", "review"); + const currentSkill = createSkill( + root, + createZipArchive(createMarkdownSkillEntries("next catalog contents")), + ); + await mkdir(mountPath, { recursive: true }); + await writeFile(join(mountPath, "SKILL.md"), "previous contents", "utf8"); + const { newRoot, oldRoot } = await createActiveTransaction(root); + await mkdir(join(newRoot, "review")); + await writeFile(join(newRoot, "review", "SKILL.md"), "abandoned contents", "utf8"); + await rename(dirname(mountPath), oldRoot); + + try { + await materialize(createExecution(root, currentSkill), logger); + + await expect(readFile(join(mountPath, "SKILL.md"), "utf8")).resolves.toBe( + "next catalog contents", + ); + await expect(readdir(join(root, ".mosoo", ".skill-transactions"))).resolves.toEqual([]); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + test("retires a committed transaction owner before a later catalog removes its mount", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-")); + const logger = createTestLogger(); + const mountPath = join(root, ".mosoo", "skill", "review"); + await mkdir(mountPath, { recursive: true }); + await writeFile(join(mountPath, "SKILL.md"), "current contents", "utf8"); + const { oldRoot } = await createActiveTransaction(root, true); + await mkdir(join(oldRoot, "review")); + await Promise.all( + Array.from({ length: 1_025 }, (_, index) => + writeFile(join(oldRoot, "review", `old-${String(index)}`), "", "utf8"), + ), + ); + + try { + await expect(materialize(createExecution(root, []), logger)).resolves.toEqual([]); + await expect(readFile(join(mountPath, "SKILL.md"), "utf8")).rejects.toThrow(); + + await expect(materialize(createExecution(root, []), logger)).resolves.toEqual([]); + await expect(readdir(join(root, ".mosoo", ".skill-transactions"))).resolves.toEqual([]); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + test("a committed empty-catalog transaction does not conflict with re-adding the skill", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-")); + const logger = createTestLogger(); + const skill = createSkill( + root, + createZipArchive(createMarkdownSkillEntries("re-added contents")), + ); + const { oldRoot } = await createActiveTransaction(root, true); + await mkdir(join(oldRoot, "review")); + await writeFile(join(oldRoot, "review", "SKILL.md"), "removed contents", "utf8"); + await mkdir(join(root, ".mosoo", "skill")); + + try { + await materialize(createExecution(root, skill), logger); + + await expect(readFile(join(skill.mountPath, "SKILL.md"), "utf8")).resolves.toBe( + "re-added contents", + ); + await expect(readdir(join(root, ".mosoo", ".skill-transactions"))).resolves.toEqual([]); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + test("a newer generation cancels an obsolete download before it can overwrite", async () => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-")); + const logger = createTestLogger(); + const oldArchive = createZipArchive(createMarkdownSkillEntries("obsolete contents")); + const newArchive = createZipArchive(createMarkdownSkillEntries("current contents")); + const oldRequestStarted = Promise.withResolvers(); + const nativeFetch = globalThis.fetch; + let oldRequestSignal: AbortSignal | null = null; + let oldStreamCancelled = false; + globalThis.fetch = (async ( + input: Parameters[0], + init?: Parameters[1], + ) => { + if (String(input) !== "https://skills.test/obsolete.zip") { + return nativeFetch(input, init); + } + + oldRequestSignal = init?.signal ?? null; + oldRequestStarted.resolve(); + return new Response( + new ReadableStream({ + cancel() { + oldStreamCancelled = true; + }, + start(controller) { + controller.enqueue(oldArchive); + }, + }), + ); + }) as unknown as typeof fetch; + const oldSkill = createSkill(root, oldArchive, { + downloadUrl: "https://skills.test/obsolete.zip", + }); + const newSkill = createSkill(root, newArchive, { + snapshotId: "snapshot-2" as DriverResolvedSkill["snapshotId"], + }); + try { + const oldMaterialization = materializeResolvedSkills( + createExecution(root, oldSkill), + logger, + new AbortController().signal, + ); + const oldResult = oldMaterialization.then( + () => null, + (error: unknown) => error, + ); + await oldRequestStarted.promise; + await materialize(createExecution(root, newSkill), logger); + + await expect(oldResult).resolves.toMatchObject({ + message: "Skill materialization was superseded by a newer generation.", + }); + expect((oldRequestSignal as AbortSignal | null)?.aborted).toBe(true); + expect(oldStreamCancelled).toBe(true); + await expect(readFile(join(newSkill.mountPath, "SKILL.md"), "utf8")).resolves.toBe( + "current contents", + ); + } finally { + globalThis.fetch = nativeFetch; + await rm(root, { force: true, recursive: true }); + } + }); + + test.each([ + ["http-error", "cooperative"], + ["http-error", "stalled"], + ["content-length", "cooperative"], + ["content-length", "stalled"], + ["stream", "cooperative"], + ["stream", "stalled"], + ] as const)("bounds %s skill downloads with %s cancellation", async (boundary, cancellation) => { + const root = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-")); + const logger = createTestLogger(); + const skill = createSkill(root, new Uint8Array(), { + downloadUrl: "https://skills.test/oversized.zip", + }); + const nativeFetch = globalThis.fetch; + let cancelled = false; + globalThis.fetch = (async () => { + return new Response( + new ReadableStream({ + cancel() { + cancelled = true; + return cancellation === "stalled" ? new Promise(() => {}) : undefined; + }, + pull(controller) { + if (boundary === "stream") { + controller.enqueue(new Uint8Array(1024 * 1024)); + } + }, + }), + boundary === "http-error" + ? { status: 502 } + : boundary === "content-length" + ? { headers: { "content-length": "26214401" } } + : undefined, + ); + }) as unknown as typeof fetch; + await mkdir(skill.mountPath, { recursive: true }); + await writeFile(join(skill.mountPath, "SKILL.md"), "previous contents", "utf8"); + + try { + await expect( + promiseWithTimeout(materialize(createExecution(root, skill), logger), { + label: "bounded skill download", + timeoutMs: 1_000, + }), + ).rejects.toThrow( + boundary === "http-error" + ? "Failed to download skill package" + : "Compressed skill package exceeds the limit", + ); + expect(cancelled).toBe(true); + await expect(readFile(join(skill.mountPath, "SKILL.md"), "utf8")).resolves.toBe( + "previous contents", + ); + } finally { + globalThis.fetch = nativeFetch; await rm(root, { force: true, recursive: true }); } }); @@ -181,20 +1011,19 @@ Check the diff.`), const aliasPath = join(root, ".agents", "skills", "review"); try { - const materialized = await materializeResolvedSkills(execution, logger); + const materializedSkills = await materialize(execution, logger); - await exposeNativeSkillAliases(execution, logger, materialized); + await exposeAliases(execution, logger, materializedSkills); await expect(readlink(aliasPath)).resolves.toBe("../../.mosoo/skill/review"); await expect(readFile(join(aliasPath, "SKILL.md"), "utf8")).resolves.toContain( "Check the diff.", ); - await exposeNativeSkillAliases(execution, logger, []); + await exposeAliases(execution, logger, []); await expect(readFile(join(aliasPath, "SKILL.md"), "utf8")).rejects.toThrow(); } finally { - await logger.destroy(); await rm(root, { force: true, recursive: true }); } }); @@ -215,15 +1044,14 @@ Check the diff.`), } satisfies AgentDriverMaterializedSkill; try { - await expect(exposeNativeSkillAliases(execution, logger, [skill])).resolves.toEqual([]); - await expect(readdir(join(root, ".agents", "skills"))).resolves.toEqual([]); + await expect(exposeAliases(execution, logger, [skill])).resolves.toEqual([]); + await expect(readdir(join(root, ".agents", "skills"))).rejects.toThrow(); } finally { - await logger.destroy(); await rm(root, { force: true, recursive: true }); } }); - test("keeps the first skill when native skill names collide", async () => { + test("rejects duplicate native skill names instead of silently choosing one", async () => { const root = await mkdtemp(join(tmpdir(), "mosoo-skill-materialization-")); const logger = createTestLogger(); const execution = createExecution( @@ -248,14 +1076,11 @@ Check the diff.`), } satisfies AgentDriverMaterializedSkill; try { - await expect( - exposeNativeSkillAliases(execution, logger, [first, duplicate]), - ).resolves.toEqual([join(root, ".agents", "skills", "review")]); - await expect(readlink(join(root, ".agents", "skills", "review"))).resolves.toBe( - "../../.mosoo/skill/skill-1", + await expect(exposeAliases(execution, logger, [first, duplicate])).rejects.toThrow( + "duplicate skill name", ); + await expect(readlink(join(root, ".agents", "skills", "review"))).rejects.toThrow(); } finally { - await logger.destroy(); await rm(root, { force: true, recursive: true }); } }); @@ -276,7 +1101,7 @@ Check the diff.`), await symlink("../../.mosoo/skill/gone", aliasPath, "dir"); try { - await exposeNativeSkillAliases(execution, logger, [ + await exposeAliases(execution, logger, [ { mountPath, skillId: "skill-2", @@ -287,11 +1112,10 @@ Check the diff.`), ]); await expect(readlink(aliasPath)).resolves.toBe("../../.mosoo/skill/skill-2"); - await exposeNativeSkillAliases(execution, logger, []); + await exposeAliases(execution, logger, []); await expect(readlink(aliasPath)).rejects.toThrow(); } finally { - await logger.destroy(); await rm(root, { force: true, recursive: true }); } }); @@ -312,7 +1136,7 @@ Check the diff.`), try { await expect( - exposeNativeSkillAliases(execution, logger, [ + exposeAliases(execution, logger, [ { mountPath, skillId: "skill-1", @@ -324,7 +1148,6 @@ Check the diff.`), ).rejects.toThrow('Native skill alias "review" collides'); await expect(readFile(join(aliasPath, "KEEP"), "utf8")).resolves.toBe("user-owned"); } finally { - await logger.destroy(); await rm(root, { force: true, recursive: true }); } }); diff --git a/tsconfig.types.json b/tsconfig.types.json deleted file mode 100644 index 57aa706..0000000 --- a/tsconfig.types.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "declaration": true, - "emitDeclarationOnly": true, - "noEmit": false, - "outDir": "dist/types", - "rootDir": "src", - "types": ["node"] - }, - "include": ["src/**/*.ts"] -} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..4f4aede --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,72 @@ +import { defineConfig } from "vite-plus"; + +function restrictImports( + ...patterns: string[] +): ["error", { paths: string[]; patterns: { regex: string }[] }] { + return [ + "error", + { + paths: ["module", "node:module"], + patterns: patterns.map((regex) => ({ regex })), + }, + ]; +} + +export default defineConfig({ + lint: { + plugins: ["import", "typescript", "unicorn"], + rules: { + "import/no-commonjs": ["error", { allowConditionalRequire: false }], + "import/no-cycle": ["error", { ignoreExternal: true, ignoreTypes: false }], + "import/no-dynamic-require": ["error", { esmodule: true }], + "no-restricted-imports": restrictImports(), + "typescript/no-require-imports": "error", + "unicorn/prefer-module": "error", + }, + overrides: [ + { + files: ["src/contract/**/*.ts"], + rules: { + "no-restricted-imports": restrictImports( + "(^|/)(core|infrastructure|runtimes|stores|surfaces)(/|$)", + ), + }, + }, + { + files: ["src/protocol/**/*.ts"], + rules: { + "no-restricted-imports": restrictImports( + "(^|/)(core|infrastructure|runtimes)(/|$)", + "(^|/)\\.\\./runtime-events(/|$)", + ), + }, + }, + { + files: ["src/core/**/*.ts"], + rules: { + "no-restricted-imports": restrictImports( + "(^|/)(infrastructure|stores|surfaces)(/|$)", + "(^|/)runtimes/(acp|claude|openai)(/|$)", + ), + }, + }, + { + files: [ + "src/runtimes/acp/**/*.ts", + "src/runtimes/claude/**/*.ts", + "src/runtimes/openai/**/*.ts", + ], + rules: { + "no-restricted-imports": restrictImports("(^|/)(infrastructure|stores|surfaces)(/|$)"), + }, + }, + ], + }, + pack: { + dts: { emitDtsOnly: true }, + entry: ["src/*.ts", "src/contract/index.ts"], + fixedExtension: false, + outDir: "dist/types", + platform: "neutral", + }, +});