From 91b5b889b62ca0358554e29d3690ac32240cfa8b Mon Sep 17 00:00:00 2001 From: Pranav Sharan Date: Sat, 8 Aug 2026 05:54:39 -0700 Subject: [PATCH 01/44] fix: drop orphan gitlink .repos/alchemy-effect/.vendor/alchemy The path is a committed submodule gitlink with no .gitmodules entry anywhere in the repo (upstream has no .gitmodules at all). Plain clones ignore it, and upstream CI sparse-checkout excludes /.repos/, but any submodule-aware clone fails hard: fatal: No url found for submodule path '.repos/alchemy-effect/.vendor/alchemy' in .gitmodules (exit 128) That broke every Aether workspace clone of this repo. Nothing references the gitlink; the directory contents were never part of this repository. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h --- .repos/alchemy-effect/.vendor/alchemy | 1 - 1 file changed, 1 deletion(-) delete mode 160000 .repos/alchemy-effect/.vendor/alchemy diff --git a/.repos/alchemy-effect/.vendor/alchemy b/.repos/alchemy-effect/.vendor/alchemy deleted file mode 160000 index c9f5e549cf02..000000000000 --- a/.repos/alchemy-effect/.vendor/alchemy +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c9f5e549cf023632c3df948c207a58336192b3c7 From d945b186094c1cc679f7ccad45c1abade763d926 Mon Sep 17 00:00:00 2001 From: Pranav Sharan Date: Sat, 8 Aug 2026 06:09:16 -0700 Subject: [PATCH 02/44] ci: run fork CI on GitHub-hosted runners; guard upstream-only deploy/release (#1) * ci: run fork CI on GitHub-hosted runners; guard upstream-only deploy/release Blacksmith runner labels are bound to the pingdotgg account and queue forever on this fork, so CI jobs move to GitHub-hosted runners (ubuntu-24.04 / macos-latest) with timeouts widened for the smaller machines. The relay deploy and the nightly release schedule are upstream-only and now skip outside pingdotgg/t3code. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * ci: widen slow-runner timeout for the image-compression give-up test The too-large give-up path walks the whole quality/scale ladder and takes ~18s on the 2-core GitHub-hosted runners this fork uses, tripping the 15s default. Explicit 60s timeout for that one test. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * style: oxfmt formatting for the widened test timeout Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h --------- Co-authored-by: Claude Fable 5 --- .github/workflows/ci.yml | 16 ++++++++-------- .github/workflows/deploy-relay.yml | 3 +++ .github/workflows/release.yml | 4 +++- apps/web/src/lib/imageCompression.test.ts | 4 +++- 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 052a8c20cf78..af3f9d810285 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,8 +13,8 @@ concurrency: jobs: check: name: Check - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 + runs-on: ubuntu-24.04 + timeout-minutes: 30 steps: - name: Checkout uses: actions/checkout@v6 @@ -59,8 +59,8 @@ jobs: test: name: Test - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 + runs-on: ubuntu-24.04 + timeout-minutes: 30 steps: - name: Checkout uses: actions/checkout@v6 @@ -112,8 +112,8 @@ jobs: mobile_native_static_analysis: name: Mobile Native Static Analysis - runs-on: blacksmith-6vcpu-macos-26 - timeout-minutes: 10 + runs-on: macos-latest + timeout-minutes: 20 steps: - name: Checkout uses: actions/checkout@v6 @@ -140,8 +140,8 @@ jobs: release_smoke: name: Release Smoke - runs-on: blacksmith-8vcpu-ubuntu-2404 - timeout-minutes: 10 + runs-on: ubuntu-24.04 + timeout-minutes: 30 steps: - name: Checkout uses: actions/checkout@v6 diff --git a/.github/workflows/deploy-relay.yml b/.github/workflows/deploy-relay.yml index f652844a54f3..86d20e05f526 100644 --- a/.github/workflows/deploy-relay.yml +++ b/.github/workflows/deploy-relay.yml @@ -17,6 +17,9 @@ concurrency: jobs: deploy_relay: name: Deploy production relay + # Upstream-only: the relay and its Cloudflare/Clerk/APNs credentials live in + # pingdotgg. On the Aether-Runtime fork this job must never run. + if: github.repository == 'pingdotgg/t3code' runs-on: blacksmith-8vcpu-ubuntu-2404 timeout-minutes: 15 environment: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a9754f9421b5..b2591d59ebe7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,9 @@ permissions: jobs: check_changes: name: Check for changes since last nightly - if: github.event_name == 'schedule' + # The schedule fires on the Aether-Runtime fork too; nightly releases are + # upstream-only, so skip there (manual workflow_dispatch still works). + if: github.event_name == 'schedule' && github.repository == 'pingdotgg/t3code' runs-on: blacksmith-8vcpu-ubuntu-2404 outputs: has_changes: ${{ steps.check.outputs.has_changes }} diff --git a/apps/web/src/lib/imageCompression.test.ts b/apps/web/src/lib/imageCompression.test.ts index 63712ca7e295..8c20f087d5bc 100644 --- a/apps/web/src/lib/imageCompression.test.ts +++ b/apps/web/src/lib/imageCompression.test.ts @@ -122,6 +122,8 @@ describe("compressImageForStash", () => { expect(close).toHaveBeenCalled(); }); + // Walks the full quality/scale ladder before giving up — ~18s on the 2-core + // GitHub-hosted runners this fork's CI uses, vs the default 15s timeout. it("reports too-large when even the smallest encoding overflows the budget", async () => { const { close } = stubCanvasPipeline(() => 8_000_000); @@ -130,7 +132,7 @@ describe("compressImageForStash", () => { expect(result).toEqual({ ok: false, reason: "too-large" }); // The bitmap must still be released on the give-up path. expect(close).toHaveBeenCalled(); - }); + }, 60_000); it("reports too-large for an oversized image when the browser cannot re-encode", async () => { vi.stubGlobal("createImageBitmap", undefined); From 8c7b79a529902925a2126bf33854471da4f7db25 Mon Sep 17 00:00:00 2001 From: Pranav Sharan Date: Sat, 8 Aug 2026 07:30:24 -0700 Subject: [PATCH 03/44] =?UTF-8?q?feat(aether):=20provider=20driver=20skele?= =?UTF-8?q?ton=20=E2=80=94=20settings,=20registration,=20vendored=20catalo?= =?UTF-8?q?g=20(#2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(aether): provider driver skeleton — settings, registration, vendored catalog AetherDriver T1: contracts settings (AetherSettings via makeProviderSettingsSchema, apiBaseUrl with prod default, API key via sensitive AETHER_API_KEY env var), driver registration with makeManagedServerProvider snapshot (GET /profile probe: missing-key / 401 / transport failures all distinguished; catalog models with reasoning-effort option descriptors on every draft path), typed not-implemented adapter stubs (real protocol lands in T3-T6), deterministic textGeneration stubs, and vendored aether knowledge (catalog, 20-value canonical item-type map, tool-display parser port) with source paths + sync recipe documented. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * fix(aether): keep every snapshot unavailable until the turn protocol exists Review: a valid-key instance passed isProviderInstancePickerReady and routed turns into the not-implemented adapter. The draft funnel now stamps availability=unavailable with an explicit preview reason on every probe outcome — key validation still works in settings, the picker excludes Aether until T6 removes the gate. Pinned by test. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * fix(aether): route the healthy-probe draft through the availability gate The success path built its snapshot directly and skipped the gated draft funnel, leaving a healthy instance picker-visible — exactly the reviewed defect. All probe outcomes now share the single gated funnel. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * fix(aether): gate honors the full unavailable contract on every snapshot Review round 2: availability=unavailable snapshots MUST set enabled:false and installed:false (server.ts contract), and mobile's model options only honor those flags — so the T6 gate now forces all three on the pending snapshot AND every probe draft through one gateUntilTurnProtocol helper. Key-validation fidelity stays in auth/message; top-level status reads disabled while gated. Tests pin the whole flag set on pending, disabled, and healthy-probe paths. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h --------- Co-authored-by: Claude Fable 5 --- .../src/provider/Drivers/AetherDriver.test.ts | 50 ++ .../src/provider/Drivers/AetherDriver.ts | 135 +++++ .../src/provider/Layers/AetherAdapter.ts | 67 +++ .../provider/Layers/AetherProvider.test.ts | 227 ++++++++ .../src/provider/Layers/AetherProvider.ts | 301 +++++++++++ .../provider/Layers/aether/vendored/README.md | 32 ++ .../aether/vendored/canonicalItemType.test.ts | 55 ++ .../aether/vendored/canonicalItemType.ts | 88 ++++ .../Layers/aether/vendored/catalog.test.ts | 77 +++ .../Layers/aether/vendored/catalog.ts | 141 +++++ .../aether/vendored/toolDisplay.test.ts | 134 +++++ .../Layers/aether/vendored/toolDisplay.ts | 496 ++++++++++++++++++ apps/server/src/provider/builtInDrivers.ts | 3 + .../textGeneration/AetherTextGeneration.ts | 97 ++++ apps/web/src/components/Icons.tsx | 12 + .../src/components/chat/providerIconUtils.ts | 3 +- .../settings/ProviderModelsSection.tsx | 1 + .../components/settings/providerDriverMeta.ts | 18 +- apps/web/src/lib/contextWindow.ts | 2 + apps/web/src/session-logic.ts | 8 + packages/contracts/src/model.ts | 5 + packages/contracts/src/settings.ts | 40 ++ 22 files changed, 1990 insertions(+), 2 deletions(-) create mode 100644 apps/server/src/provider/Drivers/AetherDriver.test.ts create mode 100644 apps/server/src/provider/Drivers/AetherDriver.ts create mode 100644 apps/server/src/provider/Layers/AetherAdapter.ts create mode 100644 apps/server/src/provider/Layers/AetherProvider.test.ts create mode 100644 apps/server/src/provider/Layers/AetherProvider.ts create mode 100644 apps/server/src/provider/Layers/aether/vendored/README.md create mode 100644 apps/server/src/provider/Layers/aether/vendored/canonicalItemType.test.ts create mode 100644 apps/server/src/provider/Layers/aether/vendored/canonicalItemType.ts create mode 100644 apps/server/src/provider/Layers/aether/vendored/catalog.test.ts create mode 100644 apps/server/src/provider/Layers/aether/vendored/catalog.ts create mode 100644 apps/server/src/provider/Layers/aether/vendored/toolDisplay.test.ts create mode 100644 apps/server/src/provider/Layers/aether/vendored/toolDisplay.ts create mode 100644 apps/server/src/textGeneration/AetherTextGeneration.ts diff --git a/apps/server/src/provider/Drivers/AetherDriver.test.ts b/apps/server/src/provider/Drivers/AetherDriver.test.ts new file mode 100644 index 000000000000..cb7d6c3471b8 --- /dev/null +++ b/apps/server/src/provider/Drivers/AetherDriver.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vite-plus/test"; +import { DEFAULT_AETHER_API_BASE_URL } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; + +import { AetherDriver } from "./AetherDriver.ts"; + +const decodeConfig = Schema.decodeUnknownSync(AetherDriver.configSchema); + +describe("AetherDriver config schema", () => { + it("decodes an empty envelope into the defaults", () => { + expect(decodeConfig({})).toEqual({ + enabled: true, + apiBaseUrl: DEFAULT_AETHER_API_BASE_URL, + customModels: [], + }); + expect(AetherDriver.defaultConfig()).toEqual({ + enabled: true, + apiBaseUrl: DEFAULT_AETHER_API_BASE_URL, + customModels: [], + }); + }); + + it("keeps an explicit apiBaseUrl, enabled flag, and custom models", () => { + expect( + decodeConfig({ + enabled: false, + apiBaseUrl: "https://api.staging.example", + customModels: ["codex/gpt-6-preview"], + }), + ).toEqual({ + enabled: false, + apiBaseUrl: "https://api.staging.example", + customModels: ["codex/gpt-6-preview"], + }); + }); + + it("falls back to the production default when apiBaseUrl is blank", () => { + expect(decodeConfig({ apiBaseUrl: " " }).apiBaseUrl).toBe(DEFAULT_AETHER_API_BASE_URL); + }); + + it("rejects non-string apiBaseUrl and non-boolean enabled loudly", () => { + expect(() => decodeConfig({ apiBaseUrl: 42 })).toThrow(); + expect(() => decodeConfig({ enabled: "yes" })).toThrow(); + }); + + it("advertises the aether driver kind", () => { + expect(AetherDriver.driverKind).toBe("aether"); + expect(AetherDriver.metadata.displayName).toBe("Aether"); + }); +}); diff --git a/apps/server/src/provider/Drivers/AetherDriver.ts b/apps/server/src/provider/Drivers/AetherDriver.ts new file mode 100644 index 000000000000..a960ab584022 --- /dev/null +++ b/apps/server/src/provider/Drivers/AetherDriver.ts @@ -0,0 +1,135 @@ +/** + * AetherDriver — `ProviderDriver` for Aether cloud tasks. + * + * T1 skeleton: a real snapshot (probe = authenticated `GET /profile`, models + * from the vendored platform catalog) over a not-yet-implemented adapter and + * deterministic text-generation stubs. There is no local binary — the driver + * talks to the Aether REST API, authenticated by the sensitive + * `AETHER_API_KEY` instance environment variable. + * + * @module provider/Drivers/AetherDriver + */ +import { AetherSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { HttpClient } from "effect/unstable/http"; + +import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerSettingsService } from "../../serverSettings.ts"; +import { makeAetherTextGeneration } from "../../textGeneration/AetherTextGeneration.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { makeAetherAdapter } from "../Layers/AetherAdapter.ts"; +import { checkAetherProviderStatus, makePendingAetherProvider } from "../Layers/AetherProvider.ts"; +import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; +import { + defaultProviderContinuationIdentity, + type ProviderDriver, + type ProviderInstance, +} from "../ProviderDriver.ts"; +import type { ServerProviderDraft } from "../providerSnapshot.ts"; +import { mergeProviderInstanceEnvironment } from "../ProviderInstanceEnvironment.ts"; +import { makeManualOnlyProviderMaintenanceCapabilities } from "../providerMaintenance.ts"; +import { + haveProviderSnapshotSettingsChanged, + makeProviderSnapshotSettingsSource, + type ProviderSnapshotSettings, +} from "../providerUpdateSettings.ts"; + +const decodeAetherSettings = Schema.decodeSync(AetherSettings); + +const DRIVER_KIND = ProviderDriverKind.make("aether"); + +// Cloud API — no local binary to update, so maintenance is manual-only. +const MAINTENANCE = makeManualOnlyProviderMaintenanceCapabilities({ + provider: DRIVER_KIND, + packageName: null, +}); + +export type AetherDriverEnv = + | BackgroundPolicy.BackgroundPolicy + | HttpClient.HttpClient + | ServerSettingsService; + +const withInstanceIdentity = + (input: { + readonly instanceId: ProviderInstance["instanceId"]; + readonly displayName: string | undefined; + readonly accentColor: string | undefined; + readonly continuationGroupKey: string; + }) => + (snapshot: ServerProviderDraft): ServerProvider => ({ + ...snapshot, + instanceId: input.instanceId, + driver: DRIVER_KIND, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + continuation: { groupKey: input.continuationGroupKey }, + }); + +export const AetherDriver: ProviderDriver = { + driverKind: DRIVER_KIND, + metadata: { + displayName: "Aether", + supportsMultipleInstances: true, + }, + configSchema: AetherSettings, + defaultConfig: (): AetherSettings => decodeAetherSettings({}), + create: ({ instanceId, displayName, accentColor, environment, enabled, config }) => + Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const serverSettings = yield* ServerSettingsService; + const processEnv = mergeProviderInstanceEnvironment(environment); + const continuationIdentity = defaultProviderContinuationIdentity({ + driverKind: DRIVER_KIND, + instanceId, + }); + const stampIdentity = withInstanceIdentity({ + instanceId, + displayName, + accentColor, + continuationGroupKey: continuationIdentity.continuationKey, + }); + const effectiveConfig = { ...config, enabled } satisfies AetherSettings; + + const adapter = yield* makeAetherAdapter({ instanceId }); + const textGeneration = makeAetherTextGeneration(); + + const checkProvider = checkAetherProviderStatus(effectiveConfig, processEnv).pipe( + Effect.map(stampIdentity), + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + + const snapshotSettings = makeProviderSnapshotSettingsSource(effectiveConfig, serverSettings); + const snapshot = yield* makeManagedServerProvider>({ + maintenanceCapabilities: MAINTENANCE, + getSettings: snapshotSettings.getSettings, + streamSettings: snapshotSettings.streamSettings, + haveSettingsChanged: haveProviderSnapshotSettingsChanged, + initialSnapshot: (settings) => + makePendingAetherProvider(settings.provider).pipe(Effect.map(stampIdentity)), + checkProvider, + }).pipe( + Effect.mapError( + (cause) => + new ProviderDriverError({ + driver: DRIVER_KIND, + instanceId, + detail: `Failed to build Aether snapshot: ${cause.message ?? String(cause)}`, + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; diff --git a/apps/server/src/provider/Layers/AetherAdapter.ts b/apps/server/src/provider/Layers/AetherAdapter.ts new file mode 100644 index 000000000000..d7b6b0ef4241 --- /dev/null +++ b/apps/server/src/provider/Layers/AetherAdapter.ts @@ -0,0 +1,67 @@ +/** + * AetherAdapter — T1 skeleton adapter for the Aether cloud-task driver. + * + * Every session/turn operation fails loudly with a typed + * `ProviderAdapterRequestError` until the REST client (T3) and event mapper + * (T6) land. The event stream is a real, scope-owned queue that simply never + * receives an event yet, so consumers can subscribe without special-casing + * this driver. + * + * @module provider/Layers/AetherAdapter + */ +import { + ProviderDriverKind, + type ProviderInstanceId, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Queue from "effect/Queue"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import { ProviderAdapterRequestError, type ProviderAdapterError } from "../Errors.ts"; +import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; + +const PROVIDER = ProviderDriverKind.make("aether"); + +const NOT_IMPLEMENTED_DETAIL = "Aether driver: not implemented until T3/T6"; + +const notImplemented = (method: string): Effect.Effect => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: NOT_IMPLEMENTED_DETAIL, + }), + ); + +export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* (_input: { + readonly instanceId: ProviderInstanceId; +}): Effect.fn.Return, never, Scope.Scope> { + // Scope-owned so registry teardown shuts the stream down with the instance. + const runtimeEvents = yield* Effect.acquireRelease( + Queue.unbounded(), + Queue.shutdown, + ); + + return { + provider: PROVIDER, + capabilities: { + sessionModelSwitch: "unsupported", + }, + startSession: () => notImplemented("startSession"), + sendTurn: () => notImplemented("sendTurn"), + interruptTurn: () => notImplemented("interruptTurn"), + respondToRequest: () => notImplemented("respondToRequest"), + respondToUserInput: () => notImplemented("respondToUserInput"), + stopSession: () => notImplemented("stopSession"), + listSessions: () => Effect.succeed([]), + hasSession: () => Effect.succeed(false), + readThread: () => notImplemented("readThread"), + rollbackThread: () => notImplemented("rollbackThread"), + stopAll: () => Effect.void, + get streamEvents() { + return Stream.fromQueue(runtimeEvents); + }, + } satisfies ProviderAdapterShape; +}); diff --git a/apps/server/src/provider/Layers/AetherProvider.test.ts b/apps/server/src/provider/Layers/AetherProvider.test.ts new file mode 100644 index 000000000000..dadefb12228e --- /dev/null +++ b/apps/server/src/provider/Layers/AetherProvider.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { + HttpClient, + HttpClientError, + HttpClientRequest, + HttpClientResponse, +} from "effect/unstable/http"; +import { AetherSettings } from "@t3tools/contracts"; + +import { + AETHER_API_KEY_ENV_VAR, + aetherModels, + checkAetherProviderStatus, + makePendingAetherProvider, +} from "./AetherProvider.ts"; + +const decodeAetherSettings = Schema.decodeSync(AetherSettings); + +const enabledSettings = decodeAetherSettings({}); +const keyedEnvironment: NodeJS.ProcessEnv = { [AETHER_API_KEY_ENV_VAR]: "test-key" }; + +const respondingClient = (handler: (request: HttpClientRequest.HttpClientRequest) => Response) => + HttpClient.make((request) => + Effect.succeed(HttpClientResponse.fromWeb(request, handler(request))), + ); + +const failingClient = () => + HttpClient.make((request) => + Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + cause: new Error("connection refused"), + }), + }), + ), + ); + +describe("aetherModels", () => { + it("appends custom models to the vendored catalog with empty capabilities", () => { + const models = aetherModels(decodeAetherSettings({ customModels: ["codex/gpt-6-preview"] })); + const custom = models.find((model) => model.slug === "codex/gpt-6-preview"); + expect(custom).toEqual({ + slug: "codex/gpt-6-preview", + name: "codex/gpt-6-preview", + isCustom: true, + capabilities: { optionDescriptors: [] }, + }); + // The vendored catalog still leads the list, with its default intact. + expect(models.filter((model) => model.isDefault)).toHaveLength(1); + }); + + it("offers no reasoning-effort descriptor for claude-haiku-4-5 (in no effort group)", () => { + const haiku = aetherModels(enabledSettings).find( + (model) => model.slug === "claude-code/claude-haiku-4-5", + ); + expect(haiku).toBeDefined(); + expect(haiku?.capabilities).toEqual({ optionDescriptors: [] }); + }); +}); + +describe("makePendingAetherProvider", () => { + it.effect("returns a disabled snapshot when settings.enabled is false", () => + Effect.gen(function* () { + const snapshot = yield* makePendingAetherProvider(decodeAetherSettings({ enabled: false })); + expect(snapshot.enabled).toBe(false); + expect(snapshot.installed).toBe(false); + expect(snapshot.message).toContain("disabled"); + expect(snapshot.availability).toBe("unavailable"); + }), + ); + + it.effect("returns a pending snapshot carrying the vendored catalog by default", () => + Effect.gen(function* () { + const snapshot = yield* makePendingAetherProvider(enabledSettings); + // T6 flips enabled/installed back on: until the turn protocol exists, + // the pending snapshot must be unselectable on every client — the + // composer keys on enabled && isAvailable, mobile on + // enabled/installed/auth — so all gate flags hold at once. + expect(snapshot.enabled).toBe(false); + expect(snapshot.installed).toBe(false); + expect(snapshot.availability).toBe("unavailable"); + expect(snapshot.unavailableReason).toBeTruthy(); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.version).toBeNull(); + expect(snapshot.message).toContain("not been checked"); + expect(snapshot.models.length).toBeGreaterThan(0); + }), + ); +}); + +describe("checkAetherProviderStatus", () => { + it.effect("reports disabled without touching the network", () => + Effect.gen(function* () { + // A failing client proves the disabled branch never issues a request. + const snapshot = yield* checkAetherProviderStatus( + decodeAetherSettings({ enabled: false }), + keyedEnvironment, + ).pipe(Effect.provideService(HttpClient.HttpClient, failingClient())); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.message).toContain("disabled"); + }), + ); + + it.effect("reports unauthenticated with a clear reason when no key is configured", () => + Effect.gen(function* () { + const snapshot = yield* checkAetherProviderStatus(enabledSettings, {}).pipe( + Effect.provideService(HttpClient.HttpClient, failingClient()), + ); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.auth.status).toBe("unauthenticated"); + expect(snapshot.message).toContain(AETHER_API_KEY_ENV_VAR); + }), + ); + + it.effect("treats a blank key as missing", () => + Effect.gen(function* () { + const snapshot = yield* checkAetherProviderStatus(enabledSettings, { + [AETHER_API_KEY_ENV_VAR]: " ", + }).pipe(Effect.provideService(HttpClient.HttpClient, failingClient())); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.auth.status).toBe("unauthenticated"); + }), + ); + + it.effect("reports an invalid key on 401", () => + Effect.gen(function* () { + const snapshot = yield* checkAetherProviderStatus(enabledSettings, keyedEnvironment).pipe( + Effect.provideService( + HttpClient.HttpClient, + respondingClient(() => new Response(null, { status: 401 })), + ), + ); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.auth.status).toBe("unauthenticated"); + expect(snapshot.message).toContain("Invalid Aether API key"); + }), + ); + + it.effect("reports the HTTP status on any other non-2xx response", () => + Effect.gen(function* () { + const snapshot = yield* checkAetherProviderStatus(enabledSettings, keyedEnvironment).pipe( + Effect.provideService( + HttpClient.HttpClient, + respondingClient(() => new Response(null, { status: 503 })), + ), + ); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.auth.status).toBe("unknown"); + expect(snapshot.message).toContain("HTTP 503"); + }), + ); + + it.effect("reports unreachable on transport failure", () => + Effect.gen(function* () { + const snapshot = yield* checkAetherProviderStatus(enabledSettings, keyedEnvironment).pipe( + Effect.provideService(HttpClient.HttpClient, failingClient()), + ); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.message).toContain("Couldn't reach the Aether API"); + }), + ); + + it.effect("reports an unexpected payload when /profile is not JSON", () => + Effect.gen(function* () { + const snapshot = yield* checkAetherProviderStatus(enabledSettings, keyedEnvironment).pipe( + Effect.provideService( + HttpClient.HttpClient, + respondingClient(() => new Response("not json", { status: 200 })), + ), + ); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.message).toContain("unexpected /profile payload"); + }), + ); + + it.effect("reports ready with the account email on a healthy probe", () => + Effect.gen(function* () { + let seen: HttpClientRequest.HttpClientRequest | undefined; + const snapshot = yield* checkAetherProviderStatus( + decodeAetherSettings({ apiBaseUrl: "https://api.example.test/" }), + keyedEnvironment, + ).pipe( + Effect.provideService( + HttpClient.HttpClient, + respondingClient((request) => { + seen = request; + return Response.json({ email: "dev@example.test" }); + }), + ), + ); + expect(seen?.url).toBe("https://api.example.test/profile"); + expect(seen?.headers["authorization"]).toBe("Bearer test-key"); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.auth).toEqual({ + status: "authenticated", + type: "aether", + email: "dev@example.test", + }); + expect(snapshot.message).toBe("Connected to Aether as dev@example.test."); + // T6 flips this: until the turn protocol exists, even a healthy, + // authenticated instance must stay unselectable on every client — + // availability for clients that honor it, enabled/installed for the + // ones (mobile) that do not. + expect(snapshot.availability).toBe("unavailable"); + expect(snapshot.unavailableReason).toBeTruthy(); + expect(snapshot.enabled).toBe(false); + expect(snapshot.installed).toBe(false); + }), + ); + + it.effect("reports ready without an email when the profile omits it", () => + Effect.gen(function* () { + const snapshot = yield* checkAetherProviderStatus(enabledSettings, keyedEnvironment).pipe( + Effect.provideService( + HttpClient.HttpClient, + respondingClient(() => Response.json({})), + ), + ); + expect(snapshot.status).toBe("disabled"); + expect(snapshot.auth).toEqual({ status: "authenticated", type: "aether" }); + expect(snapshot.message).toBe("Connected to Aether."); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/AetherProvider.ts b/apps/server/src/provider/Layers/AetherProvider.ts new file mode 100644 index 000000000000..883d3338c0b0 --- /dev/null +++ b/apps/server/src/provider/Layers/AetherProvider.ts @@ -0,0 +1,301 @@ +/** + * AetherProvider — snapshot/probe helpers for the Aether cloud-task driver. + * + * Aether is a cloud API, not a local CLI: `installed` is always `true`, + * `version` is always `null`, and the probe is a single authenticated + * `GET {apiBaseUrl}/profile`. Models never come from the wire — Aether has no + * runtime catalog endpoint — so every draft path (pending, disabled, error, + * ready) carries the vendored platform catalog plus the instance's custom + * models. + * + * @module provider/Layers/AetherProvider + */ +import type { AetherSettings, ModelCapabilities, ServerProviderModel } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest } from "effect/unstable/http"; + +import { createModelCapabilities } from "@t3tools/shared/model"; +import { + buildSelectOptionDescriptor, + buildServerProvider, + providerModelsFromSettings, + type ServerProviderDraft, +} from "../providerSnapshot.ts"; +import { + AETHER_AGENT_TYPES, + AETHER_DEFAULT_AGENT_TYPE, + AETHER_PLATFORM_CATALOG, + defaultReasoningEffortForModel, + reasoningEffortsForModel, + type AetherAgentType, +} from "./aether/vendored/catalog.ts"; + +const AETHER_PRESENTATION = { + displayName: "Aether", + showInteractionModeToggle: true, +} as const; + +/** Sensitive instance environment variable carrying the Aether API key. */ +export const AETHER_API_KEY_ENV_VAR = "AETHER_API_KEY"; + +const PROBE_TIMEOUT_MS = 10_000; + +/** + * The subset of the Aether `GET /profile` response the probe reads. The full + * response (`handlers.ProfileResponse`) carries id/email/display_name/ + * onboarding_completed/created_at/updated_at and deliberately no billing + * fields; only `email` feeds the probe message. + */ +const AetherProfileResponse = Schema.Struct({ + email: Schema.optional(Schema.String), +}); +const decodeAetherProfile = Schema.decodeUnknownEffect(AetherProfileResponse); + +function titleCaseEffort(value: string): string { + switch (value) { + case "xhigh": + return "Extra High"; + default: + return value.charAt(0).toUpperCase() + value.slice(1); + } +} + +function aetherModelCapabilities(agentType: AetherAgentType, modelSlug: string): ModelCapabilities { + const efforts = reasoningEffortsForModel(agentType, modelSlug); + const defaultEffort = defaultReasoningEffortForModel(agentType, modelSlug); + return createModelCapabilities({ + optionDescriptors: + efforts.length === 0 + ? [] + : [ + buildSelectOptionDescriptor({ + id: "reasoningEffort", + label: "Reasoning effort", + options: efforts.map((effort) => ({ + value: effort, + label: titleCaseEffort(effort), + ...(effort === defaultEffort ? { isDefault: true } : {}), + })), + }), + ], + }); +} + +/** + * The vendored catalog flattened into t3 model entries. Slugs are the stable + * composite `/` (they key preferences and thread + * selections). Exactly one entry is the default: the default agent type's + * default model. + */ +export function aetherCatalogModels(): ReadonlyArray { + const models: Array = []; + for (const agentType of AETHER_AGENT_TYPES) { + const agent = AETHER_PLATFORM_CATALOG.agents[agentType]; + for (const model of agent.models) { + const isDefault = + agentType === AETHER_DEFAULT_AGENT_TYPE && model.slug === agent.defaultModel; + models.push({ + slug: `${agentType}/${model.slug}`, + name: model.name, + subProvider: agent.label, + isCustom: false, + ...(isDefault ? { isDefault: true } : {}), + capabilities: aetherModelCapabilities(agentType, model.slug), + }); + } + } + return models; +} + +const EMPTY_CAPABILITIES: ModelCapabilities = createModelCapabilities({ optionDescriptors: [] }); + +/** + * All models for one instance: the vendored catalog plus the instance's + * user-added custom models (no capabilities — Aether accepts or rejects them + * at dispatch time; the vendored catalog only goes stale until the next sync). + */ +export function aetherModels(aetherSettings: AetherSettings): ReadonlyArray { + return providerModelsFromSettings( + aetherCatalogModels(), + aetherSettings.customModels, + EMPTY_CAPABILITIES, + ); +} + +/** The API key from a merged instance environment, or undefined when absent/blank. */ +export function readAetherApiKey(environment: NodeJS.ProcessEnv): string | undefined { + const key = environment[AETHER_API_KEY_ENV_VAR]?.trim(); + return key !== undefined && key.length > 0 ? key : undefined; +} + +const MISSING_KEY_MESSAGE = `No Aether API key configured. Add a sensitive ${AETHER_API_KEY_ENV_VAR} environment variable to this provider instance.`; + +/** + * T6 removes this gate. Until the turn protocol exists (T3–T6), any + * selectable Aether snapshot routes turns into the not-implemented adapter. + * The composer resolves send availability from `enabled && isAvailable`, and + * mobile's model options check only enabled/installed/auth — so the gate must + * hold on ALL of them at once, per the ServerProvider contract that + * `availability: "unavailable"` snapshots set `enabled: false` and + * `installed: false`. Probes still run so key validation surfaces in + * settings via status/auth/message. + */ +const gateUntilTurnProtocol = (base: ServerProviderDraft): ServerProviderDraft => ({ + ...base, + availability: "unavailable", + unavailableReason: "Aether driver preview: sessions arrive in a later update.", +}); + +/** Instant zero-I/O draft published while the first probe runs. */ +export const makePendingAetherProvider = ( + aetherSettings: AetherSettings, +): Effect.Effect => + Effect.gen(function* () { + const checkedAt = yield* Effect.map(DateTime.now, DateTime.formatIso); + const models = aetherModels(aetherSettings); + + if (!aetherSettings.enabled) { + return gateUntilTurnProtocol( + buildServerProvider({ + presentation: AETHER_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Aether is disabled in T3 Code settings.", + }, + }), + ); + } + + return gateUntilTurnProtocol( + buildServerProvider({ + presentation: AETHER_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Aether provider status has not been checked in this session yet.", + }, + }), + ); + }); + +/** + * Probe the Aether API: `GET {apiBaseUrl}/profile` with the instance's + * `AETHER_API_KEY` as a bearer token. Never fails — every outcome becomes a + * draft with an explicit status and reason. 401 (bad key) is deliberately + * distinguished from transport failures and other statuses. + */ +export const checkAetherProviderStatus = Effect.fn("checkAetherProviderStatus")(function* ( + aetherSettings: AetherSettings, + environment: NodeJS.ProcessEnv, +): Effect.fn.Return { + const checkedAt = DateTime.formatIso(yield* DateTime.now); + const models = aetherModels(aetherSettings); + + const draft = (probe: { + readonly status: "ready" | "warning" | "error"; + readonly auth: ServerProviderDraft["auth"]; + readonly message: string; + }): ServerProviderDraft => + gateUntilTurnProtocol( + buildServerProvider({ + presentation: AETHER_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + installed: false, + version: null, + status: probe.status, + auth: probe.auth, + message: probe.message, + }, + }), + ); + + if (!aetherSettings.enabled) { + return draft({ + status: "warning", + auth: { status: "unknown" }, + message: "Aether is disabled in T3 Code settings.", + }); + } + + const apiKey = readAetherApiKey(environment); + if (apiKey === undefined) { + return draft({ + status: "error", + auth: { status: "unauthenticated" }, + message: MISSING_KEY_MESSAGE, + }); + } + + const client = yield* HttpClient.HttpClient; + const baseUrl = aetherSettings.apiBaseUrl.replace(/\/+$/, ""); + const request = HttpClientRequest.get(`${baseUrl}/profile`).pipe( + HttpClientRequest.setHeader("accept", "application/json"), + HttpClientRequest.setHeader("authorization", `Bearer ${apiKey}`), + ); + + const responseExit = yield* Effect.exit( + client.execute(request).pipe(Effect.timeout(PROBE_TIMEOUT_MS)), + ); + if (Exit.isFailure(responseExit)) { + return draft({ + status: "error", + auth: { status: "unknown" }, + message: `Couldn't reach the Aether API at ${baseUrl}. Check the API base URL and your network connection.`, + }); + } + + const response = responseExit.value; + if (response.status === 401) { + return draft({ + status: "error", + auth: { status: "unauthenticated" }, + message: + "Invalid Aether API key. Update the AETHER_API_KEY environment variable on this instance.", + }); + } + if (response.status < 200 || response.status >= 300) { + return draft({ + status: "error", + auth: { status: "unknown" }, + message: `Aether API returned HTTP ${response.status} from ${baseUrl}/profile.`, + }); + } + + const profileExit = yield* Effect.exit(response.json.pipe(Effect.flatMap(decodeAetherProfile))); + if (Exit.isFailure(profileExit)) { + return draft({ + status: "error", + auth: { status: "unknown" }, + message: "Aether API returned an unexpected /profile payload.", + }); + } + + const profile = profileExit.value; + const email = profile.email?.trim(); + return draft({ + status: "ready", + auth: { + status: "authenticated", + type: "aether", + ...(email ? { email } : {}), + }, + message: email ? `Connected to Aether as ${email}.` : "Connected to Aether.", + }); +}); diff --git a/apps/server/src/provider/Layers/aether/vendored/README.md b/apps/server/src/provider/Layers/aether/vendored/README.md new file mode 100644 index 000000000000..ec92c9afa9f5 --- /dev/null +++ b/apps/server/src/provider/Layers/aether/vendored/README.md @@ -0,0 +1,32 @@ +# Vendored Aether knowledge + +Aether ships no runtime catalog/tool-display endpoint, so the AetherDriver +vendors the small, slow-moving pieces it needs from the Aether monorepo. +These files are hand-ported TypeScript with **no runtime dependency on the +Aether repo** — they go stale until someone re-syncs them. + +| File | Source of truth (Aether monorepo) | +| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `catalog.ts` | `packages/domain-types/src/generated/platform-catalog.ts` (generated from `catalog.yaml` by `tools/catalog-gen`) — codex + claude-code agent families only | +| `canonicalItemType.ts` | `packages/domain-types/src/canonical-item-type.ts` + `packages/workspace-protocol/src/messages.ts` (`CanonicalItemTypeSchema`) | +| `toolDisplay.ts` | `packages/tool-display/src/parse.ts` (`parseFileChanges`, `fileChangeDiff` + helpers) and `packages/tool-display/src/diff.ts` (`diffLines`, `parseUnifiedDiff`) | + +## Sync recipe + +1. Check out the Aether monorepo at the ref you want to sync against. +2. `catalog.ts`: diff `packages/domain-types/src/generated/platform-catalog.ts` + against `AETHER_PLATFORM_CATALOG` here. Copy over the `codex` and + `claude-code` agent entries (models + `defaultModel`) and their + `reasoningEffort` groups verbatim. Other agent families (opencode, cursor, + hardware presets) are deliberately not vendored. +3. `canonicalItemType.ts`: diff the `CanonicalItemTypeSchema` enum in + `packages/workspace-protocol/src/messages.ts`. Add any new value to + `AETHER_CANONICAL_ITEM_TYPES` and give it an explicit entry in + `TOOL_LIFECYCLE_BY_AETHER_ITEM_TYPE` (unmapped values classify as + `dynamic_tool_call`, never a new string). +4. `toolDisplay.ts`: diff `packages/tool-display/src/parse.ts` (the + file-change section) and `packages/tool-display/src/diff.ts` (`diffLines`, + `parseUnifiedDiff`). Port changes, keeping the field-alias handling in + `normalizeChange` and the multi-file `splitUnifiedDiff` behavior intact. + Aether-only imports (`@aether/domain-types` `DiffLine`) stay inlined here. +5. Run the colocated `*.test.ts` suites in this directory. diff --git a/apps/server/src/provider/Layers/aether/vendored/canonicalItemType.test.ts b/apps/server/src/provider/Layers/aether/vendored/canonicalItemType.test.ts new file mode 100644 index 000000000000..18a053aa3ce7 --- /dev/null +++ b/apps/server/src/provider/Layers/aether/vendored/canonicalItemType.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { isToolLifecycleItemType } from "@t3tools/contracts"; + +import { + AETHER_CANONICAL_ITEM_TYPES, + toolLifecycleItemTypeFromAether, + type AetherCanonicalItemType, +} from "./canonicalItemType.ts"; + +// One expectation per Aether canonical item type — exhaustive by +// construction: the Record type errors if a value of the vendored enum is +// missing here, and the loop below proves the enum has no extra members. +const EXPECTED: Record = { + user_message: "dynamic_tool_call", + assistant_message: "dynamic_tool_call", + reasoning: "dynamic_tool_call", + plan: "dynamic_tool_call", + command_execution: "command_execution", + file_change: "file_change", + // t3's ToolLifecycleItemType has no `file_read`; unmapped → dynamic_tool_call. + file_read: "dynamic_tool_call", + mcp_tool_call: "mcp_tool_call", + dynamic_tool_call: "dynamic_tool_call", + collab_agent_tool_call: "collab_agent_tool_call", + web_search: "web_search", + web_fetch: "dynamic_tool_call", + image_view: "image_view", + task_tracking: "dynamic_tool_call", + subagent_invocation: "collab_agent_tool_call", + review_entered: "dynamic_tool_call", + review_exited: "dynamic_tool_call", + context_compaction: "dynamic_tool_call", + error: "dynamic_tool_call", + unknown: "dynamic_tool_call", +}; + +describe("toolLifecycleItemTypeFromAether", () => { + it("classifies every Aether canonical item type into t3's closed union", () => { + for (const itemType of AETHER_CANONICAL_ITEM_TYPES) { + const mapped = toolLifecycleItemTypeFromAether(itemType); + expect(mapped).toBe(EXPECTED[itemType]); + expect(isToolLifecycleItemType(mapped)).toBe(true); + } + }); + + it("maps subagent_invocation to collab_agent_tool_call", () => { + expect(toolLifecycleItemTypeFromAether("subagent_invocation")).toBe("collab_agent_tool_call"); + }); + + it("never invents a new string for values outside the vendored enum", () => { + expect(toolLifecycleItemTypeFromAether("some_future_type")).toBe("dynamic_tool_call"); + expect(toolLifecycleItemTypeFromAether("")).toBe("dynamic_tool_call"); + }); +}); diff --git a/apps/server/src/provider/Layers/aether/vendored/canonicalItemType.ts b/apps/server/src/provider/Layers/aether/vendored/canonicalItemType.ts new file mode 100644 index 000000000000..3625f5642a32 --- /dev/null +++ b/apps/server/src/provider/Layers/aether/vendored/canonicalItemType.ts @@ -0,0 +1,88 @@ +/** + * Vendored Aether canonical item types and their classification into t3's + * closed 7-value `ToolLifecycleItemType` union. + * + * Ported from the Aether monorepo's `packages/domain-types/src/canonical-item-type.ts` + * (wire twin: `packages/workspace-protocol/src/messages.ts` `CanonicalItemTypeSchema`) + * — see the README in this directory for the sync recipe. + * + * Classification rules: + * - types t3 also has keep their name (`command_execution`, `file_change`, + * `mcp_tool_call`, `dynamic_tool_call`, `collab_agent_tool_call`, + * `web_search`, `image_view`) + * - `subagent_invocation` → `collab_agent_tool_call` + * - everything else → `dynamic_tool_call` — NEVER a new string. This + * includes `file_read`, which t3's `ToolLifecycleItemType` union does not + * contain. + * + * @module provider/Layers/aether/vendored/canonicalItemType + */ +import type { ToolLifecycleItemType } from "@t3tools/contracts"; + +/** The full Aether `CanonicalItemType` enum, verbatim from the wire schema. */ +export const AETHER_CANONICAL_ITEM_TYPES = [ + "user_message", + "assistant_message", + "reasoning", + "plan", + "command_execution", + "file_change", + "file_read", + "mcp_tool_call", + "dynamic_tool_call", + "collab_agent_tool_call", + "web_search", + "web_fetch", + "image_view", + "task_tracking", + "subagent_invocation", + "review_entered", + "review_exited", + "context_compaction", + "error", + "unknown", +] as const; +export type AetherCanonicalItemType = (typeof AETHER_CANONICAL_ITEM_TYPES)[number]; + +/** + * Total static map: every Aether canonical item type resolves to exactly one + * t3 tool-lifecycle type. The `Record` is deliberately exhaustive so adding a + * value to `AETHER_CANONICAL_ITEM_TYPES` without classifying it is a compile + * error. + */ +const TOOL_LIFECYCLE_BY_AETHER_ITEM_TYPE: Record = { + user_message: "dynamic_tool_call", + assistant_message: "dynamic_tool_call", + reasoning: "dynamic_tool_call", + plan: "dynamic_tool_call", + command_execution: "command_execution", + file_change: "file_change", + file_read: "dynamic_tool_call", + mcp_tool_call: "mcp_tool_call", + dynamic_tool_call: "dynamic_tool_call", + collab_agent_tool_call: "collab_agent_tool_call", + web_search: "web_search", + web_fetch: "dynamic_tool_call", + image_view: "image_view", + task_tracking: "dynamic_tool_call", + subagent_invocation: "collab_agent_tool_call", + review_entered: "dynamic_tool_call", + review_exited: "dynamic_tool_call", + context_compaction: "dynamic_tool_call", + error: "dynamic_tool_call", + unknown: "dynamic_tool_call", +}; + +const isAetherCanonicalItemType = (value: string): value is AetherCanonicalItemType => + (AETHER_CANONICAL_ITEM_TYPES as ReadonlyArray).includes(value); + +/** + * Classify an Aether tool-card item type into t3's tool-lifecycle union. + * Accepts any string (the value crosses the wire untrusted); anything outside + * the vendored enum classifies as `dynamic_tool_call`. + */ +export function toolLifecycleItemTypeFromAether(itemType: string): ToolLifecycleItemType { + return isAetherCanonicalItemType(itemType) + ? TOOL_LIFECYCLE_BY_AETHER_ITEM_TYPE[itemType] + : "dynamic_tool_call"; +} diff --git a/apps/server/src/provider/Layers/aether/vendored/catalog.test.ts b/apps/server/src/provider/Layers/aether/vendored/catalog.test.ts new file mode 100644 index 000000000000..910bdc360d5f --- /dev/null +++ b/apps/server/src/provider/Layers/aether/vendored/catalog.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + AETHER_AGENT_TYPES, + AETHER_DEFAULT_AGENT_TYPE, + AETHER_PLATFORM_CATALOG, + defaultReasoningEffortForModel, + reasoningEffortsForModel, +} from "./catalog.ts"; + +describe("AETHER_PLATFORM_CATALOG", () => { + it("keeps the codex and claude families", () => { + expect(AETHER_AGENT_TYPES).toEqual(["codex", "claude-code"]); + expect(AETHER_DEFAULT_AGENT_TYPE).toBe("codex"); + }); + + it("declares a default model that exists in each agent's model list", () => { + for (const agentType of AETHER_AGENT_TYPES) { + const agent = AETHER_PLATFORM_CATALOG.agents[agentType]; + expect(agent.models.length).toBeGreaterThan(0); + expect(agent.models.map((model) => model.slug)).toContain(agent.defaultModel); + } + }); + + it("offers only selectable reasoning efforts, non-empty for grouped models", () => { + for (const agentType of AETHER_AGENT_TYPES) { + const group = AETHER_PLATFORM_CATALOG.reasoningEffort[agentType]; + const selectable = new Set(group.selectableOptions); + for (const model of AETHER_PLATFORM_CATALOG.agents[agentType].models) { + const efforts = reasoningEffortsForModel(agentType, model.slug); + const inModelGroup = group.modelOptions.some((entry) => entry.models.includes(model.slug)); + // Grouped models offer their group; a model in NO group gets NO + // efforts — upstream (domain-types model.ts / Go catalog) resolves + // nil options and the Aether API 400s any reasoning_effort for it. + if (inModelGroup) { + expect(efforts.length).toBeGreaterThan(0); + } else { + expect(efforts).toEqual([]); + } + // Every offered effort must be selectable — Aether 422s on the rest. + for (const effort of efforts) { + expect(selectable.has(effort)).toBe(true); + } + } + } + }); + + it("resolves a default effort inside each model's offered set, none when empty", () => { + for (const agentType of AETHER_AGENT_TYPES) { + for (const model of AETHER_PLATFORM_CATALOG.agents[agentType].models) { + const efforts = reasoningEffortsForModel(agentType, model.slug); + const defaultEffort = defaultReasoningEffortForModel(agentType, model.slug); + if (efforts.length === 0) { + expect(defaultEffort).toBeUndefined(); + } else { + expect(defaultEffort).toBeDefined(); + expect(efforts).toContain(defaultEffort); + } + } + } + }); + + it("offers no reasoning efforts for claude-haiku-4-5 (in no effort group)", () => { + expect(reasoningEffortsForModel("claude-code", "claude-haiku-4-5")).toEqual([]); + expect(defaultReasoningEffortForModel("claude-code", "claude-haiku-4-5")).toBeUndefined(); + }); + + it("restricts gpt-5.6-luna to its narrower effort group", () => { + expect(reasoningEffortsForModel("codex", "gpt-5.6-luna")).toEqual([ + "max", + "xhigh", + "high", + "medium", + "low", + ]); + }); +}); diff --git a/apps/server/src/provider/Layers/aether/vendored/catalog.ts b/apps/server/src/provider/Layers/aether/vendored/catalog.ts new file mode 100644 index 000000000000..6768f6376468 --- /dev/null +++ b/apps/server/src/provider/Layers/aether/vendored/catalog.ts @@ -0,0 +1,141 @@ +/** + * Vendored Aether platform catalog (codex + claude-code families). + * + * Ported from the Aether monorepo's generated + * `packages/domain-types/src/generated/platform-catalog.ts` — see the README + * in this directory for the sync recipe. Aether exposes no runtime catalog + * endpoint, so the driver compiles this knowledge in and it goes stale until + * the next sync. + * + * @module provider/Layers/aether/vendored/catalog + */ + +export interface AetherCatalogModel { + readonly slug: string; + readonly name: string; + readonly runtimeModel: string; +} + +export interface AetherCatalogAgent { + readonly label: string; + readonly defaultModel: string; + readonly models: ReadonlyArray; +} + +export interface AetherReasoningEffortGroup { + readonly default: string; + readonly options: ReadonlyArray; + readonly selectableOptions: ReadonlyArray; + readonly modelOptions: ReadonlyArray<{ + readonly models: ReadonlyArray; + readonly options: ReadonlyArray; + }>; +} + +export const AETHER_AGENT_TYPES = ["codex", "claude-code"] as const; +export type AetherAgentType = (typeof AETHER_AGENT_TYPES)[number]; + +export const AETHER_DEFAULT_AGENT_TYPE: AetherAgentType = "codex"; + +export const AETHER_PLATFORM_CATALOG: { + readonly agents: Record; + readonly reasoningEffort: Record; +} = { + agents: { + codex: { + label: "Codex", + defaultModel: "gpt-5.6-sol", + models: [ + { slug: "gpt-5.6-sol", name: "GPT-5.6 Sol", runtimeModel: "gpt-5.6-sol" }, + { slug: "gpt-5.6-terra", name: "GPT-5.6 Terra", runtimeModel: "gpt-5.6-terra" }, + { slug: "gpt-5.6-luna", name: "GPT-5.6 Luna", runtimeModel: "gpt-5.6-luna" }, + { slug: "gpt-5.5", name: "GPT-5.5", runtimeModel: "gpt-5.5" }, + { slug: "gpt-5.4", name: "GPT-5.4", runtimeModel: "gpt-5.4" }, + { slug: "gpt-5.4-mini", name: "GPT-5.4 Mini", runtimeModel: "gpt-5.4-mini" }, + { + slug: "gpt-5.3-codex-spark", + name: "GPT-5.3 Codex Spark", + runtimeModel: "gpt-5.3-codex-spark", + }, + ], + }, + "claude-code": { + label: "Claude Code", + defaultModel: "claude-opus-5", + models: [ + { slug: "claude-fable-5", name: "Claude Fable 5", runtimeModel: "claude-fable-5" }, + { slug: "claude-sonnet-5", name: "Claude Sonnet 5", runtimeModel: "claude-sonnet-5" }, + { slug: "claude-opus-5", name: "Claude Opus 5", runtimeModel: "claude-opus-5" }, + { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", runtimeModel: "claude-sonnet-4-6" }, + { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5", runtimeModel: "claude-haiku-4-5" }, + ], + }, + }, + reasoningEffort: { + codex: { + default: "xhigh", + options: ["ultra", "max", "xhigh", "high", "medium", "low"], + selectableOptions: ["ultra", "max", "xhigh", "high", "medium", "low"], + modelOptions: [ + { + models: ["gpt-5.6-sol", "gpt-5.6-terra"], + options: ["ultra", "max", "xhigh", "high", "medium", "low"], + }, + { + models: ["gpt-5.6-luna"], + options: ["max", "xhigh", "high", "medium", "low"], + }, + { + models: ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark"], + options: ["xhigh", "high", "medium", "low"], + }, + ], + }, + "claude-code": { + default: "xhigh", + options: ["low", "medium", "high", "xhigh", "max", "ultrathink", "ultracode"], + selectableOptions: ["low", "medium", "high", "xhigh", "max", "ultrathink", "ultracode"], + modelOptions: [ + { + models: ["claude-fable-5", "claude-opus-5", "claude-sonnet-5"], + options: ["low", "medium", "high", "xhigh", "max", "ultrathink", "ultracode"], + }, + { + models: ["claude-sonnet-4-6"], + options: ["low", "medium", "high", "ultrathink"], + }, + ], + }, + }, +}; + +/** + * Reasoning-effort choices offered for one catalog model, mirroring the Aether + * source of truth (`domain-types/src/model.ts` `resolveSelectableEfforts` and + * Go `catalog.ReasoningEffortSelectableOptions`): when `modelOptions` groups + * exist, a model in NO group gets NO efforts — the Aether API rejects any + * `reasoning_effort` for such models. Otherwise the agent-wide `options` + * apply. The result is intersected with `selectableOptions` — Aether rejects + * non-selectable values too, so only selectable efforts may ever be surfaced + * to the picker. + */ +export function reasoningEffortsForModel( + agentType: AetherAgentType, + modelSlug: string, +): ReadonlyArray { + const group = AETHER_PLATFORM_CATALOG.reasoningEffort[agentType]; + const modelGroup = group.modelOptions.find((entry) => entry.models.includes(modelSlug)); + const options = group.modelOptions.length > 0 ? (modelGroup?.options ?? []) : group.options; + const selectable = new Set(group.selectableOptions); + return options.filter((option) => selectable.has(option)); +} + +/** The default reasoning effort for one catalog model, constrained to its selectable set. */ +export function defaultReasoningEffortForModel( + agentType: AetherAgentType, + modelSlug: string, +): string | undefined { + const group = AETHER_PLATFORM_CATALOG.reasoningEffort[agentType]; + const efforts = reasoningEffortsForModel(agentType, modelSlug); + return efforts.includes(group.default) ? group.default : efforts[0]; +} diff --git a/apps/server/src/provider/Layers/aether/vendored/toolDisplay.test.ts b/apps/server/src/provider/Layers/aether/vendored/toolDisplay.test.ts new file mode 100644 index 000000000000..327ba4d66ca4 --- /dev/null +++ b/apps/server/src/provider/Layers/aether/vendored/toolDisplay.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { fileChangeDiff, parseFileChanges } from "./toolDisplay.ts"; + +const CODEX_UNIFIED_DIFF = [ + "diff --git a/src/a.ts b/src/a.ts", + "index 1111111..2222222 100644", + "--- a/src/a.ts", + "+++ b/src/a.ts", + "@@ -1,2 +1,2 @@", + "-const a = 1;", + "+const a = 2;", + " export default a;", + "diff --git a/src/b.ts b/src/b.ts", + "new file mode 100644", + "--- /dev/null", + "+++ b/src/b.ts", + "@@ -0,0 +1 @@", + "+export const b = true;", +].join("\n"); + +describe("parseFileChanges", () => { + it("merges the codex files[] input stubs with the buffered result diff", () => { + // Codex persists `input.files` entries with only `{ path, op }` while the + // RESULT carries the buffered unified diff in `{ files, output }`. + const input = { + files: [ + { path: "src/a.ts", op: "modify" }, + { path: "src/b.ts", op: "create" }, + ], + }; + const result = JSON.stringify({ + files: [ + { path: "src/a.ts", op: "modify" }, + { path: "src/b.ts", op: "create" }, + ], + output: CODEX_UNIFIED_DIFF, + }); + + const changes = parseFileChanges(input, result); + expect(changes).toHaveLength(2); + expect(changes[0]?.path).toBe("src/a.ts"); + expect(changes[0]?.diff).toContain("-const a = 1;"); + expect(changes[0]?.diff).toContain("+const a = 2;"); + expect(changes[1]?.path).toBe("src/b.ts"); + expect(changes[1]?.diff).toContain("+export const b = true;"); + }); + + it("parses the claude single-edit old_string/new_string shape", () => { + const changes = parseFileChanges( + { + file_path: "src/index.ts", + old_string: "const value = 1;", + new_string: "const value = 2;", + }, + undefined, + ); + + expect(changes).toHaveLength(1); + expect(changes[0]).toEqual({ + path: "src/index.ts", + oldText: "const value = 1;", + newText: "const value = 2;", + diff: null, + }); + }); + + it("splits a multi-file unified diff result into one change per file", () => { + const changes = parseFileChanges({}, JSON.stringify({ output: CODEX_UNIFIED_DIFF })); + + expect(changes.map((change) => change.path)).toEqual(["src/a.ts", "src/b.ts"]); + expect(changes[0]?.diff?.startsWith("diff --git a/src/a.ts b/src/a.ts")).toBe(true); + expect(changes[1]?.diff?.startsWith("diff --git a/src/b.ts b/src/b.ts")).toBe(true); + }); + + it("treats a claude Write content body as new-file content", () => { + const changes = parseFileChanges( + { file_path: "notes.md", content: "hello\nworld\n" }, + undefined, + ); + + expect(changes).toHaveLength(1); + expect(changes[0]?.newText).toBe("hello\nworld\n"); + expect(changes[0]?.oldText).toBeNull(); + }); + + it("returns an empty list when neither input nor result carries a change shape", () => { + expect(parseFileChanges({}, undefined)).toEqual([]); + expect(parseFileChanges({}, "not json")).toEqual([]); + }); +}); + +describe("fileChangeDiff", () => { + it("derives an LCS diff from an old/new text pair", () => { + const summary = fileChangeDiff({ + path: "src/index.ts", + oldText: "const value = 1;\nexport default value;", + newText: "const value = 2;\nexport default value;", + diff: null, + }); + + expect(summary).not.toBeNull(); + expect(summary?.added).toBe(1); + expect(summary?.removed).toBe(1); + expect( + summary?.lines.some((line) => line.kind === "add" && line.text === "const value = 2;"), + ).toBe(true); + }); + + it("parses a unified diff body with line numbers", () => { + const summary = fileChangeDiff({ + path: "src/a.ts", + oldText: null, + newText: null, + diff: ["@@ -1,2 +1,2 @@", "-const a = 1;", "+const a = 2;", " export default a;"].join("\n"), + }); + + expect(summary).not.toBeNull(); + expect(summary?.added).toBe(1); + expect(summary?.removed).toBe(1); + expect(summary?.lines[0]).toEqual({ + kind: "del", + text: "const a = 1;", + oldLine: 1, + newLine: null, + }); + }); + + it("returns null for a path-only stub", () => { + expect( + fileChangeDiff({ path: "src/a.ts", oldText: null, newText: null, diff: null }), + ).toBeNull(); + }); +}); diff --git a/apps/server/src/provider/Layers/aether/vendored/toolDisplay.ts b/apps/server/src/provider/Layers/aether/vendored/toolDisplay.ts new file mode 100644 index 000000000000..857de703af46 --- /dev/null +++ b/apps/server/src/provider/Layers/aether/vendored/toolDisplay.ts @@ -0,0 +1,496 @@ +/** + * Vendored Aether tool-display file-change parsing. + * + * Self-contained port of `parseFileChanges` / `fileChangeDiff` (and the + * helpers they need) from the Aether monorepo's + * `packages/tool-display/src/parse.ts`, plus the two diff-engine entries they + * depend on (`diffLines`, `parseUnifiedDiff`) from + * `packages/tool-display/src/diff.ts`. The Aether-only `DiffLine` import + * (`@aether/domain-types`) is inlined as a local type. See the README in this + * directory for the sync recipe. + * + * The parsers turn a tool message's opaque `input` / `result` payload (both + * untrusted JSON) into trusted shapes; each returns `null`/empty when the + * payload does not match so the caller falls back to a generic rendering + * VISIBLY — never a silent blank. + * + * @module provider/Layers/aether/vendored/toolDisplay + */ + +// The opaque input map for a tool call. +export type ToolInput = { [key: string]: unknown }; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +// A trimmed non-empty string at any of the given keys, else undefined. +function readString(record: Record, ...keys: string[]): string | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.trim().length > 0) return value.trim(); + } + return undefined; +} + +// A raw string (may be empty) at any of the given keys, else undefined. Content +// bodies must not be trimmed — leading whitespace is significant in diffs/code. +function readText(record: Record, ...keys: string[]): string | undefined { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string") return value; + } + return undefined; +} + +function tryJson(value: string | undefined): unknown { + if (value === undefined) return undefined; + try { + return JSON.parse(value); + } catch { + return undefined; + } +} + +const PATH_KEYS = ["path", "file_path", "filePath", "file", "filename", "notebook_path"]; + +// --------------------------------------------------------------------------- +// file_change (diff) +// --------------------------------------------------------------------------- + +// A single edited file. Exactly one of (oldText+newText) | diff carries the +// change; `path` may be absent for a bare unified diff. +export type FileChange = { + path: string | null; + oldText: string | null; + newText: string | null; + diff: string | null; +}; + +function looksLikeUnifiedDiff(value: string): boolean { + return ( + value.startsWith("diff --git ") || + value.startsWith("--- ") || + value.startsWith("+++ ") || + value.startsWith("@@ ") || + value.includes("\n@@ ") + ); +} + +type ChangeOp = "create" | "delete" | "modify"; + +// The operation for a change, read from the normalized `op` (codex client shape) +// or the raw `kind.type` (raw provider shape). Anything else → "modify". +function readChangeOp(record: Record): ChangeOp { + const kind = isRecord(record["kind"]) ? record["kind"] : null; + const raw = ( + readString(record, "op") ?? + (kind ? readString(kind, "type") : undefined) ?? + "" + ).toLowerCase(); + switch (raw) { + case "create": + case "created": + case "add": + case "added": + case "new": + return "create"; + case "delete": + case "deleted": + case "remove": + case "removed": + return "delete"; + default: + return "modify"; + } +} + +function normalizeChange(record: Record): FileChange | null { + const path = readString(record, ...PATH_KEYS) ?? null; + const op = readChangeOp(record); + const oldText = readText(record, "oldContent", "old_content", "before", "old_string") ?? null; + const newText = + readText(record, "newContent", "new_content", "after", "content", "new_string") ?? null; + const rawDiff = readText(record, "diff") ?? null; + // A `diff` field that isn't a unified diff and has no old/new pair is a plain + // file body. Its DIRECTION depends on the op: a DELETE body is the OLD file + // content (renders as red removals); an add/modify body is NEW content. + const plainBody = + rawDiff !== null && oldText === null && newText === null && !looksLikeUnifiedDiff(rawDiff) + ? rawDiff + : null; + const diff = plainBody !== null ? null : rawDiff; + const resolvedOld = oldText ?? (plainBody !== null && op === "delete" ? plainBody : null); + const resolvedNew = newText ?? (plainBody !== null && op !== "delete" ? plainBody : null); + + if (path === null && resolvedOld === null && resolvedNew === null && diff === null) return null; + return { path, oldText: resolvedOld, newText: resolvedNew, diff }; +} + +// True when a change carries something the diff view can actually render +// (old/new text or a diff), not merely a path/op stub. +function hasDiffContent(change: FileChange): boolean { + return change.oldText !== null || change.newText !== null || change.diff !== null; +} + +// Read the file-change list from a `{ files: [...] }` / `{ changes: [...] }` +// record (input or the structured result envelope). +function changeListOf(record: Record): FileChange[] { + const list = Array.isArray(record["changes"]) + ? record["changes"] + : Array.isArray(record["files"]) + ? record["files"] + : null; + if (list === null) return []; + return list + .filter(isRecord) + .map(normalizeChange) + .filter((c): c is FileChange => c !== null); +} + +// Extract the path from a `diff --git a/ b/` header line. +function gitHeaderPath(line: string): string | null { + const match = /^diff --git a\/(.+?) b\/(.+)$/.exec(line); + if (match === null) return null; + // Prefer the b-side (destination) path; fall back to the a-side. + return (match[2] ?? match[1] ?? "").trim() || null; +} + +// Split a (possibly multi-file) unified diff into one FileChange per file, each +// carrying that file's diff segment. Codex buffers the fileChange/outputDelta +// stream into a single `output: "diff --git ..."` string on the result. A diff +// with no `diff --git` header is a single-file diff kept whole (path unknown). +function splitUnifiedDiff(output: string): FileChange[] { + if (!output.includes("diff --git ")) { + return [{ path: null, oldText: null, newText: null, diff: output }]; + } + + const lines = output.split("\n"); + const changes: FileChange[] = []; + let path: string | null = null; + let buffer: string[] = []; + + const flush = () => { + if (buffer.length === 0) return; + changes.push({ path, oldText: null, newText: null, diff: buffer.join("\n") }); + buffer = []; + }; + + for (const line of lines) { + if (line.startsWith("diff --git ")) { + flush(); + path = gitHeaderPath(line); + } + buffer.push(line); + } + flush(); + + return changes; +} + +// A unified-diff string carried on the result's `output` (or `diff`) field. +function resultUnifiedDiff(record: Record): string | null { + const output = readText(record, "output", "diff"); + return output !== null && output !== undefined && looksLikeUnifiedDiff(output) ? output : null; +} + +// All file changes recoverable from the structured result: per-file +// `files`/`changes` entries plus any buffered unified-diff string on +// `output`/`diff` (Codex streams fileChange/outputDelta into one +// `{ files: [{path, op}], output: "diff --git ..." }` string). +function resultChanges(result: string | undefined): FileChange[] { + const parsed = tryJson(result); + if (parsed === undefined) return []; + + const listed = isRecord(parsed) + ? changeListOf(parsed) + : Array.isArray(parsed) + ? parsed + .filter(isRecord) + .map(normalizeChange) + .filter((c): c is FileChange => c !== null) + : []; + + const unified = isRecord(parsed) ? resultUnifiedDiff(parsed) : null; + const fromDiff = unified !== null ? splitUnifiedDiff(unified) : []; + + return [...listed, ...fromDiff]; +} + +// Fill in diff content on path/op-only input changes from the structured result, +// matched by path. Codex file-change completions persist `input.files` entries +// with only `{ path, op }` while the RESULT carries the buffered diff in +// `{ files, output }` — without this merge the diff would be lost. +function mergeResultDiff(inputChanges: FileChange[], result: string | undefined): FileChange[] { + if (inputChanges.every(hasDiffContent)) return inputChanges; + + const fromResult = resultChanges(result).filter(hasDiffContent); + if (fromResult.length === 0) return inputChanges; + + const byPath = new Map(); + for (const change of fromResult) { + if (change.path !== null) byPath.set(change.path, change); + } + // A single result diff with no header path enriches a single path-only input. + const pathlessDiff = fromResult.find((c) => c.path === null) ?? null; + + return inputChanges.map((change) => { + if (hasDiffContent(change)) return change; + const enriched = + (change.path !== null ? byPath.get(change.path) : undefined) ?? + (inputChanges.length === 1 ? (pathlessDiff ?? undefined) : undefined); + return enriched === undefined + ? change + : { + path: change.path ?? enriched.path, + oldText: enriched.oldText, + newText: enriched.newText, + diff: enriched.diff, + }; + }); +} + +export function parseFileChanges(input: ToolInput, result: string | undefined): FileChange[] { + const listed = changeListOf(input); + if (listed.length > 0) return mergeResultDiff(listed, result); + + const single = normalizeChange(input); + if (single !== null) return mergeResultDiff([single], result); + + // No file-change shape in the input at all: the result carries the changes + // (per-file entries and/or a buffered unified-diff `output`). + return resultChanges(result); +} + +// --------------------------------------------------------------------------- +// Diff engine (trimmed port of tool-display/src/diff.ts) +// --------------------------------------------------------------------------- + +// Inlined from `@aether/domain-types` — the Aether-only import is stripped. +export type DiffLine = { + kind: "add" | "del" | "context"; + text: string; + oldLine: number | null; + newLine: number | null; + noTrailingNewline?: boolean; +}; + +// `lines` is empty and `oversized` true when the input exceeded the LCS bound: +// the quadratic table is never allocated; only approximate +/− counts are +// reported. +export type DiffSummary = { + added: number; + removed: number; + lines: DiffLine[]; + oversized: boolean; +}; + +// The per-side line cap on the LCS input. A quadratic DP table is only safe for +// modest edits; above this we refuse the line-level diff and summarize instead. +export const MAX_DIFF_INPUT_LINES = 2000; + +function splitLines(code: string): string[] { + if (code === "") return []; + const lines = code.split("\n"); + // Drop the single trailing empty entry produced by a final newline. + if (lines.length > 1 && lines[lines.length - 1] === "") lines.pop(); + return lines; +} + +// O(n+m) approximate +/− counts via multiset line differences — used only for +// oversized inputs where running the quadratic LCS is unsafe. +function approximateLineChanges(a: string[], b: string[]): { added: number; removed: number } { + const counts = new Map(); + for (const line of a) counts.set(line, (counts.get(line) ?? 0) + 1); + let added = 0; + for (const line of b) { + const remaining = counts.get(line) ?? 0; + if (remaining > 0) counts.set(line, remaining - 1); + else added++; + } + let removed = 0; + for (const remaining of counts.values()) removed += remaining; + return { added, removed }; +} + +// Longest-common-subsequence line diff. Bounded: for inputs above +// MAX_DIFF_INPUT_LINES on either side, returns an oversized summary WITHOUT +// ever allocating the O(n·m) table. All the non-null assertions below only +// discharge index-access widening — the LCS table is (n+1)×(m+1) and every +// access stays within i∈0..n / j∈0..m. +export function diffLines(oldCode: string, newCode: string): DiffSummary { + const a = splitLines(oldCode); + const b = splitLines(newCode); + const n = a.length; + const m = b.length; + + if (n > MAX_DIFF_INPUT_LINES || m > MAX_DIFF_INPUT_LINES) { + const { added, removed } = approximateLineChanges(a, b); + return { added, removed, lines: [], oversized: true }; + } + + const lcs: number[][] = Array.from({ length: n + 1 }, () => + Array.from({ length: m + 1 }, () => 0), + ); + for (let i = n - 1; i >= 0; i--) { + const cur = lcs[i]!; + const next = lcs[i + 1]!; + for (let j = m - 1; j >= 0; j--) { + cur[j] = a[i] === b[j] ? next[j + 1]! + 1 : Math.max(next[j]!, cur[j + 1]!); + } + } + + const lines: DiffLine[] = []; + let added = 0; + let removed = 0; + let i = 0; + let j = 0; + while (i < n && j < m) { + if (a[i] === b[j]) { + lines.push({ kind: "context", text: a[i]!, oldLine: i + 1, newLine: j + 1 }); + i++; + j++; + } else if (lcs[i + 1]![j]! >= lcs[i]![j + 1]!) { + lines.push({ kind: "del", text: a[i]!, oldLine: i + 1, newLine: null }); + removed++; + i++; + } else { + lines.push({ kind: "add", text: b[j]!, oldLine: null, newLine: j + 1 }); + added++; + j++; + } + } + while (i < n) { + lines.push({ kind: "del", text: a[i]!, oldLine: i + 1, newLine: null }); + removed++; + i++; + } + while (j < m) { + lines.push({ kind: "add", text: b[j]!, oldLine: null, newLine: j + 1 }); + added++; + j++; + } + + return { added, removed, lines, oversized: false }; +} + +// "@@ -oldStart[,oldCount] +newStart[,newCount] @@ ..." — the unified hunk +// header. A missing count is git's shorthand for 1. +const HUNK_HEADER_RE = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/; + +function parseHunkHeader( + line: string, +): { oldStart: number; oldCount: number; newStart: number; newCount: number } | null { + const match = HUNK_HEADER_RE.exec(line); + if (match === null) return null; + return { + oldStart: Number(match[1]), + oldCount: match[2] === undefined ? 1 : Number(match[2]), + newStart: Number(match[3]), + newCount: match[4] === undefined ? 1 : Number(match[4]), + }; +} + +type HunkLineClass = + | { kind: "add" | "del" | "context"; text: string } + | { kind: "no-newline-marker" } + | { kind: "unclassifiable" }; + +function classifyHunkLine(raw: string): HunkLineClass { + if (raw.startsWith("\\ ")) return { kind: "no-newline-marker" }; + if (raw.startsWith("+")) return { kind: "add", text: raw.slice(1) }; + if (raw.startsWith("-")) return { kind: "del", text: raw.slice(1) }; + if (raw.startsWith(" ")) return { kind: "context", text: raw.slice(1) }; + return { kind: "unclassifiable" }; +} + +// Build the numbered DiffLine for a classified content line, advancing the +// old/new cursors it consumes: an add consumes a new-side number, a del an +// old-side number, context one of each. +function numberedLine( + cls: { kind: "add" | "del" | "context"; text: string }, + cursor: { oldLine: number; newLine: number }, +): DiffLine { + const { oldLine, newLine } = cursor; + switch (cls.kind) { + case "add": + cursor.newLine = newLine + 1; + return { kind: "add", text: cls.text, oldLine: null, newLine }; + case "del": + cursor.oldLine = oldLine + 1; + return { kind: "del", text: cls.text, oldLine, newLine: null }; + case "context": + cursor.oldLine = oldLine + 1; + cursor.newLine = newLine + 1; + return { kind: "context", text: cls.text, oldLine, newLine }; + } +} + +// Parse an already-unified diff string into typed lines (no algorithm needed — +// the +/-/space prefix is the classification). This is the LENIENT entry to +// the diff engine — the input is an untrusted tool payload, so an +// unclassifiable line renders VISIBLY as context instead of failing, and a +// hunk's declared counts are advisory. +export function parseUnifiedDiff(diff: string): DiffSummary { + const lines: DiffLine[] = []; + let added = 0; + let removed = 0; + // Line-number cursors. A hunk header re-anchors them to its declared starts; + // input with no hunk header (a bare fragment) numbers from 1 as if the whole + // fragment were one hunk. + const cursor = { oldLine: 1, newLine: 1 }; + for (const raw of diff.split("\n")) { + if (raw.startsWith("@@")) { + const header = parseHunkHeader(raw); + if (header !== null) { + cursor.oldLine = header.oldStart; + cursor.newLine = header.newStart; + } + continue; + } + if ( + raw.startsWith("diff --git ") || + raw.startsWith("index ") || + raw.startsWith("--- ") || + raw.startsWith("+++ ") + ) { + continue; + } + const cls = classifyHunkLine(raw); + if (cls.kind === "no-newline-marker") { + // A marker about the preceding line, not a content line. Folded onto + // that line (dropped when nothing precedes). + const last = lines.at(-1); + if (last !== undefined) last.noTrailingNewline = true; + } else if (cls.kind === "unclassifiable") { + // Lenient policy: non-empty junk stays visible as context; empty lines + // are structural, not content. + if (raw.length > 0) lines.push(numberedLine({ kind: "context", text: raw }, cursor)); + } else { + lines.push(numberedLine(cls, cursor)); + if (cls.kind === "add") added++; + if (cls.kind === "del") removed++; + } + } + return { added, removed, lines, oversized: false }; +} + +// Derive a renderable diff from a normalized FileChange, or null when it only +// carries a path (nothing to diff). +export function fileChangeDiff(change: FileChange): DiffSummary | null { + if (change.oldText !== null && change.newText !== null) { + return diffLines(change.oldText, change.newText); + } + if (change.diff !== null) { + return parseUnifiedDiff(change.diff); + } + if (change.newText !== null) { + // A pure creation: every line is an addition. + return diffLines("", change.newText); + } + if (change.oldText !== null) { + // A pure deletion: every line is a removal. + return diffLines(change.oldText, ""); + } + return null; +} diff --git a/apps/server/src/provider/builtInDrivers.ts b/apps/server/src/provider/builtInDrivers.ts index 791a96e1da3c..26997f7b11eb 100644 --- a/apps/server/src/provider/builtInDrivers.ts +++ b/apps/server/src/provider/builtInDrivers.ts @@ -20,6 +20,7 @@ * * @module provider/builtInDrivers */ +import { AetherDriver, type AetherDriverEnv } from "./Drivers/AetherDriver.ts"; import { ClaudeDriver, type ClaudeDriverEnv } from "./Drivers/ClaudeDriver.ts"; import { CodexDriver, type CodexDriverEnv } from "./Drivers/CodexDriver.ts"; import { CursorDriver, type CursorDriverEnv } from "./Drivers/CursorDriver.ts"; @@ -33,6 +34,7 @@ import type { AnyProviderDriver } from "./ProviderDriver.ts"; * layer must provide every service in this union. */ export type BuiltInDriversEnv = + | AetherDriverEnv | ClaudeDriverEnv | CodexDriverEnv | CursorDriverEnv @@ -50,4 +52,5 @@ export const BUILT_IN_DRIVERS: ReadonlyArray 0) return trimmed; + } + return undefined; +} + +/** Deterministic commit message: subject = first staged-summary line. */ +export function stubCommitMessage(input: { + readonly stagedSummary: string; + readonly includeBranch: boolean; +}): TextGeneration.CommitMessageGenerationResult { + const subject = sanitizeCommitSubject(firstNonEmptyLine(input.stagedSummary) ?? ""); + return { + subject, + body: "", + ...(input.includeBranch ? { branch: `feature/${sanitizeBranchFragment(subject)}` } : {}), + }; +} + +/** Deterministic PR content: title from the commit summary, body = truncated passthrough. */ +export function stubPrContent(input: { + readonly headBranch: string; + readonly commitSummary: string; + readonly diffSummary: string; +}): TextGeneration.PrContentGenerationResult { + const title = sanitizePrTitle(firstNonEmptyLine(input.commitSummary) ?? input.headBranch); + const sections = [ + input.commitSummary.trim().length > 0 + ? `## Commits\n\n${limitSection(input.commitSummary.trim(), PR_BODY_SECTION_MAX_CHARS)}` + : "", + input.diffSummary.trim().length > 0 + ? `## Changes\n\n${limitSection(input.diffSummary.trim(), PR_BODY_SECTION_MAX_CHARS)}` + : "", + ].filter((section) => section.length > 0); + return { title, body: sections.join("\n\n") }; +} + +/** Deterministic branch name: message slug + ISO date, re-sanitized as one fragment. */ +export function stubBranchName(message: string, isoDate: string): string { + return sanitizeBranchFragment(`${sanitizeBranchFragment(message)}-${isoDate}`); +} + +export function makeAetherTextGeneration(): TextGeneration.TextGeneration["Service"] { + return { + generateCommitMessage: (input) => + Effect.sync(() => + stubCommitMessage({ + stagedSummary: input.stagedSummary, + includeBranch: input.includeBranch === true, + }), + ), + generatePrContent: (input) => + Effect.sync(() => + stubPrContent({ + headBranch: input.headBranch, + commitSummary: input.commitSummary, + diffSummary: input.diffSummary, + }), + ), + generateBranchName: (input) => + DateTime.now.pipe( + Effect.map((now) => ({ + branch: stubBranchName(input.message, DateTime.formatIso(now).slice(0, 10)), + })), + ), + generateThreadTitle: (input) => + Effect.sync(() => ({ title: sanitizeThreadTitle(input.message) })), + } satisfies TextGeneration.TextGeneration["Service"]; +} diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index 8ea38c519588..1a5696430c65 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -663,6 +663,18 @@ export const OpenCodeIcon: Icon = (props) => ( ); +export const AetherIcon: Icon = ({ className, ...props }) => ( + + + + +); + export const GithubCopilotIcon: Icon = ({ className, ...props }) => ( > = { @@ -8,6 +8,7 @@ export const PROVIDER_ICON_BY_PROVIDER: Partial [ProviderDriverKind.make("opencode")]: OpenCodeIcon, [ProviderDriverKind.make("cursor")]: CursorIcon, [ProviderDriverKind.make("grok")]: GrokIcon, + [ProviderDriverKind.make("aether")]: AetherIcon, }; function isAvailableProviderOption(option: (typeof PROVIDER_OPTIONS)[number]): option is { diff --git a/apps/web/src/components/settings/ProviderModelsSection.tsx b/apps/web/src/components/settings/ProviderModelsSection.tsx index 7ae26b278657..915d471079b0 100644 --- a/apps/web/src/components/settings/ProviderModelsSection.tsx +++ b/apps/web/src/components/settings/ProviderModelsSection.tsx @@ -35,6 +35,7 @@ const CUSTOM_MODEL_PLACEHOLDER_BY_KIND: Partial>; @@ -67,6 +76,13 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = icon: OpenCodeIcon, settingsSchema: OpenCodeSettings, }, + { + value: ProviderDriverKind.make("aether"), + label: "Aether", + icon: AetherIcon, + badgeLabel: "Early Access", + settingsSchema: AetherSettings, + }, ]; export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< diff --git a/apps/web/src/lib/contextWindow.ts b/apps/web/src/lib/contextWindow.ts index 80f7d31cf2f9..3da0f4bf5e2d 100644 --- a/apps/web/src/lib/contextWindow.ts +++ b/apps/web/src/lib/contextWindow.ts @@ -38,6 +38,8 @@ export function formatProviderDisplayName(provider: string | null | undefined): return "Cursor"; case "opencode": return "OpenCode"; + case "aether": + return "Aether"; default: { // Title-case unknown driver kinds so they read reasonably. const trimmed = provider.replace(/Agent$/i, "").trim(); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4d0a76cf133b..d13d27104634 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -52,6 +52,14 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, + // T1 skeleton: the Aether adapter fails every session/turn operation until + // T3/T6 land, so the picker entry stays unavailable ("soon"), not live. + { + value: ProviderDriverKind.make("aether"), + label: "Aether", + available: false, + pickerSidebarBadge: "soon", + }, ]; export type WorkLogToolLifecycleStatus = diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 9fcd0d266dd6..351d51340471 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -132,6 +132,7 @@ const CLAUDE_DRIVER_KIND = ProviderDriverKind.make("claudeAgent"); const CURSOR_DRIVER_KIND = ProviderDriverKind.make("cursor"); const GROK_DRIVER_KIND = ProviderDriverKind.make("grok"); const OPENCODE_DRIVER_KIND = ProviderDriverKind.make("opencode"); +const AETHER_DRIVER_KIND = ProviderDriverKind.make("aether"); export const DEFAULT_MODEL = "gpt-5.6-sol"; @@ -153,6 +154,9 @@ export const DEFAULT_MODEL_BY_PROVIDER: Partial/` slug matching the vendored Aether + // platform catalog default (codex / gpt-5.6-sol). + [AETHER_DRIVER_KIND]: "codex/gpt-5.6-sol", }; /** Per-provider text generation model defaults. */ @@ -222,4 +226,5 @@ export const PROVIDER_DISPLAY_NAMES: Partial> [CURSOR_DRIVER_KIND]: "Cursor", [GROK_DRIVER_KIND]: "Grok", [OPENCODE_DRIVER_KIND]: "OpenCode", + [AETHER_DRIVER_KIND]: "Aether", }; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 17ae0e08683c..01ffa7188039 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -469,6 +469,46 @@ export const OpenCodeSettings = makeProviderSettingsSchema( ); export type OpenCodeSettings = typeof OpenCodeSettings.Type; +/** + * Default Aether API endpoint. Mirrors the production default baked into the + * Aether CLI at release time (`-X main.defaultAPIURL=https://api.runaether.dev`). + */ +export const DEFAULT_AETHER_API_BASE_URL = "https://api.runaether.dev"; + +/** + * Settings for the Aether cloud-task driver. Deliberately has no API-key + * field: the key is sensitive and must arrive via a `ProviderInstanceEnvironment` + * variable named `AETHER_API_KEY` (marked sensitive so the server stores it in + * the secret store and redacts it from client snapshots). + */ +export const AetherSettings = makeProviderSettingsSchema( + { + enabled: Schema.Boolean.pipe( + Schema.withDecodingDefault(Effect.succeed(true)), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + apiBaseUrl: makeBinaryPathSetting(DEFAULT_AETHER_API_BASE_URL).pipe( + Schema.annotateKey({ + title: "API base URL", + description: + "Aether API endpoint. The API key is read from the sensitive AETHER_API_KEY environment variable on this instance.", + providerSettingsForm: { + placeholder: DEFAULT_AETHER_API_BASE_URL, + clearWhenEmpty: "omit", + }, + }), + ), + customModels: Schema.Array(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + Schema.annotateKey({ providerSettingsForm: { hidden: true } }), + ), + }, + { + order: ["apiBaseUrl"], + }, +); +export type AetherSettings = typeof AetherSettings.Type; + export const ObservabilitySettings = Schema.Struct({ otlpTracesUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), otlpMetricsUrl: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), From 824bc87b6499df0db207cdc9469fe39ef6b73de9 Mon Sep 17 00:00:00 2001 From: Pranav Sharan Date: Sat, 8 Aug 2026 08:37:03 -0700 Subject: [PATCH 04/44] feat(aether): REST task client + adapter session core (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(aether): REST task client + adapter session core T2+T3 of the AetherDriver chain. REST client (restClient.ts + restSchemas.ts): tasks create/respond/stop/update/remove-from-queue, task read with status-probe dispatch (unknown-status forward-compat carrier; known status with malformed payload fails loudly), conversation messages/delta with pagination, projects, loose additive-tolerant schemas, tagged errors for 401/402/404/409(code+kind)/4xx/transport/ decode, 30s timeout, caller-driven retry via client_message_id. Session core: startSession preflight (clean tree, pushed+synced branch, actionable remediations), repo→project resolution through the shared normalizeGitRemoteUrl (ssh/https equivalence; ambiguity listed loudly), resumeCursor {schemaVersion, taskId, latestSequence, turnLedger} validated against task existence AND project membership (404 → typed session-not-found), stopSession/stopAll as pure disconnects, minimal readThread snapshot via vendored classification. sendTurn and the event pump stay typed not-implemented until T4-T6. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * fix(aether): stopAll emits one graceful session.exited per thread Review: bulk disconnect cleared the session map silently, so ingestion never saw the per-session exit events it uses to clear active-turn and liveness state — stale running UI after ProviderService teardown. Both disconnect paths now share one pure-disconnect helper; test pins one graceful exit per thread. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h --------- Co-authored-by: Claude Fable 5 --- .../src/provider/Drivers/AetherDriver.ts | 45 +- .../src/provider/Layers/AetherAdapter.test.ts | 780 ++++++++++++++++++ .../src/provider/Layers/AetherAdapter.ts | 524 +++++++++++- .../src/provider/Layers/AetherProvider.ts | 3 +- .../provider/Layers/ProviderRegistry.test.ts | 17 + .../provider/Layers/aether/restClient.test.ts | 637 ++++++++++++++ .../src/provider/Layers/aether/restClient.ts | 516 ++++++++++++ .../src/provider/Layers/aether/restSchemas.ts | 480 +++++++++++ apps/server/src/server.ts | 7 +- 9 files changed, 2983 insertions(+), 26 deletions(-) create mode 100644 apps/server/src/provider/Layers/AetherAdapter.test.ts create mode 100644 apps/server/src/provider/Layers/aether/restClient.test.ts create mode 100644 apps/server/src/provider/Layers/aether/restClient.ts create mode 100644 apps/server/src/provider/Layers/aether/restSchemas.ts diff --git a/apps/server/src/provider/Drivers/AetherDriver.ts b/apps/server/src/provider/Drivers/AetherDriver.ts index a960ab584022..b5938c358041 100644 --- a/apps/server/src/provider/Drivers/AetherDriver.ts +++ b/apps/server/src/provider/Drivers/AetherDriver.ts @@ -1,25 +1,34 @@ /** * AetherDriver — `ProviderDriver` for Aether cloud tasks. * - * T1 skeleton: a real snapshot (probe = authenticated `GET /profile`, models - * from the vendored platform catalog) over a not-yet-implemented adapter and - * deterministic text-generation stubs. There is no local binary — the driver - * talks to the Aether REST API, authenticated by the sensitive - * `AETHER_API_KEY` instance environment variable. + * A real snapshot (probe = authenticated `GET /profile`, models from the + * vendored platform catalog) over the session-core adapter (REST task client + * + git preflight; turn streaming still pending) and deterministic + * text-generation stubs. There is no local binary — the driver talks to the + * Aether REST API, authenticated by the sensitive `AETHER_API_KEY` instance + * environment variable. * * @module provider/Drivers/AetherDriver */ import { AetherSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; +import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { makeAetherTextGeneration } from "../../textGeneration/AetherTextGeneration.ts"; +import { GitVcsDriver } from "../../vcs/GitVcsDriver.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeAetherAdapter } from "../Layers/AetherAdapter.ts"; -import { checkAetherProviderStatus, makePendingAetherProvider } from "../Layers/AetherProvider.ts"; +import { makeAetherRestClient } from "../Layers/aether/restClient.ts"; +import { + checkAetherProviderStatus, + makePendingAetherProvider, + readAetherApiKey, +} from "../Layers/AetherProvider.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import { defaultProviderContinuationIdentity, @@ -47,7 +56,10 @@ const MAINTENANCE = makeManualOnlyProviderMaintenanceCapabilities({ export type AetherDriverEnv = | BackgroundPolicy.BackgroundPolicy + | Crypto.Crypto + | GitVcsDriver | HttpClient.HttpClient + | ServerConfig | ServerSettingsService; const withInstanceIdentity = @@ -78,6 +90,8 @@ export const AetherDriver: ProviderDriver = { Effect.gen(function* () { const httpClient = yield* HttpClient.HttpClient; const serverSettings = yield* ServerSettingsService; + const gitVcsDriver = yield* GitVcsDriver; + const serverConfig = yield* ServerConfig; const processEnv = mergeProviderInstanceEnvironment(environment); const continuationIdentity = defaultProviderContinuationIdentity({ driverKind: DRIVER_KIND, @@ -91,7 +105,24 @@ export const AetherDriver: ProviderDriver = { }); const effectiveConfig = { ...config, enabled } satisfies AetherSettings; - const adapter = yield* makeAetherAdapter({ instanceId }); + // Missing key is NOT a create() failure: the probe reports it and + // startSession fails loudly with the remediation — a keyless instance + // still shows a useful settings card instead of an "unavailable" shadow. + const apiKey = readAetherApiKey(processEnv); + const restClient = + apiKey === undefined + ? undefined + : makeAetherRestClient({ + apiBaseUrl: effectiveConfig.apiBaseUrl, + apiKey, + httpClient, + }); + const adapter = yield* makeAetherAdapter({ + instanceId, + defaultCwd: serverConfig.cwd, + git: gitVcsDriver, + restClient, + }); const textGeneration = makeAetherTextGeneration(); const checkProvider = checkAetherProviderStatus(effectiveConfig, processEnv).pipe( diff --git a/apps/server/src/provider/Layers/AetherAdapter.test.ts b/apps/server/src/provider/Layers/AetherAdapter.test.ts new file mode 100644 index 000000000000..4265874005b3 --- /dev/null +++ b/apps/server/src/provider/Layers/AetherAdapter.test.ts @@ -0,0 +1,780 @@ +import { describe, expect, it } from "@effect/vitest"; +import { ProviderInstanceId, ThreadId, type ProviderRuntimeEvent } from "@t3tools/contracts"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; + +import type { GitStatusDetails } from "../../vcs/GitVcsDriver.ts"; +import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; +import type { ProviderAdapterError } from "../Errors.ts"; +import { makeAetherAdapter, parseAetherResume, type AetherSessionGit } from "./AetherAdapter.ts"; +import { AetherApiNotFoundError, type AetherRestClient } from "./aether/restClient.ts"; +import type { AetherProject, AetherTask, AetherTimelineMessage } from "./aether/restSchemas.ts"; + +const instanceId = ProviderInstanceId.make("aether"); + +const testCrypto = Crypto.make({ + randomBytes: (size) => new Uint8Array(size), + digest: () => Effect.die("digest is unused in AetherAdapter tests"), +}); + +const cleanStatus: GitStatusDetails = { + isRepo: true, + hasOriginRemote: true, + isDefaultBranch: false, + branch: "feature/demo", + upstreamRef: "origin/feature/demo", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + hasUpstream: true, + aheadCount: 0, + behindCount: 0, + aheadOfDefaultCount: 0, +}; + +const gitWith = ( + status: GitStatusDetails, + originUrl: string | null = "git@github.com:acme/aether.git", +): AetherSessionGit => ({ + statusDetails: () => Effect.succeed(status), + readConfigValue: (_cwd, key) => Effect.succeed(key === "remote.origin.url" ? originUrl : null), +}); + +/** Every method defects — override exactly what a test expects to be called. */ +const unusedRestClient: AetherRestClient = { + createTask: () => Effect.die("createTask must not be called"), + respondToTask: () => Effect.die("respondToTask must not be called"), + stopTask: () => Effect.die("stopTask must not be called — stop is a pure disconnect"), + removeFromQueue: () => Effect.die("removeFromQueue must not be called"), + updateTask: () => Effect.die("updateTask must not be called"), + getTask: () => Effect.die("getTask must not be called"), + getConversationMessages: () => Effect.die("getConversationMessages must not be called"), + getConversationDelta: () => Effect.die("getConversationDelta must not be called"), + listProjects: () => Effect.die("listProjects must not be called"), + getProfile: () => Effect.die("getProfile must not be called"), +}; + +const project = (overrides?: Partial): AetherProject => ({ + id: "project-1", + name: "aether", + repo_url: "https://github.com/acme/aether", + default_branch: "main", + task_defaults: { + agent_type: "codex", + model: "gpt-5.6-sol", + interaction_mode: "default", + reasoning_effort: null, + }, + ...overrides, +}); + +const processingTask: AetherTask = { + id: "task-1", + project_id: "project-1", + name: "Fix the flaky test", + agent_type: "codex", + model: "gpt-5.6-sol", + interaction_mode: "default", + latest_sequence: 12, + status: "processing", + run_context: { workspace_id: "ws-1", started_at: "2026-08-08T10:01:00Z" }, +}; + +const startInput = (overrides?: { + readonly resumeCursor?: unknown; + readonly modelSelection?: { readonly instanceId: ProviderInstanceId; readonly model: string }; + readonly threadId?: ThreadId; +}) => ({ + threadId: overrides?.threadId ?? ThreadId.make("thread-1"), + cwd: "/repo", + runtimeMode: "full-access" as const, + ...(overrides?.resumeCursor !== undefined ? { resumeCursor: overrides.resumeCursor } : {}), + ...(overrides?.modelSelection !== undefined ? { modelSelection: overrides.modelSelection } : {}), +}); + +const withAdapter = ( + options: { + readonly git?: AetherSessionGit; + readonly restClient?: AetherRestClient | undefined; + readonly hasRestClient?: boolean; + }, + use: (adapter: ProviderAdapterShape) => Effect.Effect, +) => + Effect.gen(function* () { + const adapter = yield* makeAetherAdapter({ + instanceId, + defaultCwd: "/default-cwd", + git: options.git ?? gitWith(cleanStatus), + restClient: + options.hasRestClient === false ? undefined : (options.restClient ?? unusedRestClient), + }); + return yield* use(adapter); + }).pipe(Effect.scoped, Effect.provideService(Crypto.Crypto, testCrypto)); + +const expectStartFailure = (options: { + readonly git?: AetherSessionGit; + readonly restClient?: AetherRestClient; + readonly hasRestClient?: boolean; + readonly resumeCursor?: unknown; +}) => + withAdapter(options, (adapter) => + Effect.flip( + adapter.startSession( + startInput( + options.resumeCursor !== undefined ? { resumeCursor: options.resumeCursor } : undefined, + ), + ), + ), + ); + +describe("parseAetherResume", () => { + it("parses a current-version cursor and preserves the opaque turn ledger", () => { + expect( + parseAetherResume({ + schemaVersion: 1, + taskId: "task-1", + latestSequence: 12, + turnLedger: [{ turn: 1 }], + }), + ).toEqual({ + schemaVersion: 1, + taskId: "task-1", + latestSequence: 12, + turnLedger: [{ turn: 1 }], + }); + }); + + it("returns undefined for foreign shapes instead of failing", () => { + expect(parseAetherResume(undefined)).toBeUndefined(); + expect(parseAetherResume(null)).toBeUndefined(); + expect(parseAetherResume("task-1")).toBeUndefined(); + expect(parseAetherResume({ schemaVersion: 2, taskId: "t", latestSequence: 1 })).toBeUndefined(); + expect( + parseAetherResume({ schemaVersion: 1, taskId: " ", latestSequence: 1 }), + ).toBeUndefined(); + expect( + parseAetherResume({ schemaVersion: 1, taskId: "t", latestSequence: Number.NaN }), + ).toBeUndefined(); + }); +}); + +describe("AetherAdapter startSession", () => { + it.effect("fails loudly when the instance has no API key", () => + Effect.gen(function* () { + const error = yield* expectStartFailure({ hasRestClient: false }); + expect(error._tag).toBe("ProviderAdapterRequestError"); + expect(error.message).toContain("AETHER_API_KEY"); + }), + ); + + it.effect("refuses a dirty working tree, naming the remediation", () => + Effect.gen(function* () { + const error = yield* expectStartFailure({ + git: gitWith({ ...cleanStatus, hasWorkingTreeChanges: true }), + }); + expect(error._tag).toBe("ProviderAdapterValidationError"); + expect(error.message).toContain("Commit or stash"); + }), + ); + + it.effect("refuses a non-repo cwd", () => + Effect.gen(function* () { + const error = yield* expectStartFailure({ + git: gitWith({ ...cleanStatus, isRepo: false }), + }); + expect(error._tag).toBe("ProviderAdapterValidationError"); + expect(error.message).toContain("not a git repository"); + }), + ); + + it.effect("refuses a detached HEAD", () => + Effect.gen(function* () { + const error = yield* expectStartFailure({ + git: gitWith({ ...cleanStatus, branch: null }), + }); + expect(error._tag).toBe("ProviderAdapterValidationError"); + expect(error.message).toContain("detached HEAD"); + }), + ); + + it.effect("refuses a branch with no upstream", () => + Effect.gen(function* () { + const error = yield* expectStartFailure({ + git: gitWith({ ...cleanStatus, hasUpstream: false, upstreamRef: null }), + }); + expect(error._tag).toBe("ProviderAdapterValidationError"); + expect(error.message).toContain("git push -u origin feature/demo"); + }), + ); + + it.effect("refuses an unpushed (ahead) branch", () => + Effect.gen(function* () { + const error = yield* expectStartFailure({ + git: gitWith({ ...cleanStatus, aheadCount: 2 }), + }); + expect(error._tag).toBe("ProviderAdapterValidationError"); + expect(error.message).toContain("ahead of its upstream by 2"); + }), + ); + + it.effect("refuses a behind branch", () => + Effect.gen(function* () { + const error = yield* expectStartFailure({ + git: gitWith({ ...cleanStatus, behindCount: 3 }), + }); + expect(error._tag).toBe("ProviderAdapterValidationError"); + expect(error.message).toContain("behind its upstream by 3"); + }), + ); + + it.effect("refuses a cwd without an origin remote", () => + Effect.gen(function* () { + const error = yield* expectStartFailure({ git: gitWith(cleanStatus, null) }); + expect(error._tag).toBe("ProviderAdapterValidationError"); + expect(error.message).toContain("no 'origin' remote"); + }), + ); + + it.effect("matches an ssh local origin against an https project repo_url", () => + withAdapter( + { + // ssh origin (default in gitWith) vs the project's https repo_url — + // raw string comparison would miss; the shared normalizer must not. + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + }, + }, + (adapter) => + Effect.gen(function* () { + const session = yield* adapter.startSession(startInput()); + expect(session.status).toBe("ready"); + expect(session.cwd).toBe("/repo"); + // Model defaults to the project task_defaults composite slug. + expect(session.model).toBe("codex/gpt-5.6-sol"); + // No task yet — no resume cursor to persist. + expect(session.resumeCursor).toBeUndefined(); + expect(yield* adapter.hasSession(session.threadId)).toBe(true); + expect(yield* adapter.listSessions()).toHaveLength(1); + }), + ), + ); + + it.effect("fails with the link-repo remediation when no project matches", () => + Effect.gen(function* () { + const error = yield* expectStartFailure({ + restClient: { + ...unusedRestClient, + listProjects: () => + Effect.succeed([project({ repo_url: "https://github.com/acme/other" })]), + }, + }); + expect(error._tag).toBe("ProviderAdapterValidationError"); + expect(error.message).toContain("Link or import the repository in Aether"); + }), + ); + + it.effect("lists the candidates when several projects share the repo", () => + Effect.gen(function* () { + const error = yield* expectStartFailure({ + restClient: { + ...unusedRestClient, + listProjects: () => + Effect.succeed([project(), project({ id: "project-2", name: "aether-fork" })]), + }, + }); + expect(error._tag).toBe("ProviderAdapterValidationError"); + expect(error.message).toContain("'aether' (project-1)"); + expect(error.message).toContain("'aether-fork' (project-2)"); + }), + ); + + it.effect("uses the explicit model selection over the project defaults", () => + withAdapter( + { + restClient: { ...unusedRestClient, listProjects: () => Effect.succeed([project()]) }, + }, + (adapter) => + Effect.gen(function* () { + const session = yield* adapter.startSession( + startInput({ modelSelection: { instanceId, model: "claude-code/claude-opus-5" } }), + ); + expect(session.model).toBe("claude-code/claude-opus-5"); + }), + ), + ); + + it.effect("rejects a model selection bound to another instance", () => + withAdapter({}, (adapter) => + Effect.gen(function* () { + const error = yield* Effect.flip( + adapter.startSession( + startInput({ + modelSelection: { + instanceId: ProviderInstanceId.make("aether_other"), + model: "codex/gpt-5.6-sol", + }, + }), + ), + ); + expect(error._tag).toBe("ProviderAdapterValidationError"); + expect(error.message).toContain("aether_other"); + }), + ), + ); + + it.effect("validates a resume cursor's task and keeps the cursor's sequence", () => + Effect.gen(function* () { + const requestedTaskIds: Array = []; + yield* withAdapter( + { + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + getTask: (taskId) => + Effect.sync(() => { + requestedTaskIds.push(taskId); + }).pipe(Effect.as(processingTask)), + }, + }, + (adapter) => + Effect.gen(function* () { + const session = yield* adapter.startSession( + startInput({ + resumeCursor: { schemaVersion: 1, taskId: "task-1", latestSequence: 7 }, + }), + ); + // The CURSOR's sequence is the safe replay point — never + // fast-forwarded to the task row's fresher latest_sequence. + expect(session.resumeCursor).toEqual({ + schemaVersion: 1, + taskId: "task-1", + latestSequence: 7, + }); + }), + ); + expect(requestedTaskIds).toEqual(["task-1"]); + }), + ); + + it.effect("fails with session-not-found when the resumed task is gone", () => + Effect.gen(function* () { + const error = yield* expectStartFailure({ + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + getTask: () => + Effect.fail( + new AetherApiNotFoundError({ endpoint: "GET /tasks/{id}", detail: "task not found" }), + ), + }, + resumeCursor: { schemaVersion: 1, taskId: "task-gone", latestSequence: 7 }, + }); + expect(error._tag).toBe("ProviderAdapterSessionNotFoundError"); + }), + ); + + it.effect("rejects a resume cursor whose task belongs to another project", () => + Effect.gen(function* () { + const error = yield* expectStartFailure({ + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + getTask: () => Effect.succeed({ ...processingTask, project_id: "project-other" }), + }, + resumeCursor: { schemaVersion: 1, taskId: "task-1", latestSequence: 7 }, + }); + expect(error._tag).toBe("ProviderAdapterValidationError"); + expect(error.message).toContain("project-other"); + expect(error.message).toContain("project-1"); + }), + ); + + it.effect("round-trips an opaque turn ledger through the rebuilt resume cursor", () => + withAdapter( + { + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + getTask: () => Effect.succeed(processingTask), + }, + }, + (adapter) => + Effect.gen(function* () { + const session = yield* adapter.startSession( + startInput({ + resumeCursor: { + schemaVersion: 1, + taskId: "task-1", + latestSequence: 7, + turnLedger: [{ turn: 1, messageId: "m-1" }], + }, + }), + ); + // A ledger written by a newer build (item 10) must survive a + // startSession round-trip through this one. + expect(session.resumeCursor).toEqual({ + schemaVersion: 1, + taskId: "task-1", + latestSequence: 7, + turnLedger: [{ turn: 1, messageId: "m-1" }], + }); + }), + ), + ); + + it.effect("ignores a stale-shaped cursor and starts fresh without a task read", () => + withAdapter( + { + // getTask stays a defect: reaching it would fail the test. + restClient: { ...unusedRestClient, listProjects: () => Effect.succeed([project()]) }, + }, + (adapter) => + Effect.gen(function* () { + const session = yield* adapter.startSession( + startInput({ resumeCursor: { schemaVersion: 99, sessionId: "opencode-shaped" } }), + ); + expect(session.resumeCursor).toBeUndefined(); + }), + ), + ); +}); + +describe("AetherAdapter session lifecycle", () => { + it.effect("stopSession is a pure disconnect that emits one graceful session.exited", () => + withAdapter( + { + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + getTask: () => Effect.succeed(processingTask), + }, + }, + (adapter) => + Effect.gen(function* () { + const session = yield* adapter.startSession( + startInput({ resumeCursor: { schemaVersion: 1, taskId: "task-1", latestSequence: 7 } }), + ); + const events = yield* adapter.streamEvents.pipe( + Stream.take(1), + Stream.runCollect, + Effect.forkScoped, + ); + // stopTask on the fake defects if touched — the pure-disconnect + // invariant is asserted structurally. + yield* adapter.stopSession(session.threadId); + expect(yield* adapter.hasSession(session.threadId)).toBe(false); + const collected: ReadonlyArray = yield* Fiber.join(events); + expect(collected).toHaveLength(1); + const exited = collected[0]!; + expect(exited.type).toBe("session.exited"); + expect(exited.threadId).toBe(session.threadId); + if (exited.type === "session.exited") { + expect(exited.payload.exitKind).toBe("graceful"); + expect(exited.payload.recoverable).toBe(true); + expect(exited.payload.reason).toContain("keeps running"); + } + }), + ), + ); + + it.effect("stopSession fails for an unknown thread", () => + withAdapter({}, (adapter) => + Effect.gen(function* () { + const error = yield* Effect.flip(adapter.stopSession(ThreadId.make("thread-none"))); + expect(error._tag).toBe("ProviderAdapterSessionNotFoundError"); + }), + ), + ); + + it.effect("stopAll disconnects every session without touching the remote tasks", () => + withAdapter( + { + restClient: { ...unusedRestClient, listProjects: () => Effect.succeed([project()]) }, + }, + (adapter) => + Effect.gen(function* () { + yield* adapter.startSession(startInput({ threadId: ThreadId.make("thread-a") })); + yield* adapter.startSession(startInput({ threadId: ThreadId.make("thread-b") })); + expect(yield* adapter.listSessions()).toHaveLength(2); + const events = yield* adapter.streamEvents.pipe( + Stream.take(2), + Stream.runCollect, + Effect.forkScoped, + ); + yield* adapter.stopAll(); + expect(yield* adapter.listSessions()).toHaveLength(0); + expect(yield* adapter.hasSession(ThreadId.make("thread-a"))).toBe(false); + // Ingestion clears per-session turn/liveness state from + // session.exited — bulk teardown must emit one per thread, same as + // stopSession does. + const collected: ReadonlyArray = yield* Fiber.join(events); + expect(collected).toHaveLength(2); + const exitedThreads = collected + .filter((event) => event.type === "session.exited") + .map((event) => event.threadId) + .sort(); + expect(exitedThreads).toEqual([ThreadId.make("thread-a"), ThreadId.make("thread-b")]); + for (const event of collected) { + if (event.type === "session.exited") { + expect(event.payload.exitKind).toBe("graceful"); + expect(event.payload.recoverable).toBe(true); + } + } + }), + ), + ); + + it.effect("turn-surface methods stay loud typed not-implemented stubs", () => + withAdapter({}, (adapter) => + Effect.gen(function* () { + const threadId = ThreadId.make("thread-1"); + const sendTurn = yield* Effect.flip(adapter.sendTurn({ threadId, input: "hi" })); + expect(sendTurn._tag).toBe("ProviderAdapterRequestError"); + expect(sendTurn.message).toContain("not implemented"); + const interrupt = yield* Effect.flip(adapter.interruptTurn(threadId)); + expect(interrupt._tag).toBe("ProviderAdapterRequestError"); + const rollback = yield* Effect.flip(adapter.rollbackThread(threadId, 1)); + expect(rollback._tag).toBe("ProviderAdapterRequestError"); + }), + ), + ); +}); + +describe("AetherAdapter readThread", () => { + const timelineFixture: ReadonlyArray = [ + { + id: "u1", + role: "user", + content: "fix the bug", + deliveryStatus: "delivered", + timestamp: "t1", + sequence: 1, + }, + { + id: "a1", + role: "assistant", + variant: "text", + content: "looking", + timestamp: "t2", + sequence: 2, + }, + { + id: "tool1", + role: "assistant", + variant: "tool", + tool: { + id: "call-1", + name: "Edit", + input: { file_path: "src/app.ts", old_string: "a", new_string: "b" }, + status: "completed", + itemType: "file_change", + display: { label: "Edit src/app.ts" }, + }, + timestamp: "t3", + sequence: 3, + }, + { + id: "tool2", + role: "assistant", + variant: "tool", + tool: { + id: "call-2", + name: "Read", + input: { file_path: "src/app.ts" }, + status: "completed", + // file_read is NOT in t3's 7-value union — must classify, never leak. + itemType: "file_read", + display: { label: "Read src/app.ts" }, + }, + timestamp: "t4", + sequence: 4, + }, + { + id: "u2", + role: "user", + content: "now add a test", + deliveryStatus: "delivered", + timestamp: "t5", + sequence: 5, + }, + { + id: "a2", + role: "assistant", + variant: "thinking", + content: "planning", + isStreaming: false, + timestamp: "t6", + sequence: 6, + }, + ]; + + it.effect("returns empty turns for a session with no task yet", () => + withAdapter( + { + restClient: { ...unusedRestClient, listProjects: () => Effect.succeed([project()]) }, + }, + (adapter) => + Effect.gen(function* () { + const session = yield* adapter.startSession(startInput()); + const snapshot = yield* adapter.readThread(session.threadId); + expect(snapshot).toEqual({ threadId: session.threadId, turns: [] }); + }), + ), + ); + + it.effect("fails for an unknown thread", () => + withAdapter({}, (adapter) => + Effect.gen(function* () { + const error = yield* Effect.flip(adapter.readThread(ThreadId.make("thread-none"))); + expect(error._tag).toBe("ProviderAdapterSessionNotFoundError"); + }), + ), + ); + + it.effect("groups rows into user-opened turns with classified tool items", () => + withAdapter( + { + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + getTask: () => Effect.succeed(processingTask), + getConversationMessages: () => + Effect.succeed({ + task: processingTask, + messages: timelineFixture, + activity: [], + activeProcessingTurn: null, + latestSequence: 6, + oldestSequenceLoaded: 1, + oldestSortTimestampLoaded: "t1", + hasMoreOlder: false, + }), + }, + }, + (adapter) => + Effect.gen(function* () { + const session = yield* adapter.startSession( + startInput({ resumeCursor: { schemaVersion: 1, taskId: "task-1", latestSequence: 6 } }), + ); + const snapshot = yield* adapter.readThread(session.threadId); + expect(snapshot.turns).toHaveLength(2); + // Turn ids derive from the durable user-row ids: stable across reads. + expect(snapshot.turns[0]?.id).toBe("aether-turn-u1"); + expect(snapshot.turns[1]?.id).toBe("aether-turn-u2"); + expect(snapshot.turns[0]?.items).toHaveLength(4); + expect(snapshot.turns[1]?.items).toHaveLength(2); + const [, text, editTool, readTool] = snapshot.turns[0]!.items as ReadonlyArray< + Record + >; + expect(text).toEqual({ type: "assistant_message", id: "a1", content: "looking" }); + expect(editTool).toEqual({ + type: "tool", + id: "call-1", + itemType: "file_change", + name: "Edit", + status: "completed", + label: "Edit src/app.ts", + files: ["src/app.ts"], + }); + // file_read classifies into the closed union, never a new string. + expect(readTool).toMatchObject({ type: "tool", itemType: "dynamic_tool_call" }); + }), + ), + ); + + it.effect("walks hasMoreOlder pages so older turns are never silently dropped", () => + Effect.gen(function* () { + const cursors: Array = []; + // The endpoint serves the NEWEST page first: rows 5-6 arrive on page + // one, rows 1-4 only behind the older-page cursor. + const newestRows = timelineFixture.slice(4); + const olderRows = timelineFixture.slice(0, 4); + yield* withAdapter( + { + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + getTask: () => Effect.succeed(processingTask), + getConversationMessages: (_taskId, before) => + Effect.sync(() => { + cursors.push(before); + }).pipe( + Effect.as( + before === undefined + ? { + task: processingTask, + messages: newestRows, + activity: [], + activeProcessingTurn: null, + latestSequence: 6, + oldestSequenceLoaded: 5, + oldestSortTimestampLoaded: "t5", + hasMoreOlder: true, + } + : { + task: processingTask, + messages: olderRows, + activity: [], + activeProcessingTurn: null, + latestSequence: 6, + oldestSequenceLoaded: 1, + oldestSortTimestampLoaded: "t1", + hasMoreOlder: false, + }, + ), + ), + }, + }, + (adapter) => + Effect.gen(function* () { + const session = yield* adapter.startSession( + startInput({ + resumeCursor: { schemaVersion: 1, taskId: "task-1", latestSequence: 6 }, + }), + ); + const snapshot = yield* adapter.readThread(session.threadId); + // Both turns present, oldest first — nothing truncated. + expect(snapshot.turns).toHaveLength(2); + expect(snapshot.turns[0]?.id).toBe("aether-turn-u1"); + expect(snapshot.turns[1]?.id).toBe("aether-turn-u2"); + }), + ); + expect(cursors).toEqual([undefined, { sequence: 5, sortTimestamp: "t5" }]); + }), + ); + + it.effect("fails loudly when a page claims more older rows without a cursor", () => + withAdapter( + { + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + getTask: () => Effect.succeed(processingTask), + getConversationMessages: () => + Effect.succeed({ + task: processingTask, + messages: timelineFixture, + activity: [], + activeProcessingTurn: null, + latestSequence: 6, + oldestSequenceLoaded: null, + oldestSortTimestampLoaded: null, + hasMoreOlder: true, + }), + }, + }, + (adapter) => + Effect.gen(function* () { + const session = yield* adapter.startSession( + startInput({ resumeCursor: { schemaVersion: 1, taskId: "task-1", latestSequence: 6 } }), + ); + const error = yield* Effect.flip(adapter.readThread(session.threadId)); + expect(error._tag).toBe("ProviderAdapterRequestError"); + expect(error.message).toContain("no older-page cursor"); + }), + ), + ); +}); diff --git a/apps/server/src/provider/Layers/AetherAdapter.ts b/apps/server/src/provider/Layers/AetherAdapter.ts index d7b6b0ef4241..edf0cdfb5334 100644 --- a/apps/server/src/provider/Layers/AetherAdapter.ts +++ b/apps/server/src/provider/Layers/AetherAdapter.ts @@ -1,30 +1,65 @@ /** - * AetherAdapter — T1 skeleton adapter for the Aether cloud-task driver. + * AetherAdapter — session core for the Aether cloud-task driver. * - * Every session/turn operation fails loudly with a typed - * `ProviderAdapterRequestError` until the REST client (T3) and event mapper - * (T6) land. The event stream is a real, scope-owned queue that simply never - * receives an event yet, so consumers can subscribe without special-casing - * this driver. + * T2+T3 slice: real startSession/listSessions/hasSession/readThread/ + * stopSession/stopAll over the REST client, with the turn surface + * (sendTurn/interruptTurn/respondToUserInput/rollbackThread) still failing + * loudly until the streaming slices (build items 5–7, 9, 10) land. + * + * Design invariants (docs/aether-driver-plumbing-spec.md §2.3): + * - startSession NEVER creates a task — the task is created on the first + * sendTurn. It preflights the local checkout (clean tree on a pushed, + * in-sync branch), resolves the cwd's origin remote to exactly one + * linked Aether project, and validates a resume cursor's task still + * exists remotely. + * - stopSession / stopAll are PURE DISCONNECTS: the cloud task keeps + * running and the VM idles itself out. `/stop` is never called here. + * - resumeCursor = `{schemaVersion: 1, taskId, latestSequence, turnLedger?}`; + * t3 persists it at startSession/sendTurn returns, so a fresh session + * (no task yet) carries none. * * @module provider/Layers/AetherAdapter */ import { + EventId, ProviderDriverKind, + TurnId, type ProviderInstanceId, type ProviderRuntimeEvent, + type ProviderSession, + type ThreadId, } from "@t3tools/contracts"; +import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Queue from "effect/Queue"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; -import { ProviderAdapterRequestError, type ProviderAdapterError } from "../Errors.ts"; -import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; +import type { GitCommandError } from "@t3tools/contracts"; +import type { GitStatusDetails } from "../../vcs/GitVcsDriver.ts"; +import { + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; +import type { + ProviderAdapterShape, + ProviderThreadSnapshot, + ProviderThreadTurnSnapshot, +} from "../Services/ProviderAdapter.ts"; +import { AETHER_API_KEY_ENV_VAR } from "./AetherProvider.ts"; +import type { AetherRestClient } from "./aether/restClient.ts"; +import type { AetherProject, AetherTimelineMessage } from "./aether/restSchemas.ts"; +import { toolLifecycleItemTypeFromAether } from "./aether/vendored/canonicalItemType.ts"; +import { parseFileChanges } from "./aether/vendored/toolDisplay.ts"; const PROVIDER = ProviderDriverKind.make("aether"); -const NOT_IMPLEMENTED_DETAIL = "Aether driver: not implemented until T3/T6"; +const NOT_IMPLEMENTED_DETAIL = + "Aether driver: not implemented until the turn-lifecycle slices (build items 5-10)"; const notImplemented = (method: string): Effect.Effect => Effect.fail( @@ -35,31 +70,486 @@ const notImplemented = (method: string): Effect.Effect; + if (record.schemaVersion !== AETHER_RESUME_VERSION) { + return undefined; + } + if (typeof record.taskId !== "string" || record.taskId.trim().length === 0) { + return undefined; + } + if (typeof record.latestSequence !== "number" || !Number.isFinite(record.latestSequence)) { + return undefined; + } + return { + schemaVersion: AETHER_RESUME_VERSION, + taskId: record.taskId.trim(), + latestSequence: record.latestSequence, + ...(record.turnLedger !== undefined ? { turnLedger: record.turnLedger } : {}), + }; +} + +/** + * The two git reads the session preflight needs, structurally satisfied by + * `GitVcsDriver`. Narrowed so unit tests can fake it without the full + * driver surface. + */ +export interface AetherSessionGit { + readonly statusDetails: (cwd: string) => Effect.Effect; + readonly readConfigValue: ( + cwd: string, + key: string, + ) => Effect.Effect; +} + +export interface AetherAdapterOptions { readonly instanceId: ProviderInstanceId; -}): Effect.fn.Return, never, Scope.Scope> { + /** Fallback session cwd when the start input carries none (ServerConfig.cwd). */ + readonly defaultCwd: string; + readonly git: AetherSessionGit; + /** + * Undefined when the instance has no `AETHER_API_KEY` — startSession then + * fails loudly with the remediation instead of the driver failing create(). + */ + readonly restClient: AetherRestClient | undefined; +} + +interface AetherSessionContext { + session: ProviderSession; + readonly cwd: string; + readonly projectId: string; + /** Undefined until the first sendTurn creates the cloud task (item 7). */ + taskId: string | undefined; + latestSequence: number; + /** Opaque turn ledger carried from the resume cursor (see AetherResumeCursor). */ + turnLedger: unknown; +} + +const nowIso = Effect.map(DateTime.now, DateTime.formatIso); + +function buildAetherResumeCursor(context: AetherSessionContext): AetherResumeCursor | undefined { + return context.taskId === undefined + ? undefined + : { + schemaVersion: AETHER_RESUME_VERSION, + taskId: context.taskId, + latestSequence: context.latestSequence, + ...(context.turnLedger !== undefined ? { turnLedger: context.turnLedger } : {}), + }; +} + +/** + * Verify the local checkout is a safe mirror base for a cloud thread: a git + * repo, on a branch, with a clean tree, pushed, and in sync with its origin + * counterpart (spec §2.2 — thread start REQUIRES a clean tree on a pushed + * branch). Every failure names its exact remediation. + */ +function preflightIssue(status: GitStatusDetails, cwd: string): string | undefined { + if (!status.isRepo) { + return `'${cwd}' is not a git repository. Aether cloud tasks need a git checkout of the linked repository.`; + } + if (status.branch === null) { + return "The working tree is on a detached HEAD. Check out a branch and push it before starting an Aether cloud task."; + } + if (status.hasWorkingTreeChanges) { + return `The working tree has uncommitted changes. Commit or stash them, then push '${status.branch}', before starting an Aether cloud task — the local checkout becomes a one-way mirror of the cloud workspace.`; + } + if (!status.hasUpstream) { + return `Branch '${status.branch}' has no upstream. Push it first (git push -u origin ${status.branch}) so the cloud task starts from the same base.`; + } + if (status.aheadCount > 0) { + return `Branch '${status.branch}' is ahead of its upstream by ${status.aheadCount} commit(s). Push it before starting an Aether cloud task.`; + } + if (status.behindCount > 0) { + return `Branch '${status.branch}' is behind its upstream by ${status.behindCount} commit(s). Sync it (git pull --ff-only) before starting an Aether cloud task.`; + } + return undefined; +} + +/** Snapshot item for a timeline row — minimal, per t3's opaque snapshot type. */ +function snapshotItemFromMessage(row: AetherTimelineMessage): unknown { + if (row.role === "user") { + return { type: "user_message", id: row.id, content: row.content }; + } + switch (row.variant) { + case "text": + return { type: "assistant_message", id: row.id, content: row.content }; + case "thinking": + return { type: "reasoning", id: row.id, content: row.content }; + case "seam": + return { type: "seam", id: row.id, reason: row.seam.reason }; + case "tool": { + const itemType = toolLifecycleItemTypeFromAether(row.tool.itemType ?? "unknown"); + const files = + itemType === "file_change" + ? parseFileChanges(row.tool.input, row.tool.result) + .map((change) => change.path) + .filter((path): path is string => path !== null) + : []; + return { + type: "tool", + id: row.tool.id, + itemType, + name: row.tool.name, + status: row.tool.status, + label: row.tool.display.label, + ...(files.length > 0 ? { files } : {}), + }; + } + } +} + +/** + * Group timeline rows into turn snapshots: each user row opens a turn (its + * durable row id keys the TurnId, so snapshots are stable across reads); + * rows arriving before any user row open a synthetic leading turn. + */ +export function snapshotTurnsFromMessages( + messages: ReadonlyArray, +): ReadonlyArray { + const turns: Array<{ id: TurnId; items: Array }> = []; + for (const row of messages) { + if (row.role === "user" || turns.length === 0) { + turns.push({ id: TurnId.make(`aether-turn-${row.id}`), items: [] }); + } + turns[turns.length - 1]?.items.push(snapshotItemFromMessage(row)); + } + return turns; +} + +export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( + options: AetherAdapterOptions, +): Effect.fn.Return< + ProviderAdapterShape, + never, + Crypto.Crypto | Scope.Scope +> { + const crypto = yield* Crypto.Crypto; // Scope-owned so registry teardown shuts the stream down with the instance. const runtimeEvents = yield* Effect.acquireRelease( Queue.unbounded(), Queue.shutdown, ); + const sessions = new Map(); + + const emit = (event: ProviderRuntimeEvent) => + Queue.offer(runtimeEvents, event).pipe(Effect.asVoid); + + const randomEventId = crypto.randomUUIDv4.pipe( + Effect.map(EventId.make), + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "crypto/randomUUIDv4", + detail: "Failed to generate Aether runtime identifier.", + cause, + }), + ), + ); + + const requireRestClient = (method: string) => + options.restClient !== undefined + ? Effect.succeed(options.restClient) + : Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: `No Aether API key configured. Add a sensitive ${AETHER_API_KEY_ENV_VAR} environment variable to this provider instance.`, + }), + ); + + const toGitRequestError = (method: string) => (cause: GitCommandError) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: `Git preflight failed: ${cause.detail}`, + cause, + }); + + const toRestRequestError = (method: string) => (cause: { readonly message: string }) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: cause.message, + cause, + }); + + const ensureContext = (threadId: ThreadId) => { + const context = sessions.get(threadId); + return context !== undefined + ? Effect.succeed(context) + : Effect.fail(new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId })); + }; + + const startSession: ProviderAdapterShape["startSession"] = Effect.fn( + "startSession", + )(function* (input) { + const restClient = yield* requireRestClient("startSession"); + const cwd = input.cwd ?? options.defaultCwd; + + if ( + input.modelSelection !== undefined && + input.modelSelection.instanceId !== options.instanceId + ) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Aether model selection is bound to instance '${input.modelSelection.instanceId}', expected '${options.instanceId}'.`, + }); + } + + // (1) Mirror preflight: clean tree on a pushed, in-sync branch. + const status = yield* options.git + .statusDetails(cwd) + .pipe(Effect.mapError(toGitRequestError("startSession"))); + const issue = preflightIssue(status, cwd); + if (issue !== undefined) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue, + }); + } + + // (2) Repo → project resolution via the canonical owner/repo key. + const originUrl = yield* options.git + .readConfigValue(cwd, "remote.origin.url") + .pipe(Effect.mapError(toGitRequestError("startSession"))); + if (originUrl === null || originUrl.trim().length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `'${cwd}' has no 'origin' remote. Aether cloud tasks run against a repository linked in Aether, matched by the origin remote URL.`, + }); + } + const repoKey = normalizeGitRemoteUrl(originUrl); + const projects = yield* restClient + .listProjects() + .pipe(Effect.mapError(toRestRequestError("startSession"))); + const matches = projects.filter( + (project): project is AetherProject & { readonly repo_url: string } => + typeof project.repo_url === "string" && normalizeGitRemoteUrl(project.repo_url) === repoKey, + ); + if (matches.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `No Aether project is linked to '${originUrl.trim()}'. Link or import the repository in Aether first, then retry.`, + }); + } + if (matches.length > 1) { + const candidates = matches.map((project) => `'${project.name}' (${project.id})`).join(", "); + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Multiple Aether projects are linked to '${originUrl.trim()}': ${candidates}. Archive the duplicates in Aether or start the task from Aether directly.`, + }); + } + const project = matches[0]!; + + // (3) Resume validation: the cursor's task must still exist remotely AND + // belong to the project the cwd just resolved to — a persisted cursor is + // untrusted input, and binding a foreign project's task here would later + // mirror that repo's diffs onto this checkout. + const resume = parseAetherResume(input.resumeCursor); + let taskId: string | undefined; + let latestSequence = 0; + let turnLedger: unknown; + if (resume !== undefined) { + const task = yield* restClient.getTask(resume.taskId).pipe( + Effect.mapError((cause) => + cause._tag === "AetherApiNotFoundError" + ? new ProviderAdapterSessionNotFoundError({ + provider: PROVIDER, + threadId: input.threadId, + cause, + }) + : toRestRequestError("startSession")(cause), + ), + ); + if (task.project_id !== project.id) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `Resumed Aether task '${resume.taskId}' belongs to project '${task.project_id}', but '${cwd}' resolves to project '${project.name}' (${project.id}). The checkout and the thread's cloud task have diverged — start the thread from the task's repository checkout, or start a fresh thread here.`, + }); + } + taskId = resume.taskId; + // Keep the CURSOR's sequence, not the task row's: it is the safe + // replay point — fast-forwarding here would skip never-ingested rows. + latestSequence = resume.latestSequence; + turnLedger = resume.turnLedger; + } + + // (4) Session record. Model precedence: explicit selection, else the + // project's task defaults as the composite `/` slug. + const model = + input.modelSelection?.model ?? + `${project.task_defaults.agent_type}/${project.task_defaults.model}`; + const createdAt = yield* nowIso; + const context: AetherSessionContext = { + session: { + provider: PROVIDER, + providerInstanceId: options.instanceId, + status: "ready", + runtimeMode: input.runtimeMode, + cwd, + model, + threadId: input.threadId, + createdAt, + updatedAt: createdAt, + }, + cwd, + projectId: project.id, + taskId, + latestSequence, + turnLedger, + }; + const resumeCursor = buildAetherResumeCursor(context); + if (resumeCursor !== undefined) { + context.session = { ...context.session, resumeCursor }; + } + sessions.set(input.threadId, context); + return context.session; + }); + + // Shared pure-disconnect teardown: the cloud task keeps running and the VM + // idles itself out (spec §2.3 reaper-safety) — never POST /tasks/{id}/stop. + // Ingestion relies on one graceful session.exited per thread to clear + // active-turn/liveness state, so every disconnect path emits it. + const disconnectSession = Effect.fn("disconnectSession")(function* ( + threadId: ThreadId, + context: AetherSessionContext, + ) { + sessions.delete(threadId); + yield* emit({ + eventId: yield* randomEventId, + provider: PROVIDER, + threadId, + createdAt: yield* nowIso, + type: "session.exited", + payload: { + reason: + context.taskId === undefined + ? "Disconnected from Aether." + : "Disconnected from Aether; the cloud task keeps running.", + recoverable: true, + exitKind: "graceful", + }, + }); + }); + + const stopSession: ProviderAdapterShape["stopSession"] = Effect.fn( + "stopSession", + )(function* (threadId) { + const context = yield* ensureContext(threadId); + yield* disconnectSession(threadId, context); + }); + + const readThread: ProviderAdapterShape["readThread"] = Effect.fn( + "readThread", + )(function* (threadId) { + const context = yield* ensureContext(threadId); + if (context.taskId === undefined) { + // No task yet — the thread has no remote conversation until the first + // sendTurn creates one. + return { threadId, turns: [] } satisfies ProviderThreadSnapshot; + } + const restClient = yield* requireRestClient("readThread"); + const taskId = context.taskId; + let page = yield* restClient + .getConversationMessages(taskId) + .pipe(Effect.mapError(toRestRequestError("readThread"))); + const rows: Array = [...page.messages]; + // Walk `hasMoreOlder` back to the first turn: the endpoint serves the + // NEWEST page first, and a snapshot missing older turns would be silent + // data loss. The cursor must advance every page — a stuck cursor is a + // contract break, surfaced loudly instead of looping forever. + while (page.hasMoreOlder) { + const beforeSequence = page.oldestSequenceLoaded; + const beforeSortTimestamp = page.oldestSortTimestampLoaded; + if (beforeSequence === null || beforeSortTimestamp === null) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "readThread", + detail: `Aether conversation page for task '${taskId}' reports more older rows but carries no older-page cursor.`, + }); + } + page = yield* restClient + .getConversationMessages(taskId, { + sequence: beforeSequence, + sortTimestamp: beforeSortTimestamp, + }) + .pipe(Effect.mapError(toRestRequestError("readThread"))); + if (page.oldestSequenceLoaded !== null && page.oldestSequenceLoaded >= beforeSequence) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "readThread", + detail: `Aether conversation paging for task '${taskId}' did not advance past sequence ${beforeSequence}.`, + }); + } + rows.unshift(...page.messages); + } + return { + threadId, + turns: snapshotTurnsFromMessages(rows), + } satisfies ProviderThreadSnapshot; + }); return { provider: PROVIDER, capabilities: { + // Flips to "in-session" with the model-switch slice (build item 11). sessionModelSwitch: "unsupported", }, - startSession: () => notImplemented("startSession"), + startSession, sendTurn: () => notImplemented("sendTurn"), interruptTurn: () => notImplemented("interruptTurn"), respondToRequest: () => notImplemented("respondToRequest"), respondToUserInput: () => notImplemented("respondToUserInput"), - stopSession: () => notImplemented("stopSession"), - listSessions: () => Effect.succeed([]), - hasSession: () => Effect.succeed(false), - readThread: () => notImplemented("readThread"), + stopSession, + listSessions: () => Effect.sync(() => [...sessions.values()].map((context) => context.session)), + hasSession: (threadId) => Effect.sync(() => sessions.has(threadId)), + readThread, rollbackThread: () => notImplemented("rollbackThread"), - stopAll: () => Effect.void, + // Pure disconnect for every session; remote tasks are untouched. Each + // thread gets the same graceful session.exited stopSession emits — + // ingestion clears per-session turn/liveness state from that event. + stopAll: () => + Effect.gen(function* () { + for (const [threadId, context] of [...sessions.entries()]) { + yield* disconnectSession(threadId, context); + } + }), get streamEvents() { return Stream.fromQueue(runtimeEvents); }, diff --git a/apps/server/src/provider/Layers/AetherProvider.ts b/apps/server/src/provider/Layers/AetherProvider.ts index 883d3338c0b0..9a7fe1819817 100644 --- a/apps/server/src/provider/Layers/AetherProvider.ts +++ b/apps/server/src/provider/Layers/AetherProvider.ts @@ -49,9 +49,10 @@ const PROBE_TIMEOUT_MS = 10_000; * onboarding_completed/created_at/updated_at and deliberately no billing * fields; only `email` feeds the probe message. */ -const AetherProfileResponse = Schema.Struct({ +export const AetherProfileResponse = Schema.Struct({ email: Schema.optional(Schema.String), }); +export type AetherProfileResponse = typeof AetherProfileResponse.Type; const decodeAetherProfile = Schema.decodeUnknownEffect(AetherProfileResponse); function titleCaseEffort(value: string): string { diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index c78ecb3952a3..5f593a9f0194 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -44,6 +44,7 @@ import { ProviderRegistryLive, selectProvidersByKind, } from "./ProviderRegistry.ts"; +import * as GitVcsDriverModule from "../../vcs/GitVcsDriver.ts"; import * as ServerConfig from "../../config.ts"; import * as ServerSettingsModule from "../../serverSettings.ts"; import { readProviderStatusCache, resolveProviderStatusCachePath } from "../providerStatusCache.ts"; @@ -1470,6 +1471,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const providerRegistryLayer = ProviderRegistryLive.pipe( Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + // AetherDriver.create() yields GitVcsDriver for its session + // preflight; its own inputs come from ServerConfig below plus + // the outer NodeServices layer. + Layer.provideMerge(GitVcsDriverModule.layer), Layer.provideMerge( Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), ), @@ -1563,6 +1568,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const providerRegistryLayer = ProviderRegistryLive.pipe( Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + // AetherDriver.create() yields GitVcsDriver for its session + // preflight; its own inputs come from ServerConfig below plus + // the outer NodeServices layer. + Layer.provideMerge(GitVcsDriverModule.layer), Layer.provideMerge( Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), ), @@ -1685,6 +1694,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const providerRegistryLayer = ProviderRegistryLive.pipe( Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + // AetherDriver.create() yields GitVcsDriver for its session + // preflight; its own inputs come from ServerConfig below plus + // the outer NodeServices layer. + Layer.provideMerge(GitVcsDriverModule.layer), Layer.provideMerge( Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), ), @@ -1747,6 +1760,10 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te yield* Effect.addFinalizer(() => Scope.close(scope, Exit.void)); const providerRegistryLayer = ProviderRegistryLive.pipe( Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + // AetherDriver.create() yields GitVcsDriver for its session + // preflight; its own inputs come from ServerConfig below plus + // the outer NodeServices layer. + Layer.provideMerge(GitVcsDriverModule.layer), Layer.provideMerge( Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), ), diff --git a/apps/server/src/provider/Layers/aether/restClient.test.ts b/apps/server/src/provider/Layers/aether/restClient.test.ts new file mode 100644 index 000000000000..71323130d312 --- /dev/null +++ b/apps/server/src/provider/Layers/aether/restClient.test.ts @@ -0,0 +1,637 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { + HttpClient, + HttpClientError, + HttpClientResponse, + type HttpClientRequest, +} from "effect/unstable/http"; + +import { makeAetherRestClient, type AetherRestError } from "./restClient.ts"; + +const decodeJsonBody = Schema.decodeSync(Schema.fromJsonString(Schema.Unknown)); + +interface RecordedRequest { + readonly method: string; + readonly url: string; + readonly authorization: string | undefined; + readonly body: unknown; +} + +/** A mock HttpClient that records every request and replies via `handler`. */ +const makeRecordingClient = ( + handler: (request: HttpClientRequest.HttpClientRequest) => Response, +) => { + const requests: Array = []; + const client = HttpClient.make((request) => + Effect.sync(() => { + const bodyText = + request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : ""; + requests.push({ + method: request.method, + url: request.url, + authorization: request.headers["authorization"], + body: bodyText.length > 0 ? decodeJsonBody(bodyText) : undefined, + }); + return HttpClientResponse.fromWeb(request, handler(request)); + }), + ); + return { requests, client }; +}; + +const failingTransportClient = () => + HttpClient.make((request) => + Effect.fail( + new HttpClientError.HttpClientError({ + reason: new HttpClientError.TransportError({ + request, + cause: new Error("connection refused"), + }), + }), + ), + ); + +const makeClient = (httpClient: HttpClient.HttpClient) => + makeAetherRestClient({ + apiBaseUrl: "https://api.example.test/", + apiKey: "aether-test-key", + httpClient, + }); + +const taskBase = { + id: "task-1", + project_id: "project-1", + user_id: "user-1", + name: "Fix the flaky test", + agent_type: "codex", + model: "gpt-5.6-sol", + interaction_mode: "default", + latest_sequence: 41, + // Additive fields the client must tolerate without declaring them: + display_status: "Working", + usage: { tokens_in: 1, tokens_out: 2 }, + created_at: "2026-08-08T10:00:00Z", +}; + +const expectFailure = (effect: Effect.Effect) => Effect.flip(effect); + +describe("makeAetherRestClient", () => { + describe("happy paths", () => { + it.effect("createTask POSTs the body with bearer auth and parses the 202", () => + Effect.gen(function* () { + const { requests, client } = makeRecordingClient(() => + Response.json({ id: "task-1", name: "Fix the flaky test" }, { status: 202 }), + ); + const created = yield* makeClient(client).createTask({ + project_id: "project-1", + prompt: "Fix the flaky test", + base_branch: "main", + agent_type: "codex", + model: "gpt-5.6-sol", + interaction_mode: "default", + auto_fix_ci: false, + auto_fix_pr_comments: false, + auto_rebase: false, + }); + expect(created).toEqual({ id: "task-1", name: "Fix the flaky test" }); + expect(requests).toHaveLength(1); + expect(requests[0]?.method).toBe("POST"); + // Trailing base-URL slash is normalized away. + expect(requests[0]?.url).toBe("https://api.example.test/tasks"); + expect(requests[0]?.authorization).toBe("Bearer aether-test-key"); + expect(requests[0]?.body).toEqual({ + project_id: "project-1", + prompt: "Fix the flaky test", + base_branch: "main", + agent_type: "codex", + model: "gpt-5.6-sol", + interaction_mode: "default", + auto_fix_ci: false, + auto_fix_pr_comments: false, + auto_rebase: false, + }); + }), + ); + + it.effect("respondToTask carries client_message_id and parses message_id", () => + Effect.gen(function* () { + const { requests, client } = makeRecordingClient(() => + Response.json({ message_id: "message-9" }, { status: 202 }), + ); + const accepted = yield* makeClient(client).respondToTask("task-1", { + message: "also update the docs", + client_message_id: "3e2a4f9c-0000-4000-8000-000000000001", + }); + expect(accepted).toEqual({ message_id: "message-9" }); + expect(requests[0]?.url).toBe("https://api.example.test/tasks/task-1/respond"); + expect(requests[0]?.body).toEqual({ + message: "also update the docs", + client_message_id: "3e2a4f9c-0000-4000-8000-000000000001", + }); + }), + ); + + it.effect("stopTask sends the explicit discard_queued_messages flag", () => + Effect.gen(function* () { + const { requests, client } = makeRecordingClient(() => new Response(null, { status: 200 })); + yield* makeClient(client).stopTask("task-1", { discardQueuedMessages: true }); + expect(requests[0]?.url).toBe("https://api.example.test/tasks/task-1/stop"); + expect(requests[0]?.body).toEqual({ discard_queued_messages: true }); + }), + ); + + it.effect("removeFromQueue sends the message id", () => + Effect.gen(function* () { + const { requests, client } = makeRecordingClient(() => new Response(null, { status: 200 })); + yield* makeClient(client).removeFromQueue("task-1", "message-3"); + expect(requests[0]?.url).toBe("https://api.example.test/tasks/task-1/remove-from-queue"); + expect(requests[0]?.body).toEqual({ message_id: "message-3" }); + }), + ); + + it.effect("updateTask PUTs the full-replace body and decodes the task union", () => + Effect.gen(function* () { + const { requests, client } = makeRecordingClient(() => + Response.json({ + ...taskBase, + model: "claude-opus-5", + agent_type: "claude-code", + status: "awaiting_input", + run_context: { workspace_id: "ws-1", started_at: "2026-08-08T10:01:00Z" }, + awaiting_input: { kind: "message" }, + activity_items: [], + }), + ); + const task = yield* makeClient(client).updateTask("task-1", { + agent_type: "claude-code", + model: "claude-opus-5", + interaction_mode: "default", + reasoning_effort: null, + auto_fix_ci: false, + auto_fix_pr_comments: false, + auto_rebase: false, + }); + expect(requests[0]?.method).toBe("PUT"); + expect(requests[0]?.url).toBe("https://api.example.test/tasks/task-1"); + // Full replace: reasoning_effort travels as an explicit null. + expect(requests[0]?.body).toEqual({ + agent_type: "claude-code", + model: "claude-opus-5", + interaction_mode: "default", + reasoning_effort: null, + auto_fix_ci: false, + auto_fix_pr_comments: false, + auto_rebase: false, + }); + expect(task.status).toBe("awaiting_input"); + if (task.status === "awaiting_input") { + expect(task.awaiting_input).toEqual({ kind: "message" }); + } + }), + ); + + it.effect("getTask decodes every known status variant", () => + Effect.gen(function* () { + const bodies = [ + { ...taskBase, status: "queued", run_context: null }, + { + ...taskBase, + status: "processing", + run_context: { workspace_id: "ws-1", started_at: "2026-08-08T10:01:00Z" }, + }, + { + ...taskBase, + status: "awaiting_input", + run_context: { workspace_id: "ws-1", started_at: "2026-08-08T10:01:00Z" }, + awaiting_input: { + kind: "questions", + tool_id: "tool-7", + input: { questions: [{ id: "q1" }] }, + }, + }, + { + ...taskBase, + status: "errored", + run_context: null, + error: "agent crashed", + completed_at: "2026-08-08T10:30:00Z", + }, + ]; + let call = 0; + const { client } = makeRecordingClient(() => Response.json(bodies[call++])); + const restClient = makeClient(client); + + const queued = yield* restClient.getTask("task-1"); + expect(queued.status).toBe("queued"); + if (queued.status === "queued") { + expect(queued.run_context).toBeNull(); + } + + const processing = yield* restClient.getTask("task-1"); + expect(processing.status).toBe("processing"); + if (processing.status === "processing") { + expect(processing.run_context.workspace_id).toBe("ws-1"); + } + + const awaiting = yield* restClient.getTask("task-1"); + expect(awaiting.status).toBe("awaiting_input"); + if (awaiting.status === "awaiting_input" && awaiting.awaiting_input.kind === "questions") { + expect(awaiting.awaiting_input.tool_id).toBe("tool-7"); + } + + const errored = yield* restClient.getTask("task-1"); + expect(errored.status).toBe("errored"); + if (errored.status === "errored") { + expect(errored.error).toBe("agent crashed"); + expect(errored.completed_at).toBe("2026-08-08T10:30:00Z"); + } + }), + ); + + it.effect("getConversationDelta parses every timeline row variant", () => + Effect.gen(function* () { + const { requests, client } = makeRecordingClient(() => + Response.json({ + task: { ...taskBase, status: "queued", run_context: null }, + messages: [ + { + id: "m-user", + role: "user", + content: "do the thing", + deliveryStatus: "delivered", + timestamp: "t1", + sequence: 1, + }, + { + id: "m-text", + role: "assistant", + variant: "text", + content: "on it", + timestamp: "t2", + sequence: 2, + }, + { + id: "m-think", + role: "assistant", + variant: "thinking", + content: "hmm", + isStreaming: false, + duration: 1.5, + timestamp: "t3", + sequence: 3, + }, + { + id: "m-tool", + role: "assistant", + variant: "tool", + tool: { + id: "tool-1", + name: "Bash", + input: { command: "ls" }, + status: "completed", + itemType: "command_execution", + display: { label: "ls" }, + result: "README.md", + }, + timestamp: "t4", + sequence: 4, + }, + { + id: "m-seam", + role: "assistant", + variant: "seam", + seam: { reason: "compaction", sessionId: "s1", boundaryId: "b1" }, + timestamp: "t5", + sequence: 5, + }, + ], + removedMessageIds: ["m-gone"], + activity: [{ id: "a1", type: "status" }], + activeProcessingTurn: { messageId: "m-user", startedAt: "t1" }, + latestSequence: 5, + truncated: false, + }), + ); + const delta = yield* makeClient(client).getConversationDelta("task-1", 3); + expect(requests[0]?.url).toBe( + "https://api.example.test/tasks/task-1/conversation/delta?after=3", + ); + expect(delta.task.status).toBe("queued"); + expect(delta.messages).toHaveLength(5); + expect(delta.removedMessageIds).toEqual(["m-gone"]); + expect(delta.activeProcessingTurn).toEqual({ messageId: "m-user", startedAt: "t1" }); + expect(delta.latestSequence).toBe(5); + expect(delta.truncated).toBe(false); + const tool = delta.messages[3]; + if (tool !== undefined && tool.role === "assistant" && tool.variant === "tool") { + expect(tool.tool.display.label).toBe("ls"); + } else { + throw new Error("expected a tool row at index 3"); + } + }), + ); + + it.effect("getConversationMessages parses the page envelope", () => + Effect.gen(function* () { + const { requests, client } = makeRecordingClient(() => + Response.json({ + task: { ...taskBase, status: "queued", run_context: null }, + messages: [], + activity: [], + activeProcessingTurn: null, + latestSequence: 41, + oldestSequenceLoaded: null, + oldestSortTimestampLoaded: null, + hasMoreOlder: false, + }), + ); + const page = yield* makeClient(client).getConversationMessages("task-1"); + expect(requests[0]?.url).toBe( + "https://api.example.test/tasks/task-1/conversation/messages", + ); + expect(page.task.status).toBe("queued"); + expect(page.hasMoreOlder).toBe(false); + expect(page.oldestSequenceLoaded).toBeNull(); + }), + ); + + it.effect("getConversationMessages sends the older-page cursor as paired query params", () => + Effect.gen(function* () { + const { requests, client } = makeRecordingClient(() => + Response.json({ + task: { ...taskBase, status: "queued", run_context: null }, + messages: [], + activity: [], + activeProcessingTurn: null, + latestSequence: 41, + oldestSequenceLoaded: 3, + oldestSortTimestampLoaded: "2026-08-08T09:00:00Z", + hasMoreOlder: false, + }), + ); + yield* makeClient(client).getConversationMessages("task-1", { + sequence: 17, + sortTimestamp: "2026-08-08T10:00:00Z", + }); + expect(requests[0]?.url).toBe( + "https://api.example.test/tasks/task-1/conversation/messages?before=17&beforeSortTimestamp=2026-08-08T10%3A00%3A00Z", + ); + }), + ); + + it.effect("listProjects returns repo_url and task_defaults", () => + Effect.gen(function* () { + const { client } = makeRecordingClient(() => + Response.json({ + projects: [ + { + id: "project-1", + name: "aether", + repo_url: "https://github.com/acme/aether", + default_branch: "main", + task_defaults: { + agent_type: "codex", + model: "gpt-5.6-sol", + interaction_mode: "default", + reasoning_effort: null, + }, + hardware: { cpu: 4 }, + }, + ], + }), + ); + const projects = yield* makeClient(client).listProjects(); + expect(projects).toHaveLength(1); + expect(projects[0]?.repo_url).toBe("https://github.com/acme/aether"); + expect(projects[0]?.task_defaults.model).toBe("gpt-5.6-sol"); + }), + ); + + it.effect("getProfile reuses the probe schema", () => + Effect.gen(function* () { + const { requests, client } = makeRecordingClient(() => + Response.json({ email: "dev@example.test", display_name: "Dev" }), + ); + const profile = yield* makeClient(client).getProfile(); + expect(requests[0]?.url).toBe("https://api.example.test/profile"); + expect(profile.email).toBe("dev@example.test"); + }), + ); + }); + + describe("forward compatibility", () => { + it.effect("tolerates additive unknown fields on every payload", () => + Effect.gen(function* () { + const { client } = makeRecordingClient(() => + Response.json({ + ...taskBase, + status: "processing", + run_context: { + workspace_id: "ws-1", + started_at: "2026-08-08T10:01:00Z", + new_field: "surprise", + }, + some_new_top_level_field: { nested: true }, + activity_items: [], + }), + ); + const task = yield* makeClient(client).getTask("task-1"); + expect(task.status).toBe("processing"); + expect(task.name).toBe("Fix the flaky test"); + }), + ); + + it.effect("carries an unrecognized status as the explicit unknown-status variant", () => + Effect.gen(function* () { + const { client } = makeRecordingClient(() => + Response.json({ ...taskBase, status: "paused", run_context: null }), + ); + const task = yield* makeClient(client).getTask("task-1"); + expect(task.status).toBe("unknown-status"); + if (task.status === "unknown-status") { + expect(task.rawStatus).toBe("paused"); + expect(task.latest_sequence).toBe(41); + } + }), + ); + + it.effect("carries an unrecognized awaiting_input kind as the unknown-kind carrier", () => + Effect.gen(function* () { + const { client } = makeRecordingClient(() => + Response.json({ + ...taskBase, + status: "awaiting_input", + run_context: null, + awaiting_input: { kind: "approval", tool_id: "tool-9", input: {} }, + }), + ); + const task = yield* makeClient(client).getTask("task-1"); + expect(task.status).toBe("awaiting_input"); + if (task.status === "awaiting_input") { + expect(task.awaiting_input).toEqual({ kind: "unknown-kind", rawKind: "approval" }); + } + }), + ); + + it.effect("fails loudly when a KNOWN awaiting_input kind carries a malformed payload", () => + Effect.gen(function* () { + // questions requires tool_id; a violation must be a decode error, + // never a silent downgrade to the unknown-kind carrier. + const { client } = makeRecordingClient(() => + Response.json({ + ...taskBase, + status: "awaiting_input", + run_context: null, + awaiting_input: { kind: "questions", input: {} }, + }), + ); + const error = yield* expectFailure(makeClient(client).getTask("task-1")); + expect(error._tag).toBe("AetherApiDecodeError"); + }), + ); + + it.effect("fails loudly when a KNOWN status carries a malformed payload", () => + Effect.gen(function* () { + // processing requires a non-null run_context; a violation must be a + // decode error, never a silent downgrade to unknown-status. + const { client } = makeRecordingClient(() => + Response.json({ ...taskBase, status: "processing", run_context: null }), + ); + const error = yield* expectFailure(makeClient(client).getTask("task-1")); + expect(error._tag).toBe("AetherApiDecodeError"); + }), + ); + }); + + describe("error mapping", () => { + it.effect("maps 401 to AetherApiAuthError", () => + Effect.gen(function* () { + const { client } = makeRecordingClient( + () => new Response(JSON.stringify({ error: "invalid api key" }), { status: 401 }), + ); + const error = yield* expectFailure(makeClient(client).getTask("task-1")); + expect(error._tag).toBe("AetherApiAuthError"); + expect(error.message).toContain("invalid api key"); + }), + ); + + it.effect("maps 402 to AetherApiPaymentRequiredError", () => + Effect.gen(function* () { + const { client } = makeRecordingClient( + () => new Response(JSON.stringify({ error: "out of credits" }), { status: 402 }), + ); + const error = yield* expectFailure( + makeClient(client).createTask({ + project_id: "project-1", + prompt: "p", + agent_type: "codex", + model: "m", + interaction_mode: "default", + auto_fix_ci: false, + auto_fix_pr_comments: false, + auto_rebase: false, + }), + ); + expect(error._tag).toBe("AetherApiPaymentRequiredError"); + expect(error.message).toContain("out of credits"); + }), + ); + + it.effect("maps 404 to AetherApiNotFoundError", () => + Effect.gen(function* () { + const { client } = makeRecordingClient( + () => new Response(JSON.stringify({ error: "task not found" }), { status: 404 }), + ); + const error = yield* expectFailure(makeClient(client).getTask("task-404")); + expect(error._tag).toBe("AetherApiNotFoundError"); + }), + ); + + it.effect("maps 409 to AetherApiConflictError with code and awaiting_input_kind", () => + Effect.gen(function* () { + const { client } = makeRecordingClient( + () => + new Response( + JSON.stringify({ + error: "task is awaiting input", + code: "pending_tool_response", + awaiting_input_kind: "questions", + }), + { status: 409 }, + ), + ); + const error = yield* expectFailure( + makeClient(client).respondToTask("task-1", { message: "hello" }), + ); + expect(error._tag).toBe("AetherApiConflictError"); + if (error._tag === "AetherApiConflictError") { + expect(error.code).toBe("pending_tool_response"); + expect(error.awaitingInputKind).toBe("questions"); + } + }), + ); + + it.effect("maps a 409 with a non-JSON body to a conflict with the status text", () => + Effect.gen(function* () { + const { client } = makeRecordingClient(() => new Response("nope", { status: 409 })); + const error = yield* expectFailure( + makeClient(client).respondToTask("task-1", { message: "hello" }), + ); + expect(error._tag).toBe("AetherApiConflictError"); + expect(error.message).toContain("HTTP 409"); + }), + ); + + it.effect("maps other 4xx to AetherApiRequestError with the status", () => + Effect.gen(function* () { + const { client } = makeRecordingClient( + () => new Response(JSON.stringify({ error: "validation failed" }), { status: 422 }), + ); + const error = yield* expectFailure(makeClient(client).getTask("task-1")); + expect(error._tag).toBe("AetherApiRequestError"); + if (error._tag === "AetherApiRequestError") { + expect(error.status).toBe(422); + } + }), + ); + + it.effect("maps 5xx to AetherApiTransportError", () => + Effect.gen(function* () { + const { client } = makeRecordingClient( + () => new Response(JSON.stringify({ error: "boom" }), { status: 502 }), + ); + const error = yield* expectFailure( + makeClient(client).stopTask("task-1", { discardQueuedMessages: true }), + ); + expect(error._tag).toBe("AetherApiTransportError"); + if (error._tag === "AetherApiTransportError") { + expect(error.status).toBe(502); + } + }), + ); + + it.effect("maps network failures to AetherApiTransportError", () => + Effect.gen(function* () { + const error = yield* expectFailure(makeClient(failingTransportClient()).listProjects()); + expect(error._tag).toBe("AetherApiTransportError"); + }), + ); + + it.effect("maps a malformed 2xx body to AetherApiDecodeError", () => + Effect.gen(function* () { + const { client } = makeRecordingClient(() => new Response("not json", { status: 200 })); + const error = yield* expectFailure(makeClient(client).getProfile()); + expect(error._tag).toBe("AetherApiDecodeError"); + }), + ); + + it.effect("maps a schema-mismatched 2xx body to AetherApiDecodeError", () => + Effect.gen(function* () { + const { client } = makeRecordingClient(() => Response.json({ projects: "nope" })); + const error = yield* expectFailure(makeClient(client).listProjects()); + expect(error._tag).toBe("AetherApiDecodeError"); + }), + ); + }); +}); diff --git a/apps/server/src/provider/Layers/aether/restClient.ts b/apps/server/src/provider/Layers/aether/restClient.ts new file mode 100644 index 000000000000..df9ed182cf70 --- /dev/null +++ b/apps/server/src/provider/Layers/aether/restClient.ts @@ -0,0 +1,516 @@ +/** + * Aether REST task client — the driver's only REST transport. + * + * Thin, fully typed wrapper over `HttpClient` for the Aether task surface: + * create/respond/stop/remove-from-queue/update/get, the conversation + * messages page + delta, the projects list, and the profile probe. Every + * response parses at this boundary through the loose schemas in + * `restSchemas.ts`; every failure is a typed tagged error from the + * `AetherRestError` union — never a thrown string, never a silent fallback. + * + * Error mapping (apps/api/router/tasks_openapi.go taskErrorResponse): + * - 401 → `AetherApiAuthError` (bad API key — distinct from transport) + * - 402 → `AetherApiPaymentRequiredError` + * - 404 → `AetherApiNotFoundError` + * - 409 → `AetherApiConflictError` carrying the structured body's + * `code` + `awaiting_input_kind` when present + * - other 4xx → `AetherApiRequestError` (status preserved) + * - 5xx, network failures, timeouts → `AetherApiTransportError` + * - malformed 2xx payloads → `AetherApiDecodeError` + * + * Requests carry a bearer token and a per-request timeout (no automatic + * retries: create/respond are non-idempotent — `client_message_id` exists so + * CALLERS can retry a respond safely). + * + * @module provider/Layers/aether/restClient + */ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import { HttpClient, HttpClientRequest, type HttpClientResponse } from "effect/unstable/http"; + +import { AetherProfileResponse } from "../AetherProvider.ts"; +import { + AetherConversationDeltaEnvelope, + AetherConversationMessagesPageEnvelope, + AetherCreateTaskResponse, + AetherProjectListResponse, + AetherRespondToTaskResponse, + decodeAetherTask, + type AetherConversationDelta, + type AetherConversationMessagesPage, + type AetherCreateTaskRequest, + type AetherProject, + type AetherRespondToTaskRequest, + type AetherTask, + type AetherUpdateTaskRequest, +} from "./restSchemas.ts"; + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +/** 401 — the API key is missing, revoked, or wrong. */ +export class AetherApiAuthError extends Schema.TaggedErrorClass()( + "AetherApiAuthError", + { + endpoint: Schema.String, + detail: Schema.String, + }, +) { + override get message(): string { + return `Aether API authentication failed (${this.endpoint}): ${this.detail}`; + } +} + +/** 402 — out of credits / plan does not allow the operation. */ +export class AetherApiPaymentRequiredError extends Schema.TaggedErrorClass()( + "AetherApiPaymentRequiredError", + { + endpoint: Schema.String, + detail: Schema.String, + }, +) { + override get message(): string { + return `Aether API payment required (${this.endpoint}): ${this.detail}`; + } +} + +/** 404 — the task/project does not exist (or is not visible to this key). */ +export class AetherApiNotFoundError extends Schema.TaggedErrorClass()( + "AetherApiNotFoundError", + { + endpoint: Schema.String, + detail: Schema.String, + }, +) { + override get message(): string { + return `Aether API resource not found (${this.endpoint}): ${this.detail}`; + } +} + +/** + * 409 — state conflict. The respond endpoint's structured body carries + * `code` and, for pending-input conflicts, `awaiting_input_kind` + * (message | questions | plan) so callers can re-sync instead of blind-retry. + */ +export class AetherApiConflictError extends Schema.TaggedErrorClass()( + "AetherApiConflictError", + { + endpoint: Schema.String, + detail: Schema.String, + code: Schema.optional(Schema.String), + awaitingInputKind: Schema.optional(Schema.String), + }, +) { + override get message(): string { + return `Aether API conflict (${this.endpoint}): ${this.detail}`; + } +} + +/** Any other 4xx (400 bad request, 422 validation, …). */ +export class AetherApiRequestError extends Schema.TaggedErrorClass()( + "AetherApiRequestError", + { + endpoint: Schema.String, + status: Schema.Number, + detail: Schema.String, + }, +) { + override get message(): string { + return `Aether API request failed (${this.endpoint}, HTTP ${this.status}): ${this.detail}`; + } +} + +/** 5xx, network failure, or request timeout — the transport, not the caller. */ +export class AetherApiTransportError extends Schema.TaggedErrorClass()( + "AetherApiTransportError", + { + endpoint: Schema.String, + detail: Schema.String, + status: Schema.optional(Schema.Number), + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Aether API transport error (${this.endpoint}): ${this.detail}`; + } +} + +/** A 2xx body that failed to parse — a contract break, surfaced loudly. */ +export class AetherApiDecodeError extends Schema.TaggedErrorClass()( + "AetherApiDecodeError", + { + endpoint: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Aether API returned an unexpected payload (${this.endpoint}): ${this.detail}`; + } +} + +export type AetherRestError = + | AetherApiAuthError + | AetherApiPaymentRequiredError + | AetherApiNotFoundError + | AetherApiConflictError + | AetherApiRequestError + | AetherApiTransportError + | AetherApiDecodeError; + +// --------------------------------------------------------------------------- +// Client +// --------------------------------------------------------------------------- + +const REQUEST_TIMEOUT_MS = 30_000; + +// Structured error body written by the task/project routers. Loose: `error` +// itself is optional so a bare or non-JSON body still maps to a typed error. +const AetherErrorBody = Schema.Struct({ + error: Schema.optional(Schema.String), + code: Schema.optional(Schema.String), + awaiting_input_kind: Schema.optional(Schema.String), +}); +const decodeErrorBody = Schema.decodeUnknownEffect(AetherErrorBody); + +const decodeCreateTaskResponse = Schema.decodeUnknownEffect(AetherCreateTaskResponse); +const decodeRespondToTaskResponse = Schema.decodeUnknownEffect(AetherRespondToTaskResponse); +const decodeProjectListResponse = Schema.decodeUnknownEffect(AetherProjectListResponse); +const decodeProfileResponse = Schema.decodeUnknownEffect(AetherProfileResponse); +const decodeMessagesPageEnvelope = Schema.decodeUnknownEffect( + AetherConversationMessagesPageEnvelope, +); +const decodeDeltaEnvelope = Schema.decodeUnknownEffect(AetherConversationDeltaEnvelope); + +export interface AetherRestClientOptions { + /** Aether API origin, e.g. `https://api.runaether.dev` (trailing slashes tolerated). */ + readonly apiBaseUrl: string; + /** The instance's `AETHER_API_KEY`, sent as a bearer token. */ + readonly apiKey: string; + readonly httpClient: HttpClient.HttpClient; + /** Per-request timeout override; defaults to 30s. */ + readonly timeoutMs?: number; +} + +export interface AetherRestClient { + /** `POST /tasks` → 202 `{id, name}`. */ + readonly createTask: ( + request: AetherCreateTaskRequest, + ) => Effect.Effect; + /** `POST /tasks/{id}/respond` → 202 `{message_id}`. */ + readonly respondToTask: ( + taskId: string, + request: AetherRespondToTaskRequest, + ) => Effect.Effect; + /** `POST /tasks/{id}/stop` — discarding queued messages is an explicit choice. */ + readonly stopTask: ( + taskId: string, + input: { readonly discardQueuedMessages: boolean }, + ) => Effect.Effect; + /** `POST /tasks/{id}/remove-from-queue`. */ + readonly removeFromQueue: ( + taskId: string, + messageId: string, + ) => Effect.Effect; + /** `PUT /tasks/{id}` — FULL settings replace (see AetherUpdateTaskRequest). */ + readonly updateTask: ( + taskId: string, + request: AetherUpdateTaskRequest, + ) => Effect.Effect; + /** `GET /tasks/{id}` → the status-discriminated task union. */ + readonly getTask: (taskId: string) => Effect.Effect; + /** + * `GET /tasks/{id}/conversation/messages` — the latest page, or the page + * older than `before`. The server requires the cursor's `before` sequence + * and `beforeSortTimestamp` together (tasks_openapi.go + * conversationPageCursorFromParams); a page's own + * `oldestSequenceLoaded`/`oldestSortTimestampLoaded` is the next cursor. + */ + readonly getConversationMessages: ( + taskId: string, + before?: { readonly sequence: number; readonly sortTimestamp: string }, + ) => Effect.Effect; + /** `GET /tasks/{id}/conversation/delta?after={sequence}`. */ + readonly getConversationDelta: ( + taskId: string, + after: number, + ) => Effect.Effect; + /** `GET /projects` → the caller's linked projects. */ + readonly listProjects: () => Effect.Effect, AetherRestError>; + /** `GET /profile` — identity probe (same schema the provider probe uses). */ + readonly getProfile: () => Effect.Effect; +} + +export function makeAetherRestClient(options: AetherRestClientOptions): AetherRestClient { + const baseUrl = options.apiBaseUrl.replace(/\/+$/, ""); + const timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS; + const httpClient = options.httpClient; + + const prepare = (request: HttpClientRequest.HttpClientRequest) => + request.pipe( + HttpClientRequest.setHeader("accept", "application/json"), + HttpClientRequest.bearerToken(options.apiKey), + ); + + /** + * The 409/402/… bodies are informative but optional: a non-JSON error body + * degrades to the HTTP status text instead of masking the real failure + * with a decode error. + */ + const readErrorBody = (response: HttpClientResponse.HttpClientResponse) => + response.json.pipe( + Effect.flatMap(decodeErrorBody), + Effect.orElseSucceed(() => ({}) as typeof AetherErrorBody.Type), + ); + + const mapErrorStatus = ( + endpoint: string, + response: HttpClientResponse.HttpClientResponse, + ): Effect.Effect => + Effect.gen(function* () { + const body = yield* readErrorBody(response); + const detail = body.error ?? `HTTP ${response.status}`; + switch (response.status) { + case 401: + return yield* new AetherApiAuthError({ endpoint, detail }); + case 402: + return yield* new AetherApiPaymentRequiredError({ endpoint, detail }); + case 404: + return yield* new AetherApiNotFoundError({ endpoint, detail }); + case 409: + return yield* new AetherApiConflictError({ + endpoint, + detail, + ...(body.code !== undefined ? { code: body.code } : {}), + ...(body.awaiting_input_kind !== undefined + ? { awaitingInputKind: body.awaiting_input_kind } + : {}), + }); + default: + if (response.status >= 500) { + return yield* new AetherApiTransportError({ + endpoint, + detail, + status: response.status, + }); + } + return yield* new AetherApiRequestError({ + endpoint, + status: response.status, + detail, + }); + } + }); + + /** Execute with timeout, then map every non-2xx status to its typed error. */ + const execute = ( + endpoint: string, + request: HttpClientRequest.HttpClientRequest, + ): Effect.Effect => + httpClient.execute(prepare(request)).pipe( + Effect.timeout(timeoutMs), + Effect.mapError( + (cause) => + new AetherApiTransportError({ + endpoint, + detail: `Request failed before a response arrived: ${String(cause)}`, + cause, + }), + ), + Effect.flatMap((response) => + response.status >= 200 && response.status < 300 + ? Effect.succeed(response) + : mapErrorStatus(endpoint, response), + ), + ); + + const readJson = ( + endpoint: string, + response: HttpClientResponse.HttpClientResponse, + ): Effect.Effect => + response.json.pipe( + Effect.mapError( + (cause) => + new AetherApiDecodeError({ + endpoint, + detail: "Response body is not valid JSON.", + cause, + }), + ), + ); + + const decodeWith = + (endpoint: string, decode: (input: unknown) => Effect.Effect) => + (input: unknown): Effect.Effect => + decode(input).pipe( + Effect.mapError( + (cause) => + new AetherApiDecodeError({ + endpoint, + detail: "Response body did not match the expected schema.", + cause, + }), + ), + ); + + const getJson = ( + endpoint: string, + url: string, + decode: (input: unknown) => Effect.Effect, + ): Effect.Effect => + execute(endpoint, HttpClientRequest.get(url)).pipe( + Effect.flatMap((response) => readJson(endpoint, response)), + Effect.flatMap(decodeWith(endpoint, decode)), + ); + + const requestWithJsonBody = ( + endpoint: string, + request: HttpClientRequest.HttpClientRequest, + body: unknown, + ): Effect.Effect => + request.pipe( + HttpClientRequest.bodyJson(body), + Effect.mapError( + (cause) => + new AetherApiRequestError({ + endpoint, + status: 0, + detail: `Request body could not be encoded as JSON: ${String(cause)}`, + }), + ), + Effect.flatMap((prepared) => execute(endpoint, prepared)), + ); + + const postJson = ( + endpoint: string, + url: string, + body: unknown, + decode: (input: unknown) => Effect.Effect, + ): Effect.Effect => + requestWithJsonBody(endpoint, HttpClientRequest.post(url), body).pipe( + Effect.flatMap((response) => readJson(endpoint, response)), + Effect.flatMap(decodeWith(endpoint, decode)), + ); + + const postJsonVoid = ( + endpoint: string, + url: string, + body: unknown, + ): Effect.Effect => + requestWithJsonBody(endpoint, HttpClientRequest.post(url), body).pipe(Effect.asVoid); + + // The task union needs a two-stage decode (envelope, then status-probe + // dispatch); these wrap `decodeAetherTask` for the flattened + // GET/PUT /tasks/{id} responses and the conversation envelopes. + const decodeTaskResponse = (endpoint: string) => (input: unknown) => + decodeWith(endpoint, decodeAetherTask)(input); + + const decodeMessagesPage = + (endpoint: string) => + (input: unknown): Effect.Effect => + decodeWith( + endpoint, + decodeMessagesPageEnvelope, + )(input).pipe( + Effect.flatMap((envelope) => + decodeWith( + endpoint, + decodeAetherTask, + )(envelope.task).pipe(Effect.map((task) => ({ ...envelope, task }))), + ), + ); + + const decodeDelta = + (endpoint: string) => + (input: unknown): Effect.Effect => + decodeWith( + endpoint, + decodeDeltaEnvelope, + )(input).pipe( + Effect.flatMap((envelope) => + decodeWith( + endpoint, + decodeAetherTask, + )(envelope.task).pipe(Effect.map((task) => ({ ...envelope, task }))), + ), + ); + + return { + createTask: (request) => + postJson("POST /tasks", `${baseUrl}/tasks`, request, decodeCreateTaskResponse), + + respondToTask: (taskId, request) => + postJson( + "POST /tasks/{id}/respond", + `${baseUrl}/tasks/${taskId}/respond`, + request, + decodeRespondToTaskResponse, + ), + + stopTask: (taskId, input) => + postJsonVoid("POST /tasks/{id}/stop", `${baseUrl}/tasks/${taskId}/stop`, { + discard_queued_messages: input.discardQueuedMessages, + }), + + removeFromQueue: (taskId, messageId) => + postJsonVoid( + "POST /tasks/{id}/remove-from-queue", + `${baseUrl}/tasks/${taskId}/remove-from-queue`, + { message_id: messageId }, + ), + + updateTask: (taskId, request) => { + const endpoint = "PUT /tasks/{id}"; + return requestWithJsonBody( + endpoint, + HttpClientRequest.put(`${baseUrl}/tasks/${taskId}`), + request, + ).pipe( + Effect.flatMap((response) => readJson(endpoint, response)), + Effect.flatMap(decodeTaskResponse(endpoint)), + ); + }, + + getTask: (taskId) => + execute("GET /tasks/{id}", HttpClientRequest.get(`${baseUrl}/tasks/${taskId}`)).pipe( + Effect.flatMap((response) => readJson("GET /tasks/{id}", response)), + Effect.flatMap(decodeTaskResponse("GET /tasks/{id}")), + ), + + getConversationMessages: (taskId, before) => { + const endpoint = "GET /tasks/{id}/conversation/messages"; + const query = + before === undefined + ? "" + : `?before=${encodeURIComponent(before.sequence)}&beforeSortTimestamp=${encodeURIComponent(before.sortTimestamp)}`; + return execute( + endpoint, + HttpClientRequest.get(`${baseUrl}/tasks/${taskId}/conversation/messages${query}`), + ).pipe( + Effect.flatMap((response) => readJson(endpoint, response)), + Effect.flatMap(decodeMessagesPage(endpoint)), + ); + }, + + getConversationDelta: (taskId, after) => { + const endpoint = "GET /tasks/{id}/conversation/delta"; + return execute( + endpoint, + HttpClientRequest.get( + `${baseUrl}/tasks/${taskId}/conversation/delta?after=${encodeURIComponent(after)}`, + ), + ).pipe( + Effect.flatMap((response) => readJson(endpoint, response)), + Effect.flatMap(decodeDelta(endpoint)), + ); + }, + + listProjects: () => + getJson("GET /projects", `${baseUrl}/projects`, decodeProjectListResponse).pipe( + Effect.map((response) => response.projects), + ), + + getProfile: () => getJson("GET /profile", `${baseUrl}/profile`, decodeProfileResponse), + } satisfies AetherRestClient; +} diff --git a/apps/server/src/provider/Layers/aether/restSchemas.ts b/apps/server/src/provider/Layers/aether/restSchemas.ts new file mode 100644 index 000000000000..a679a3e3a506 --- /dev/null +++ b/apps/server/src/provider/Layers/aether/restSchemas.ts @@ -0,0 +1,480 @@ +/** + * Aether REST wire schemas — parse-at-boundary shapes for the Aether task + * API consumed by the AetherDriver's REST client. + * + * Every schema here is deliberately LOOSE (plain `Schema.Struct`, never + * strict): the Aether server emits strict shapes, but this CLIENT must + * tolerate additive fields from newer servers — cross-version skew is the + * steady state for a vendored-contract client. Open-ended server enums + * (delivery status, tool status, seam reason, agent type) decode as plain + * strings for the same reason. + * + * Wire sources (aether repo, read-only reference): + * - task status union: apps/api/apitypes/tasks_read.go (TaskQueuedWire, + * TaskProcessingWire, TaskAwaitingInputWire, TaskErroredWire, and the + * decode-only TaskUnknownStatusWire forward-compat carrier) + * - timeline rows: tasks_read.go TaskTimelineMessage (user | assistant + * text | thinking | tool | seam) + * - conversation delta/page: tasks_read.go TaskConversationDeltaResponse / + * TaskConversationMessagesPageResponse + * - projects: apps/api/apitypes/projects.go Project / ProjectListResponse + * + * @module provider/Layers/aether/restSchemas + */ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +// --------------------------------------------------------------------------- +// Task status union +// --------------------------------------------------------------------------- + +export const AetherTaskRunContext = Schema.Struct({ + workspace_id: Schema.String, + started_at: Schema.String, +}); +export type AetherTaskRunContext = typeof AetherTaskRunContext.Type; + +/** + * The awaiting_input payload union, discriminated on `kind`. The interactive + * kinds carry the pending input's `tool_id` (the id the respond verb echoes + * back) plus a loose `input` payload — parsing that payload into question/plan + * shapes is the event mapper's job (build item 9), not the REST boundary's. + * + * `kind` is a growable server enum (apps/api/task/lifecycle.go + * ParseAwaitingInputKind), so — like the task status — an unrecognized kind + * decodes into an explicit `unknown-kind` carrier instead of failing the + * whole task read. + */ +const AetherAwaitingInputMessage = Schema.Struct({ kind: Schema.Literal("message") }); +const AetherAwaitingInputQuestions = Schema.Struct({ + kind: Schema.Literal("questions"), + tool_id: Schema.String, + input: Schema.Unknown, +}); +const AetherAwaitingInputPlan = Schema.Struct({ + kind: Schema.Literal("plan"), + tool_id: Schema.String, + input: Schema.Unknown, +}); + +/** + * Forward-compatibility carrier for awaiting-input kinds this build does not + * recognize (the analogue of AetherTaskUnknownStatus). The raw wire kind is + * preserved verbatim in `rawKind`. Consumers must treat this variant + * explicitly (fail loudly / degrade), never as "message". + */ +export interface AetherAwaitingInputUnknownKind { + readonly kind: "unknown-kind"; + readonly rawKind: string; +} + +export type AetherAwaitingInput = + | typeof AetherAwaitingInputMessage.Type + | typeof AetherAwaitingInputQuestions.Type + | typeof AetherAwaitingInputPlan.Type + | AetherAwaitingInputUnknownKind; + +const decodeKindProbe = Schema.decodeUnknownEffect(Schema.Struct({ kind: Schema.String })); +const decodeAwaitingInputMessage = Schema.decodeUnknownEffect(AetherAwaitingInputMessage); +const decodeAwaitingInputQuestions = Schema.decodeUnknownEffect(AetherAwaitingInputQuestions); +const decodeAwaitingInputPlan = Schema.decodeUnknownEffect(AetherAwaitingInputPlan); + +/** + * Decode the kind-discriminated awaiting_input union. Same shape as + * `decodeAetherTask`: dispatch on the probed kind FIRST so a known kind with + * a malformed payload fails loudly, and only a genuinely unrecognized kind + * degrades into the `unknown-kind` carrier. + */ +const decodeAetherAwaitingInput = ( + input: unknown, +): Effect.Effect => + Effect.gen(function* () { + const probe = yield* decodeKindProbe(input); + switch (probe.kind) { + case "message": + return yield* decodeAwaitingInputMessage(input); + case "questions": + return yield* decodeAwaitingInputQuestions(input); + case "plan": + return yield* decodeAwaitingInputPlan(input); + default: + return { kind: "unknown-kind" as const, rawKind: probe.kind }; + } + }); + +/** + * Status-independent task fields the driver actually consumes. The wire + * carries far more (usage, PR surfaces, hardware, …) — all tolerated and + * dropped here until a build item needs them. + */ +const aetherTaskBaseFields = { + id: Schema.String, + project_id: Schema.String, + name: Schema.String, + agent_type: Schema.String, + model: Schema.String, + interaction_mode: Schema.String, + reasoning_effort: Schema.optional(Schema.NullOr(Schema.String)), + last_error: Schema.optional(Schema.NullOr(Schema.String)), + head_branch: Schema.optional(Schema.NullOr(Schema.String)), + latest_sequence: Schema.Number, +} as const; + +const AetherTaskBase = Schema.Struct(aetherTaskBaseFields); + +const AetherTaskQueued = Schema.Struct({ + ...aetherTaskBaseFields, + status: Schema.Literal("queued"), + run_context: Schema.NullOr(AetherTaskRunContext), +}); +export type AetherTaskQueued = typeof AetherTaskQueued.Type; + +const AetherTaskProcessing = Schema.Struct({ + ...aetherTaskBaseFields, + status: Schema.Literal("processing"), + // Non-null by construction on this variant (tasks_read.go:474-479). + run_context: AetherTaskRunContext, +}); +export type AetherTaskProcessing = typeof AetherTaskProcessing.Type; + +// `awaiting_input` decodes in a second pass through +// `decodeAetherAwaitingInput` (the envelope schema cannot express the +// kind-probe dispatch that yields the unknown-kind carrier). +const AetherTaskAwaitingInputEnvelope = Schema.Struct({ + ...aetherTaskBaseFields, + status: Schema.Literal("awaiting_input"), + // Nullable: a message-kind awaiting_input task can lack an execution + // context when every queued message was cancelled before workspace + // assignment (tasks_read.go:483-489). + run_context: Schema.NullOr(AetherTaskRunContext), + awaiting_input: Schema.Unknown, +}); +export type AetherTaskAwaitingInput = Omit< + typeof AetherTaskAwaitingInputEnvelope.Type, + "awaiting_input" +> & { readonly awaiting_input: AetherAwaitingInput }; + +const AetherTaskErrored = Schema.Struct({ + ...aetherTaskBaseFields, + status: Schema.Literal("errored"), + run_context: Schema.NullOr(AetherTaskRunContext), + error: Schema.String, + completed_at: Schema.String, +}); +export type AetherTaskErrored = typeof AetherTaskErrored.Type; + +/** + * Forward-compatibility carrier for statuses this build does not recognize + * (mirrors TaskUnknownStatusWire, tasks_read.go:510-521). The literal + * `"unknown-status"` tag keeps the union cleanly discriminated in TS — the + * raw wire status is preserved verbatim in `rawStatus`. Consumers must treat + * this variant explicitly (fail loudly / degrade), never as "pending". + */ +export type AetherTaskUnknownStatus = typeof AetherTaskBase.Type & { + readonly status: "unknown-status"; + readonly rawStatus: string; +}; + +export type AetherTask = + | AetherTaskQueued + | AetherTaskProcessing + | AetherTaskAwaitingInput + | AetherTaskErrored + | AetherTaskUnknownStatus; + +const decodeStatusProbe = Schema.decodeUnknownEffect(Schema.Struct({ status: Schema.String })); +const decodeQueued = Schema.decodeUnknownEffect(AetherTaskQueued); +const decodeProcessing = Schema.decodeUnknownEffect(AetherTaskProcessing); +const decodeAwaitingInputEnvelope = Schema.decodeUnknownEffect(AetherTaskAwaitingInputEnvelope); +const decodeErrored = Schema.decodeUnknownEffect(AetherTaskErrored); +const decodeBase = Schema.decodeUnknownEffect(AetherTaskBase); + +/** + * Decode the status-discriminated task union. Dispatching on the probed + * status FIRST (instead of a schema union with a catch-all member) keeps the + * failure mode honest: a known status with a malformed payload fails the + * decode loudly instead of silently degrading into the unknown-status + * carrier. Only a genuinely unrecognized status lands there. + */ +export const decodeAetherTask = (input: unknown): Effect.Effect => + Effect.gen(function* () { + const probe = yield* decodeStatusProbe(input); + switch (probe.status) { + case "queued": + return yield* decodeQueued(input); + case "processing": + return yield* decodeProcessing(input); + case "awaiting_input": { + const envelope = yield* decodeAwaitingInputEnvelope(input); + const awaitingInput = yield* decodeAetherAwaitingInput(envelope.awaiting_input); + return { ...envelope, awaiting_input: awaitingInput }; + } + case "errored": + return yield* decodeErrored(input); + default: { + const base = yield* decodeBase(input); + return { ...base, status: "unknown-status" as const, rawStatus: probe.status }; + } + } + }); + +// --------------------------------------------------------------------------- +// Conversation timeline rows +// --------------------------------------------------------------------------- + +/** + * A tool card row's payload. `input` is the opaque tool input map; + * `display.label` is the server-rendered card label; `itemType` is the + * Aether canonical item type (classified into t3's 7-value union via the + * vendored `toolLifecycleItemTypeFromAether`). + */ +export const AetherTimelineTool = Schema.Struct({ + id: Schema.String, + name: Schema.String, + input: Schema.Record(Schema.String, Schema.Unknown), + status: Schema.String, + itemType: Schema.optional(Schema.String), + provider: Schema.optional(Schema.String), + display: Schema.Struct({ label: Schema.String }), + result: Schema.optional(Schema.String), + error: Schema.optional(Schema.String), +}); +export type AetherTimelineTool = typeof AetherTimelineTool.Type; + +const AetherUserMessage = Schema.Struct({ + id: Schema.String, + role: Schema.Literal("user"), + content: Schema.String, + deliveryStatus: Schema.String, + processingStartedAt: Schema.optional(Schema.String), + clientMessageId: Schema.optional(Schema.String), + toolResponse: Schema.optional(Schema.Unknown), + messageEvent: Schema.optional(Schema.Unknown), + timestamp: Schema.String, + sequence: Schema.Number, +}); +export type AetherUserMessage = typeof AetherUserMessage.Type; + +const aetherAssistantBaseFields = { + id: Schema.String, + role: Schema.Literal("assistant"), + timestamp: Schema.String, + sequence: Schema.Number, +} as const; + +const AetherAssistantTextMessage = Schema.Struct({ + ...aetherAssistantBaseFields, + variant: Schema.Literal("text"), + content: Schema.String, +}); +export type AetherAssistantTextMessage = typeof AetherAssistantTextMessage.Type; + +const AetherAssistantThinkingMessage = Schema.Struct({ + ...aetherAssistantBaseFields, + variant: Schema.Literal("thinking"), + content: Schema.String, + isStreaming: Schema.Boolean, + duration: Schema.optional(Schema.Number), +}); +export type AetherAssistantThinkingMessage = typeof AetherAssistantThinkingMessage.Type; + +const AetherAssistantToolMessage = Schema.Struct({ + ...aetherAssistantBaseFields, + variant: Schema.Literal("tool"), + tool: AetherTimelineTool, +}); +export type AetherAssistantToolMessage = typeof AetherAssistantToolMessage.Type; + +// A seam is a divider, not a message: no content by design. The payload +// shape varies per reason (teleport, compaction, …) — kept loose here. +const AetherSeamMessage = Schema.Struct({ + ...aetherAssistantBaseFields, + variant: Schema.Literal("seam"), + seam: Schema.Struct({ reason: Schema.String }), +}); +export type AetherSeamMessage = typeof AetherSeamMessage.Type; + +export const AetherTimelineMessage = Schema.Union([ + AetherUserMessage, + AetherAssistantTextMessage, + AetherAssistantThinkingMessage, + AetherAssistantToolMessage, + AetherSeamMessage, +]); +export type AetherTimelineMessage = typeof AetherTimelineMessage.Type; + +// --------------------------------------------------------------------------- +// Conversation responses +// --------------------------------------------------------------------------- + +export const AetherActiveProcessingTurn = Schema.Struct({ + messageId: Schema.String, + startedAt: Schema.String, +}); +export type AetherActiveProcessingTurn = typeof AetherActiveProcessingTurn.Type; + +// `task` decodes in a second pass through `decodeAetherTask` (the envelope +// schema cannot express the status-probe dispatch); `activity` stays opaque +// until the event mapper (build item 6) consumes it. +const aetherConversationBaseFields = { + task: Schema.Unknown, + messages: Schema.Array(AetherTimelineMessage), + activity: Schema.Array(Schema.Unknown), + activeProcessingTurn: Schema.NullOr(AetherActiveProcessingTurn), + latestSequence: Schema.Number, +} as const; + +export const AetherConversationMessagesPageEnvelope = Schema.Struct({ + ...aetherConversationBaseFields, + oldestSequenceLoaded: Schema.NullOr(Schema.Number), + oldestSortTimestampLoaded: Schema.NullOr(Schema.String), + hasMoreOlder: Schema.Boolean, +}); + +export const AetherConversationDeltaEnvelope = Schema.Struct({ + ...aetherConversationBaseFields, + removedMessageIds: Schema.Array(Schema.String), + truncated: Schema.Boolean, +}); + +/** Messages page with the task union decoded. */ +export type AetherConversationMessagesPage = Omit< + typeof AetherConversationMessagesPageEnvelope.Type, + "task" +> & { readonly task: AetherTask }; + +/** Conversation delta with the task union decoded. */ +export type AetherConversationDelta = Omit & { + readonly task: AetherTask; +}; + +// --------------------------------------------------------------------------- +// Command responses +// --------------------------------------------------------------------------- + +/** 202 body of `POST /tasks` — the server-generated task id and name. */ +export const AetherCreateTaskResponse = Schema.Struct({ + id: Schema.String, + name: Schema.String, +}); +export type AetherCreateTaskResponse = typeof AetherCreateTaskResponse.Type; + +/** + * 202 body of `POST /tasks/{id}/respond` — the created user message's id, + * the same value that appears as `id` on the message's timeline row. + */ +export const AetherRespondToTaskResponse = Schema.Struct({ + message_id: Schema.String, +}); +export type AetherRespondToTaskResponse = typeof AetherRespondToTaskResponse.Type; + +// --------------------------------------------------------------------------- +// Projects +// --------------------------------------------------------------------------- + +export const AetherProjectTaskDefaults = Schema.Struct({ + agent_type: Schema.String, + model: Schema.String, + interaction_mode: Schema.String, + reasoning_effort: Schema.optional(Schema.NullOr(Schema.String)), +}); +export type AetherProjectTaskDefaults = typeof AetherProjectTaskDefaults.Type; + +export const AetherProject = Schema.Struct({ + id: Schema.String, + name: Schema.String, + repo_url: Schema.optional(Schema.NullOr(Schema.String)), + default_branch: Schema.optional(Schema.NullOr(Schema.String)), + task_defaults: AetherProjectTaskDefaults, +}); +export type AetherProject = typeof AetherProject.Type; + +export const AetherProjectListResponse = Schema.Struct({ + projects: Schema.Array(AetherProject), +}); +export type AetherProjectListResponse = typeof AetherProjectListResponse.Type; + +// --------------------------------------------------------------------------- +// Request bodies (encode side — plain types, huma validates server-side) +// --------------------------------------------------------------------------- + +export interface AetherPromptAttachment { + readonly filename: string; + readonly mediaType: string; + readonly data: string; +} + +export interface AetherPromptContext { + readonly files?: ReadonlyArray<{ + readonly path: string; + readonly include: boolean; + readonly selection?: { readonly startLine: number; readonly endLine: number }; + }>; + readonly attachments?: ReadonlyArray; +} + +export interface AetherCreateTaskRequest { + readonly project_id: string; + readonly prompt: string; + readonly base_branch?: string; + readonly context?: AetherPromptContext; + readonly agent_type: string; + readonly model: string; + readonly interaction_mode: string; + readonly reasoning_effort?: string | null; + readonly auto_fix_ci: boolean; + readonly auto_fix_pr_comments: boolean; + readonly auto_rebase: boolean; +} + +/** + * The tool-response union, discriminated on `tool_name`. The server validates + * each variant strictly (required fields + additionalProperties:false on + * `data` — apitypes/tasks.go askUserToolResponseSchema / + * proposePlanToolResponseSchema), so the encode types mirror the oneOf + * exactly: an ask_user without `answers`, a propose_plan without `approved`, + * or a cross-variant field mix is unrepresentable. + */ +export interface AetherAskUserToolResponse { + readonly tool_name: "ask_user"; + readonly data: { + readonly answers: Readonly>>; + readonly customAnswers?: Readonly>; + }; +} + +export interface AetherProposePlanToolResponse { + readonly tool_name: "propose_plan"; + readonly data: { + readonly approved: boolean; + readonly feedback?: string; + }; +} + +export type AetherTaskToolResponse = AetherAskUserToolResponse | AetherProposePlanToolResponse; + +export interface AetherRespondToTaskRequest { + readonly message: string; + readonly context?: AetherPromptContext; + readonly interaction_mode?: string; + readonly reasoning_effort?: string; + readonly tool_response?: AetherTaskToolResponse; + /** Idempotency key: a retry after a lost 202 resolves to the original row. */ + readonly client_message_id?: string; +} + +/** + * `PUT /tasks/{id}` is a FULL REPLACE: every settings field is required and + * `reasoning_effort` is required-but-nullable (a null always means an + * explicit null, unlike create where the field is also optional) — + * apps/api/apitypes/tasks.go UpdateTaskRequest. + */ +export interface AetherUpdateTaskRequest { + readonly agent_type: string; + readonly model: string; + readonly interaction_mode: string; + readonly reasoning_effort: string | null; + readonly auto_fix_ci: boolean; + readonly auto_fix_pr_comments: boolean; + readonly auto_rebase: boolean; +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 1d824afbd1be..5fafd74137e0 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -376,7 +376,12 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // through this layer. Built-in drivers come from `BUILT_IN_DRIVERS`; // `providerInstances` hydration merges `settings.providers.` // with explicit `providerInstances` entries on boot. - Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + // `AetherDriver.create()` yields `GitVcsDriver` for its session preflight + // (clean-tree/pushed-branch checks + origin-remote resolution). The Git/Vcs + // layers above sit EARLIER in this chain, so they never feed the instance + // registry — provide the (memoized) driver layer directly so hydration's + // `BuiltInDriversEnv` is satisfied. + Layer.provideMerge(ProviderInstanceRegistryHydrationLive.pipe(Layer.provide(GitVcsDriver.layer))), // Shared native/canonical NDJSON writers used by both the per-instance // drivers (native stream, written from inside each `Adapter`) and // `ProviderService` (canonical stream, written after event normalization). From 0d4cf0fc9e59aa15e438f7f4f80e6351fae8fbaa Mon Sep 17 00:00:00 2001 From: Pranav Sharan Date: Sat, 8 Aug 2026 11:26:18 -0700 Subject: [PATCH 05/44] feat(aether): workspace attach, WS transport, and the full event mapper (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(aether): workspace attach, WS transport, and the full event mapper T4+T5: the live pipeline. wireEvents parses the 13-kind agent union loosely (unknown-kind/malformed carriers; the socket never dies on a frame). eventMapper is the single WS+durable transform: deterministic event ids from durable identity (crash replay collides idempotently), durable-wins dedupe, exactly-one settle per turn, WS/REST pending-input correlation, the ready-not-waiting rule for message-idle, vendored classification + parseFileChanges for tool cards, todo_list → plan updates, truncation → warning. workspaceSocket: attach poll with every terminal branch (errored payload, parked null-context durable-only), connect union incl. 409-as-data, passive never boots a VM, reconnect ladder re-running full attach + delta reconciliation from the cursor. Adapter streams mapped events on resumed sessions; teardown closes scoped pumps. Golden fixtures for both transports; 150 tests in the touched surfaces; full suite 2055 green. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * fix(aether): attribute REST-delta rows of the in-flight turn to its TurnId Review: durable rows carry no turn field, so rows of the active turn finalized with turnId null and their settle never owned them. The wire turn id IS the user row that opened the turn, so that row's sequence is the turn boundary: rows above it get the mapped aether-turn id, the previous turn's tail stays unowned. Mutation-verified boundary tests. The reasoning-shape finding is refuted with evidence in the PR thread: no driver's reasoning renders today (ingestion reads only assistant_text/assistant_message; reasoning is not a tool-lifecycle type; zero UI consumers) — the mapper already emits the ecosystem shape. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * fix(aether): keep the previous turn's late tail owned across a warm transition Review round 2: a delta carrying [tail-of-u1, opener-u2, rows-of-u2] attributed the tail to undefined even when the mapper was already tracking u1 — it finalized unowned moments before trackTurn(u2) settled u1. Pre-opener rows now fall back to the tracked turn; only a cold mapper leaves them unowned. Test pins the reviewer's exact repro including tail→settle→next-output ordering. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * fix(aether): opener-based turn attribution + cross-task frame guard Review round 3. Attribution now models the mapper's own invariant directly: delivered user rows open turns (queued/cancelled park ahead of theirs and do not), rows before the first opener fall back to the tracked turn, and opener-less batches sit mid-turn under activeProcessingTurn — which makes the cold resume to an awaiting task own both its output rows and the pending-input request (captured before the settle clears tracking). The workspace socket now drops frames whose taskId is not the subscribed task, logged once per foreign task. Cold golden-replay snapshot legitimately gains four owned turnIds. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * fix(aether): close the raw socket on every pre-open failure path Review round 4: an upgrade error/close before open failed openSocket before the acquireRelease finalizer registered, and the reconnect ladder retries open failures indefinitely — one leaked socket per attempt. All pre-open exits (error, close, timeout, interrupt) now close the raw socket themselves; close() is idempotent. Leak pinned by the open-retry test asserting both failed sockets closed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * fix(aether): session.exited carries providerInstanceId Ingestion rewrites the thread session from this event and preserves instance identity only when the event carries it — every other adapter emission already stamped it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h --------- Co-authored-by: Claude Fable 5 --- .../src/provider/Drivers/AetherDriver.ts | 11 +- .../src/provider/Layers/AetherAdapter.test.ts | 209 ++- .../src/provider/Layers/AetherAdapter.ts | 268 +++- .../__snapshots__/eventMapper.test.ts.snap | 380 ++++++ .../Layers/aether/eventMapper.fixtures.ts | 501 +++++++ .../Layers/aether/eventMapper.test.ts | 779 +++++++++++ .../src/provider/Layers/aether/eventMapper.ts | 1216 +++++++++++++++++ .../src/provider/Layers/aether/restClient.ts | 49 + .../src/provider/Layers/aether/restSchemas.ts | 103 ++ .../src/provider/Layers/aether/wireEvents.ts | 308 +++++ .../Layers/aether/workspaceSocket.test.ts | 606 ++++++++ .../provider/Layers/aether/workspaceSocket.ts | 690 ++++++++++ 12 files changed, 5106 insertions(+), 14 deletions(-) create mode 100644 apps/server/src/provider/Layers/aether/__snapshots__/eventMapper.test.ts.snap create mode 100644 apps/server/src/provider/Layers/aether/eventMapper.fixtures.ts create mode 100644 apps/server/src/provider/Layers/aether/eventMapper.test.ts create mode 100644 apps/server/src/provider/Layers/aether/eventMapper.ts create mode 100644 apps/server/src/provider/Layers/aether/wireEvents.ts create mode 100644 apps/server/src/provider/Layers/aether/workspaceSocket.test.ts create mode 100644 apps/server/src/provider/Layers/aether/workspaceSocket.ts diff --git a/apps/server/src/provider/Drivers/AetherDriver.ts b/apps/server/src/provider/Drivers/AetherDriver.ts index b5938c358041..ecdf041548ea 100644 --- a/apps/server/src/provider/Drivers/AetherDriver.ts +++ b/apps/server/src/provider/Drivers/AetherDriver.ts @@ -3,10 +3,11 @@ * * A real snapshot (probe = authenticated `GET /profile`, models from the * vendored platform catalog) over the session-core adapter (REST task client - * + git preflight; turn streaming still pending) and deterministic - * text-generation stubs. There is no local binary — the driver talks to the - * Aether REST API, authenticated by the sensitive `AETHER_API_KEY` instance - * environment variable. + * + git preflight + workspace WS event pipeline; the turn surface lands with + * build item 7) and deterministic text-generation stubs. There is no local + * binary — the driver talks to the Aether REST API and workspace WS, + * authenticated by the sensitive `AETHER_API_KEY` instance environment + * variable. * * @module provider/Drivers/AetherDriver */ @@ -122,6 +123,8 @@ export const AetherDriver: ProviderDriver = { defaultCwd: serverConfig.cwd, git: gitVcsDriver, restClient, + socket: + apiKey === undefined ? undefined : { apiBaseUrl: effectiveConfig.apiBaseUrl, apiKey }, }); const textGeneration = makeAetherTextGeneration(); diff --git a/apps/server/src/provider/Layers/AetherAdapter.test.ts b/apps/server/src/provider/Layers/AetherAdapter.test.ts index 4265874005b3..ace97c08d520 100644 --- a/apps/server/src/provider/Layers/AetherAdapter.test.ts +++ b/apps/server/src/provider/Layers/AetherAdapter.test.ts @@ -5,13 +5,26 @@ import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import type { GitStatusDetails } from "../../vcs/GitVcsDriver.ts"; import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; import type { ProviderAdapterError } from "../Errors.ts"; -import { makeAetherAdapter, parseAetherResume, type AetherSessionGit } from "./AetherAdapter.ts"; +import { + makeAetherAdapter, + parseAetherResume, + type AetherAdapterSocketOptions, + type AetherSessionGit, +} from "./AetherAdapter.ts"; import { AetherApiNotFoundError, type AetherRestClient } from "./aether/restClient.ts"; -import type { AetherProject, AetherTask, AetherTimelineMessage } from "./aether/restSchemas.ts"; +import type { + AetherConversationDelta, + AetherProject, + AetherTask, + AetherTimelineMessage, +} from "./aether/restSchemas.ts"; +import { wsAssistantDelta, wsTurnCompleted } from "./aether/eventMapper.fixtures.ts"; +import type { AetherWebSocketLike } from "./aether/workspaceSocket.ts"; const instanceId = ProviderInstanceId.make("aether"); @@ -50,6 +63,7 @@ const unusedRestClient: AetherRestClient = { removeFromQueue: () => Effect.die("removeFromQueue must not be called"), updateTask: () => Effect.die("updateTask must not be called"), getTask: () => Effect.die("getTask must not be called"), + connectWorkspace: () => Effect.die("connectWorkspace must not be called"), getConversationMessages: () => Effect.die("getConversationMessages must not be called"), getConversationDelta: () => Effect.die("getConversationDelta must not be called"), listProjects: () => Effect.die("listProjects must not be called"), @@ -99,6 +113,7 @@ const withAdapter = ( readonly git?: AetherSessionGit; readonly restClient?: AetherRestClient | undefined; readonly hasRestClient?: boolean; + readonly socket?: AetherAdapterSocketOptions; }, use: (adapter: ProviderAdapterShape) => Effect.Effect, ) => @@ -109,6 +124,7 @@ const withAdapter = ( git: options.git ?? gitWith(cleanStatus), restClient: options.hasRestClient === false ? undefined : (options.restClient ?? unusedRestClient), + socket: options.socket, }); return yield* use(adapter); }).pipe(Effect.scoped, Effect.provideService(Crypto.Crypto, testCrypto)); @@ -778,3 +794,192 @@ describe("AetherAdapter readThread", () => { ), ); }); + +// --------------------------------------------------------------------------- +// Event pipeline (T4+T5): passive attach + live streaming + reconciliation +// --------------------------------------------------------------------------- + +/** Minimal fake WebSocket: opens on listener registration, records sends. */ +class FakeAdapterSocket implements AetherWebSocketLike { + readonly sent: Array = []; + closed = false; + private opened = false; + private readonly listeners = new Map void>>(); + + addEventListener(type: string, listener: (event: never) => void): void { + const list = this.listeners.get(type) ?? []; + list.push(listener as (event: unknown) => void); + this.listeners.set(type, list); + if (type === "open" && this.opened) { + (listener as () => void)(); + } + } + + send(data: string): void { + this.sent.push(data); + } + + close(): void { + if (this.closed) { + return; + } + this.closed = true; + this.fire("close", { code: 1000, reason: "client closed" }); + } + + open(): void { + this.opened = true; + this.fire("open", undefined); + } + + message(frame: unknown): void { + this.fire("message", { data: JSON.stringify(frame) }); + } + + private fire(type: string, event: unknown): void { + for (const listener of this.listeners.get(type) ?? []) { + listener(event); + } + } +} + +const emptyDelta = (task: AetherTask, latestSequence: number): AetherConversationDelta => ({ + task, + messages: [], + activity: [], + activeProcessingTurn: null, + latestSequence, + removedMessageIds: [], + truncated: false, +}); + +const settleAdapterPump = Effect.gen(function* () { + for (let i = 0; i < 8; i++) { + yield* TestClock.adjust("0 millis"); + yield* Effect.yieldNow; + } +}); + +describe("AetherAdapter event pipeline", () => { + const zeroTiming = { + pollInitialMs: 0, + pollMaxMs: 0, + reconnectInitialMs: 0, + reconnectMaxMs: 0, + connectDefaultRetryMs: 0, + }; + + const streamingRestClient = (deltaSequences: Array): AetherRestClient => ({ + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + getTask: () => Effect.succeed(processingTask), + connectWorkspace: () => + Effect.succeed({ + state: "running", + transport: { websocket_path: "/workspaces/ws-1/ws", preview_token: "t".repeat(32) }, + } as const), + getConversationDelta: (_taskId, after) => + Effect.sync(() => { + deltaSequences.push(after); + }).pipe(Effect.as(emptyDelta(processingTask, after))), + }); + + it.effect("attaches passively on resume and streams mapped live events", () => + Effect.gen(function* () { + const sockets: Array = []; + const deltaSequences: Array = []; + yield* withAdapter( + { + restClient: streamingRestClient(deltaSequences), + socket: { + apiBaseUrl: "https://api.runaether.dev", + apiKey: "aether_test_key", + timing: zeroTiming, + webSocketFactory: () => { + const socket = new FakeAdapterSocket(); + sockets.push(socket); + socket.open(); + return socket; + }, + }, + }, + (adapter) => + Effect.gen(function* () { + const collector = yield* adapter.streamEvents.pipe( + Stream.take(5), + Stream.runCollect, + Effect.forkScoped, + ); + const session = yield* adapter.startSession( + startInput({ + resumeCursor: { schemaVersion: 1, taskId: "task-1", latestSequence: 7 }, + }), + ); + yield* settleAdapterPump; + + // Passive attach: subscribed to the agent channel for the task, + // and the delta reconciliation ran from the CURSOR's sequence. + expect(sockets).toHaveLength(1); + expect(sockets[0]!.sent[0]).toBe( + '{"channel":"agent","type":"subscribe","taskId":"task-1"}', + ); + expect(deltaSequences).toEqual([7]); + + // Live frames flow through the mapper into streamEvents. + sockets[0]!.message(wsAssistantDelta); + sockets[0]!.message(wsTurnCompleted); + yield* settleAdapterPump; + yield* adapter.stopSession(session.threadId); + expect(sockets[0]!.closed).toBe(true); + + const events = yield* Fiber.join(collector); + expect(events.map((event) => event.type)).toEqual([ + "session.started", + // The reconcile's status projection: resuming onto a + // processing task shows the session as running, not idle. + "session.state.changed", + "content.delta", + "turn.completed", + "session.exited", + ]); + expect(events[1]).toMatchObject({ payload: { state: "running" } }); + const delta = events[2]!; + expect(delta).toMatchObject({ + eventId: "aether:task-1:stream:m1:1", + threadId: session.threadId, + payload: { streamKind: "assistant_text", delta: "Looking at the" }, + }); + }), + ); + }), + ); + + it.effect("does not attach when the thread has no task yet", () => + Effect.gen(function* () { + const sockets: Array = []; + yield* withAdapter( + { + restClient: { ...unusedRestClient, listProjects: () => Effect.succeed([project()]) }, + socket: { + apiBaseUrl: "https://api.runaether.dev", + apiKey: "aether_test_key", + timing: zeroTiming, + webSocketFactory: () => { + const socket = new FakeAdapterSocket(); + sockets.push(socket); + socket.open(); + return socket; + }, + }, + }, + (adapter) => + Effect.gen(function* () { + yield* adapter.startSession(startInput()); + yield* settleAdapterPump; + // No task → nothing to attach to until the first sendTurn (T6). + expect(sockets).toHaveLength(0); + }), + ); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/AetherAdapter.ts b/apps/server/src/provider/Layers/AetherAdapter.ts index edf0cdfb5334..b15003104a4b 100644 --- a/apps/server/src/provider/Layers/AetherAdapter.ts +++ b/apps/server/src/provider/Layers/AetherAdapter.ts @@ -1,10 +1,14 @@ /** * AetherAdapter — session core for the Aether cloud-task driver. * - * T2+T3 slice: real startSession/listSessions/hasSession/readThread/ - * stopSession/stopAll over the REST client, with the turn surface - * (sendTurn/interruptTurn/respondToUserInput/rollbackThread) still failing - * loudly until the streaming slices (build items 5–7, 9, 10) land. + * T2–T5 slice: real startSession/listSessions/hasSession/readThread/ + * stopSession/stopAll over the REST client, plus the event pipeline (build + * items 5+6): a session resumed onto a live task attaches PASSIVELY to its + * workspace WS (never booting a VM to view), maps the 13-kind live event + * union through `eventMapper`, and reconciles the durable conversation delta + * on every (re)connect. The turn surface (sendTurn/interruptTurn/ + * respondToUserInput/rollbackThread) still fails loudly until build items + * 7, 9 and 10 land. * * Design invariants (docs/aether-driver-plumbing-spec.md §2.3): * - startSession NEVER creates a task — the task is created on the first @@ -14,9 +18,13 @@ * exists remotely. * - stopSession / stopAll are PURE DISCONNECTS: the cloud task keeps * running and the VM idles itself out. `/stop` is never called here. + * Closing the session scope tears the socket down (the reaper-safe idle + * path: dropping the WS and ceasing activity pings lets the VM suspend). * - resumeCursor = `{schemaVersion: 1, taskId, latestSequence, turnLedger?}`; * t3 persists it at startSession/sendTurn returns, so a fresh session - * (no task yet) carries none. + * (no task yet) carries none. latestSequence refreshes in memory as the + * mapper advances; replay safety comes from the mapper's deterministic + * event IDs, not cursor freshness. * * @module provider/Layers/AetherAdapter */ @@ -33,6 +41,7 @@ import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; import * as Queue from "effect/Queue"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -51,10 +60,16 @@ import type { ProviderThreadTurnSnapshot, } from "../Services/ProviderAdapter.ts"; import { AETHER_API_KEY_ENV_VAR } from "./AetherProvider.ts"; +import { makeAetherEventMapper, type AetherEventMapper } from "./aether/eventMapper.ts"; import type { AetherRestClient } from "./aether/restClient.ts"; import type { AetherProject, AetherTimelineMessage } from "./aether/restSchemas.ts"; import { toolLifecycleItemTypeFromAether } from "./aether/vendored/canonicalItemType.ts"; import { parseFileChanges } from "./aether/vendored/toolDisplay.ts"; +import { + runAetherAgentStream, + type AetherStreamTiming, + type AetherWebSocketFactory, +} from "./aether/workspaceSocket.ts"; const PROVIDER = ProviderDriverKind.make("aether"); @@ -130,6 +145,18 @@ export interface AetherSessionGit { ) => Effect.Effect; } +/** Transport coordinates for the workspace WS attach (build item 5). */ +export interface AetherAdapterSocketOptions { + /** Same origin the REST client talks to; the wss URL derives from it. */ + readonly apiBaseUrl: string; + /** The instance's `AETHER_API_KEY` — the socket authenticates with it. */ + readonly apiKey: string; + /** Test seam; defaults to the Node global WebSocket. */ + readonly webSocketFactory?: AetherWebSocketFactory; + /** Test seam for poll/reconnect pacing. */ + readonly timing?: Partial; +} + export interface AetherAdapterOptions { readonly instanceId: ProviderInstanceId; /** Fallback session cwd when the start input carries none (ServerConfig.cwd). */ @@ -140,6 +167,11 @@ export interface AetherAdapterOptions { * fails loudly with the remediation instead of the driver failing create(). */ readonly restClient: AetherRestClient | undefined; + /** + * Undefined only when `restClient` is (keyless instance) or in REST-only + * unit tests; the driver always passes it alongside a real client. + */ + readonly socket?: AetherAdapterSocketOptions | undefined; } interface AetherSessionContext { @@ -151,6 +183,10 @@ interface AetherSessionContext { latestSequence: number; /** Opaque turn ledger carried from the resume cursor (see AetherResumeCursor). */ turnLedger: unknown; + /** Owns the attach pump + socket; closed on stopSession/stopAll. */ + sessionScope: Scope.Closeable | undefined; + /** The session's event mapper; its latestSequence() is the live cursor. */ + mapper: AetherEventMapper | undefined; } const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -310,6 +346,202 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( : Effect.fail(new ProviderAdapterSessionNotFoundError({ provider: PROVIDER, threadId })); }; + const closeSessionScope = (context: AetherSessionContext) => + context.sessionScope === undefined + ? Effect.void + : Effect.ignore(Scope.close(context.sessionScope, Exit.void)); + + // Registry/instance teardown must also stop every attach pump (delete = + // full teardown). Registered AFTER the queue's acquireRelease so it runs + // FIRST on close: pumps stop emitting, then the queue shuts down. + yield* Effect.acquireRelease(Effect.void, () => + Effect.gen(function* () { + for (const context of sessions.values()) { + yield* closeSessionScope(context); + } + }), + ); + + const emitAll = (events: ReadonlyArray) => + Effect.forEach(events, emit, { discard: true }); + + /** + * The event pipeline (build items 5+6): attach passively to the task's + * workspace WS, feed live events through the mapper, and reconcile the + * durable delta on every (re)connect — the REST backstop is the ONLY + * recovery for live-only turn.* settles missed while detached. Forked into + * the session scope; failures surface as runtime.error events (the session + * itself stays readable via REST). + */ + const startStreamPump = Effect.fn("startAetherStreamPump")(function* ( + context: AetherSessionContext, + restClient: AetherRestClient, + socket: AetherAdapterSocketOptions, + taskId: string, + ) { + const sessionScope = yield* Scope.make(); + context.sessionScope = sessionScope; + const threadId = context.session.threadId; + const mapper = makeAetherEventMapper({ + provider: PROVIDER, + instanceId: options.instanceId, + threadId, + taskId, + initialSequence: context.latestSequence, + }); + context.mapper = mapper; + + // Stream-pump callbacks must be infallible (a failing callback would + // kill the socket loop); crypto id generation dying is the only + // acceptable defect here. + const freshEventId = Effect.orDie(randomEventId); + + // The durable reconciliation — also the poll hook the T6 turn engine + // will drive for turn-settle backstops. A transient REST failure warns + // loudly and leaves the cursor untouched, so the next (re)connect + // retries the exact same range instead of silently skipping it. + const reconcile = Effect.gen(function* () { + const delta = yield* restClient.getConversationDelta(taskId, mapper.latestSequence()); + const events = mapper.reconcileDelta(delta, yield* nowIso); + context.latestSequence = mapper.latestSequence(); + yield* emitAll(events); + }).pipe( + Effect.catch((error) => + Effect.gen(function* () { + yield* Effect.logWarning("aether.reconcile.failed", { taskId, error: String(error) }); + yield* emit({ + eventId: yield* freshEventId, + provider: PROVIDER, + providerInstanceId: options.instanceId, + threadId, + createdAt: yield* nowIso, + type: "runtime.warning", + payload: { + message: `Could not reconcile the Aether conversation feed: ${error.message}`, + }, + }); + }), + ), + ); + + let sessionStartedEmitted = false; + let slashCommandsLogged = false; + // Degradation warning: once per failure streak, reset on reconnect. + let connectRetryWarned = false; + + yield* runAetherAgentStream({ + restClient, + apiBaseUrl: socket.apiBaseUrl, + apiKey: socket.apiKey, + taskId, + ...(socket.webSocketFactory !== undefined + ? { webSocketFactory: socket.webSocketFactory } + : {}), + ...(socket.timing !== undefined ? { timing: socket.timing } : {}), + onConnected: () => + Effect.gen(function* () { + connectRetryWarned = false; + if (!sessionStartedEmitted) { + sessionStartedEmitted = true; + yield* emit({ + eventId: yield* freshEventId, + provider: PROVIDER, + providerInstanceId: options.instanceId, + threadId, + createdAt: yield* nowIso, + type: "session.started", + payload: { message: "Attached to the Aether workspace stream." }, + }); + } + yield* reconcile; + }), + onEvent: (event) => + Effect.gen(function* () { + if (event.kind === "slash_commands.updated" && !slashCommandsLogged) { + // No t3 slash-command surface for cloud sessions yet; log once. + slashCommandsLogged = true; + yield* Effect.logInfo("aether.slash-commands.ignored", { taskId }); + } + const events = mapper.mapWsEvent(event, yield* nowIso); + context.latestSequence = mapper.latestSequence(); + yield* emitAll(events); + }), + onFrameDropped: (problem) => + Effect.gen(function* () { + yield* Effect.logWarning("aether.frame.dropped", { taskId, ...problem }); + yield* emit({ + eventId: yield* freshEventId, + provider: PROVIDER, + providerInstanceId: options.instanceId, + threadId, + createdAt: yield* nowIso, + type: "runtime.warning", + payload: { + message: + "Dropped an Aether live event t3 could not parse; the durable feed remains authoritative.", + detail: problem, + }, + }); + }), + onConnectRetry: (failure) => + Effect.gen(function* () { + // The attach never reached subscribe, so onConnected's reconcile + // will not run — surface the degradation ONCE per failure streak + // and drive the durable backstop from this retry beat instead + // (REST-delta-only degrade, spec §3.11): live turn settles are + // unrecoverable except through this reconcile while opens fail. + yield* Effect.logWarning("aether.stream.connect-retry", { taskId, ...failure }); + if (!connectRetryWarned) { + connectRetryWarned = true; + yield* emit({ + eventId: yield* freshEventId, + provider: PROVIDER, + providerInstanceId: options.instanceId, + threadId, + createdAt: yield* nowIso, + type: "runtime.warning", + payload: { + message: + "Cannot reach the Aether workspace's live stream; retrying. The transcript keeps updating from the durable feed meanwhile.", + detail: failure, + }, + }); + } + yield* reconcile; + }), + onDurableOnly: (reason) => + Effect.gen(function* () { + // Stable end state for this attach: replay the durable feed once + // so the transcript catches up; the next sendTurn re-attaches. + yield* Effect.logInfo("aether.stream.durable-only", { taskId, reason }); + yield* reconcile; + }), + }).pipe( + Effect.catch((error) => + Effect.gen(function* () { + yield* Effect.logWarning("aether.stream.failed", { taskId, error: String(error) }); + const isTaskErrored = error._tag === "AetherTaskErroredError"; + yield* emit({ + // The task-errored surface shares the mapper's deterministic id + // so a REST-side projection of the same failure collides + // (idempotent) instead of duplicating. + eventId: isTaskErrored ? EventId.make(`aether:${taskId}:errored`) : yield* freshEventId, + provider: PROVIDER, + providerInstanceId: options.instanceId, + threadId, + createdAt: yield* nowIso, + type: "runtime.error", + payload: { + message: error.message, + class: isTaskErrored ? "provider_error" : "transport_error", + }, + }); + }), + ), + Effect.forkIn(sessionScope), + ); + }); + const startSession: ProviderAdapterShape["startSession"] = Effect.fn( "startSession", )(function* (input) { @@ -433,27 +665,47 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( taskId, latestSequence, turnLedger, + sessionScope: undefined, + mapper: undefined, }; const resumeCursor = buildAetherResumeCursor(context); if (resumeCursor !== undefined) { context.session = { ...context.session, resumeCursor }; } + // A re-entrant start (mode change, worktree hop) replaces the previous + // attach: tear its socket down before binding the new one. + const previous = sessions.get(input.threadId); + if (previous !== undefined) { + yield* closeSessionScope(previous); + } sessions.set(input.threadId, context); + + // (5) Passive stream attach: a resumed live task starts streaming + // immediately. No task yet (fresh thread) → nothing to attach until the + // first sendTurn (T6) creates one. + if (taskId !== undefined && options.socket !== undefined) { + yield* startStreamPump(context, restClient, options.socket, taskId); + } return context.session; }); // Shared pure-disconnect teardown: the cloud task keeps running and the VM // idles itself out (spec §2.3 reaper-safety) — never POST /tasks/{id}/stop. - // Ingestion relies on one graceful session.exited per thread to clear + // Closing the scope interrupts the attach pump and its finalizer closes the + // WS; ingestion relies on one graceful session.exited per thread to clear // active-turn/liveness state, so every disconnect path emits it. const disconnectSession = Effect.fn("disconnectSession")(function* ( threadId: ThreadId, context: AetherSessionContext, ) { + yield* closeSessionScope(context); sessions.delete(threadId); yield* emit({ eventId: yield* randomEventId, provider: PROVIDER, + // Ingestion rewrites the thread session from this event and preserves + // instance identity only when the event carries it. + providerInstanceId: options.instanceId, threadId, createdAt: yield* nowIso, type: "session.exited", @@ -542,8 +794,8 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( readThread, rollbackThread: () => notImplemented("rollbackThread"), // Pure disconnect for every session; remote tasks are untouched. Each - // thread gets the same graceful session.exited stopSession emits — - // ingestion clears per-session turn/liveness state from that event. + // thread gets the same scope-closing teardown and graceful session.exited + // stopSession emits — ingestion clears per-session state from that event. stopAll: () => Effect.gen(function* () { for (const [threadId, context] of [...sessions.entries()]) { diff --git a/apps/server/src/provider/Layers/aether/__snapshots__/eventMapper.test.ts.snap b/apps/server/src/provider/Layers/aether/__snapshots__/eventMapper.test.ts.snap new file mode 100644 index 000000000000..59610ac06510 --- /dev/null +++ b/apps/server/src/provider/Layers/aether/__snapshots__/eventMapper.test.ts.snap @@ -0,0 +1,380 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`AetherEventMapper — durable reconciliation > snapshots a full delta replay from a cold cursor 1`] = ` +[ + { + "createdAt": "2026-08-08T10:00:01.500Z", + "eventId": "aether:task-1:item:thinking:m2", + "itemId": "thinking:m2", + "payload": { + "detail": "The test asserts…", + "itemType": "reasoning", + "status": "completed", + }, + "provider": "aether", + "providerInstanceId": "aether", + "threadId": "thread-1", + "turnId": "aether-turn-u1", + "type": "item.completed", + }, + { + "createdAt": "2026-08-08T10:00:02.000Z", + "eventId": "aether:task-1:item:m1", + "itemId": "m1", + "payload": { + "detail": "Looking at the failing test.", + "itemType": "assistant_message", + "status": "completed", + }, + "provider": "aether", + "providerInstanceId": "aether", + "threadId": "thread-1", + "turnId": "aether-turn-u1", + "type": "item.completed", + }, + { + "createdAt": "2026-08-08T10:00:03.000Z", + "eventId": "aether:task-1:tool:call-fc-codex:output-available", + "itemId": "call-fc-codex", + "payload": { + "data": { + "files": [ + { + "path": "src/app.ts", + }, + { + "path": "src/new.ts", + }, + ], + "toolCallId": "call-fc-codex", + }, + "itemType": "file_change", + "status": "completed", + "title": "Edit src/app.ts, src/new.ts", + }, + "provider": "aether", + "providerInstanceId": "aether", + "threadId": "thread-1", + "turnId": "aether-turn-u1", + "type": "item.completed", + }, + { + "createdAt": "2026-08-08T10:00:04.000Z", + "eventId": "aether:task-1:seq:5", + "payload": { + "state": "compacted", + }, + "provider": "aether", + "providerInstanceId": "aether", + "threadId": "thread-1", + "type": "thread.state.changed", + }, + { + "createdAt": "2026-08-08T12:00:00.000Z", + "eventId": "aether:task-1:turn:u1:settled", + "payload": { + "state": "completed", + }, + "provider": "aether", + "providerInstanceId": "aether", + "threadId": "thread-1", + "turnId": "aether-turn-u1", + "type": "turn.completed", + }, +] +`; + +exports[`AetherEventMapper — live WS events > snapshots the full golden turn (13-kind coverage) 1`] = ` +[ + { + "createdAt": "2026-08-08T10:00:00.500Z", + "eventId": "aether:task-1:stream:thinking:m2:1", + "itemId": "thinking:m2", + "payload": { + "delta": "The test asserts…", + "streamKind": "reasoning_text", + }, + "provider": "aether", + "providerInstanceId": "aether", + "threadId": "thread-1", + "turnId": "aether-turn-u1", + "type": "content.delta", + }, + { + "createdAt": "2026-08-08T10:00:01.500Z", + "eventId": "aether:task-1:item:thinking:m2", + "itemId": "thinking:m2", + "payload": { + "detail": "The test asserts…", + "itemType": "reasoning", + "status": "completed", + }, + "provider": "aether", + "providerInstanceId": "aether", + "threadId": "thread-1", + "turnId": "aether-turn-u1", + "type": "item.completed", + }, + { + "createdAt": "2026-08-08T10:00:01.500Z", + "eventId": "aether:task-1:stream:m1:1", + "itemId": "m1", + "payload": { + "delta": "Looking at the", + "streamKind": "assistant_text", + }, + "provider": "aether", + "providerInstanceId": "aether", + "threadId": "thread-1", + "turnId": "aether-turn-u1", + "type": "content.delta", + }, + { + "createdAt": "2026-08-08T10:00:01.500Z", + "eventId": "aether:task-1:stream:m1:2", + "itemId": "m1", + "payload": { + "delta": " failing test.", + "streamKind": "assistant_text", + }, + "provider": "aether", + "providerInstanceId": "aether", + "threadId": "thread-1", + "turnId": "aether-turn-u1", + "type": "content.delta", + }, + { + "createdAt": "2026-08-08T10:00:02.000Z", + "eventId": "aether:task-1:item:m1", + "itemId": "m1", + "payload": { + "detail": "Looking at the failing test.", + "itemType": "assistant_message", + "status": "completed", + }, + "provider": "aether", + "providerInstanceId": "aether", + "threadId": "thread-1", + "turnId": "aether-turn-u1", + "type": "item.completed", + }, + { + "createdAt": "2026-08-08T10:00:03.000Z", + "eventId": "aether:task-1:tool:call-fc-codex:output-available", + "itemId": "call-fc-codex", + "payload": { + "data": { + "files": [ + { + "path": "src/app.ts", + }, + { + "path": "src/new.ts", + }, + ], + "toolCallId": "call-fc-codex", + }, + "itemType": "file_change", + "status": "completed", + "title": "Edit src/app.ts, src/new.ts", + }, + "provider": "aether", + "providerInstanceId": "aether", + "threadId": "thread-1", + "turnId": "aether-turn-u1", + "type": "item.completed", + }, + { + "createdAt": "2026-08-08T10:00:04.000Z", + "eventId": "aether:task-1:tool:call-fc-claude:output-available", + "itemId": "call-fc-claude", + "payload": { + "data": { + "files": [ + { + "path": "src/util.ts", + }, + ], + "toolCallId": "call-fc-claude", + }, + "itemType": "file_change", + "status": "completed", + "title": "Edit src/util.ts", + }, + "provider": "aether", + "providerInstanceId": "aether", + "threadId": "thread-1", + "turnId": "aether-turn-u1", + "type": "item.completed", + }, + { + "createdAt": "2026-08-08T10:00:05.000Z", + "eventId": "aether:task-1:tool:call-bash:input-available", + "itemId": "call-bash", + "payload": { + "data": { + "item": { + "command": "pnpm test", + "cwd": "/home/coder/project", + }, + "toolCallId": "call-bash", + }, + "detail": "pnpm test", + "itemType": "command_execution", + "status": "inProgress", + "title": "pnpm test", + }, + "provider": "aether", + "providerInstanceId": "aether", + "threadId": "thread-1", + "turnId": "aether-turn-u1", + "type": "item.started", + }, + { + "createdAt": "2026-08-08T10:00:06.000Z", + "eventId": "aether:task-1:tool:call-bash:output-error", + "itemId": "call-bash", + "payload": { + "data": { + "item": { + "command": "pnpm test", + "cwd": "/home/coder/project", + }, + "toolCallId": "call-bash", + }, + "detail": "pnpm test +1 test failed +", + "itemType": "command_execution", + "status": "failed", + "title": "pnpm test", + }, + "provider": "aether", + "providerInstanceId": "aether", + "threadId": "thread-1", + "turnId": "aether-turn-u1", + "type": "item.completed", + }, + { + "createdAt": "2026-08-08T10:00:07.000Z", + "eventId": "aether:task-1:tool:call-denied:output-denied", + "itemId": "call-denied", + "payload": { + "data": { + "item": { + "command": "rm -rf /", + }, + "toolCallId": "call-denied", + }, + "detail": "rm -rf /", + "itemType": "command_execution", + "status": "declined", + "title": "rm -rf /", + }, + "provider": "aether", + "providerInstanceId": "aether", + "threadId": "thread-1", + "turnId": "aether-turn-u1", + "type": "item.completed", + }, + { + "createdAt": "2026-08-08T10:00:07.000Z", + "eventId": "aether:task-1:tool:call-denied:output-denied:denied", + "payload": { + "reason": "denied by policy", + "toolName": "Bash", + "toolUseId": "call-denied", + }, + "provider": "aether", + "providerInstanceId": "aether", + "threadId": "thread-1", + "turnId": "aether-turn-u1", + "type": "tool.denied", + }, + { + "createdAt": "2026-08-08T10:00:08.000Z", + "eventId": "aether:task-1:tool:call-mcp:output-available", + "itemId": "call-mcp", + "payload": { + "data": { + "item": { + "args": { + "title": "Fix flake", + }, + "result": "{"id":"LIN-1"}", + "server": "linear", + "tool": "create_issue", + }, + "toolCallId": "call-mcp", + }, + "itemType": "mcp_tool_call", + "status": "completed", + "title": "linear: create_issue", + }, + "provider": "aether", + "providerInstanceId": "aether", + "threadId": "thread-1", + "turnId": "aether-turn-u1", + "type": "item.completed", + }, + { + "createdAt": "2026-08-08T10:00:09.000Z", + "eventId": "aether:task-1:tool:call-todo:output-available", + "itemId": "call-todo", + "payload": { + "data": { + "item": { + "input": {}, + "name": "TodoWrite", + }, + "toolCallId": "call-todo", + }, + "itemType": "dynamic_tool_call", + "status": "completed", + "title": "Update plan", + }, + "provider": "aether", + "providerInstanceId": "aether", + "threadId": "thread-1", + "turnId": "aether-turn-u1", + "type": "item.completed", + }, + { + "createdAt": "2026-08-08T10:00:09.000Z", + "eventId": "aether:task-1:tool:call-todo:output-available:plan", + "payload": { + "plan": [ + { + "status": "completed", + "step": "Reproduce the failure", + }, + { + "status": "inProgress", + "step": "Fix the reducer", + }, + { + "status": "pending", + "step": "Add a regression test", + }, + ], + }, + "provider": "aether", + "providerInstanceId": "aether", + "threadId": "thread-1", + "turnId": "aether-turn-u1", + "type": "turn.plan.updated", + }, + { + "createdAt": "2026-08-08T10:00:10.000Z", + "eventId": "aether:task-1:turn:u1:settled", + "payload": { + "state": "completed", + }, + "provider": "aether", + "providerInstanceId": "aether", + "threadId": "thread-1", + "turnId": "aether-turn-u1", + "type": "turn.completed", + }, +] +`; diff --git a/apps/server/src/provider/Layers/aether/eventMapper.fixtures.ts b/apps/server/src/provider/Layers/aether/eventMapper.fixtures.ts new file mode 100644 index 000000000000..df2a1e6d8957 --- /dev/null +++ b/apps/server/src/provider/Layers/aether/eventMapper.fixtures.ts @@ -0,0 +1,501 @@ +/** + * Golden fixtures for the Aether event pipeline tests — REAL wire shapes for + * both transports, derived from the aether repo's schemas and normalizer + * builders (read-only reference, pinned in the plumbing spec): + * + * - WS frames: packages/workspace-protocol/src/messages.ts (agent task + * event union; strict server-side, so anything here MUST also satisfy + * the strict schemas — extra keys are only used where the wire is an + * open record like tool `input`) + * - codex file_change input: apps/workspace-service/src/agents/codex/ + * event-normalizer.ts buildFileChangeInput — `{files:[{path, op, + * oldContent?, newContent?, diff?}], paths, path?, op?}` + * - claude Edit: raw tool passthrough `{file_path, old_string, new_string}` + * - command execution: codex `{command, cwd}` input + `terminal` display + * block `{command, stdout?, exitCode?}` + * - awaiting_input: live `{pendingInputId, payload:{toolName, input}}` vs + * the REST projection `{kind, tool_id, input}` (tasks_read.go:685-726) — + * DIFFERENT shapes, same pending input (`pendingInputId` ≡ `tool_id`) + * - durable rows: tasks_read.go TaskTimelineMessage + * + * Used as test INPUT only. + * + * @module provider/Layers/aether/eventMapper.fixtures + */ +import type { AetherConversationDelta, AetherTask, AetherTimelineMessage } from "./restSchemas.ts"; + +export const FIXTURE_TASK_ID = "task-1"; + +const frameBase = { + channel: "agent", + type: "task_event", + taskId: FIXTURE_TASK_ID, +} as const; + +// --------------------------------------------------------------------------- +// WS frames (live transport) +// --------------------------------------------------------------------------- + +export const wsAssistantDelta = { + ...frameBase, + kind: "assistant_message.delta", + messageId: "m1", + turnId: "u1", + createdAt: "2026-08-08T10:00:01.000Z", + payload: { delta: "Looking at the" }, +} as const; + +export const wsAssistantDelta2 = { + ...frameBase, + kind: "assistant_message.delta", + messageId: "m1", + turnId: "u1", + createdAt: "2026-08-08T10:00:01.100Z", + payload: { delta: " failing test." }, +} as const; + +// Thinking ids arrive `thinking:`-prefixed on the live stream +// (task-stream-emitter.ts durableThinkingMessageId). +export const wsThinkingDelta = { + ...frameBase, + kind: "thinking.delta", + messageId: "thinking:m2", + turnId: "u1", + createdAt: "2026-08-08T10:00:00.500Z", + payload: { delta: "The test asserts…" }, +} as const; + +export const wsStreamComplete = { + ...frameBase, + kind: "stream.complete", + messageId: "m1", + createdAt: "2026-08-08T10:00:02.000Z", +} as const; + +export const wsAssistantCompleted = { + ...frameBase, + kind: "assistant_message.completed", + messageId: "m1", + turnId: "u1", + createdAt: "2026-08-08T10:00:02.000Z", + payload: { content: "Looking at the failing test." }, +} as const; + +export const wsThinkingCompleted = { + ...frameBase, + kind: "thinking.completed", + messageId: "thinking:m2", + turnId: "u1", + createdAt: "2026-08-08T10:00:01.500Z", + payload: { content: "The test asserts…" }, +} as const; + +/** Codex file_change: normalizer-built files[] input + result-borne diff. */ +export const wsCodexFileChange = { + ...frameBase, + kind: "tool_call.completed", + toolCallId: "call-fc-codex", + turnId: "u1", + createdAt: "2026-08-08T10:00:03.000Z", + payload: { + name: "apply_patch", + input: { + files: [ + { + path: "src/app.ts", + op: "modify", + oldContent: "const a = 1;\n", + newContent: "const a = 2;\n", + }, + { + path: "src/new.ts", + op: "create", + diff: "diff --git a/src/new.ts b/src/new.ts\n@@ -0,0 +1 @@\n+export {};\n", + }, + ], + paths: ["src/app.ts", "src/new.ts"], + }, + display: { label: "Edit src/app.ts, src/new.ts" }, + status: "output-available", + itemType: "file_change", + }, +} as const; + +/** Claude Edit: raw provider input passthrough. */ +export const wsClaudeEdit = { + ...frameBase, + kind: "tool_call.completed", + toolCallId: "call-fc-claude", + turnId: "u1", + createdAt: "2026-08-08T10:00:04.000Z", + payload: { + name: "Edit", + input: { + file_path: "src/util.ts", + old_string: "return a;", + new_string: "return a + 1;", + }, + display: { label: "Edit src/util.ts" }, + status: "output-available", + itemType: "file_change", + }, +} as const; + +/** Command start: input-available, no terminal output yet. */ +export const wsCommandStarted = { + ...frameBase, + kind: "tool_call.started", + toolCallId: "call-bash", + turnId: "u1", + createdAt: "2026-08-08T10:00:05.000Z", + payload: { + name: "Bash", + input: { command: "pnpm test", cwd: "/home/coder/project" }, + display: { label: "pnpm test" }, + status: "input-available", + itemType: "command_execution", + }, +} as const; + +/** Command failure: terminal block carries stdout + nonzero exitCode. */ +export const wsCommandFailed = { + ...frameBase, + kind: "tool_call.failed", + toolCallId: "call-bash", + turnId: "u1", + createdAt: "2026-08-08T10:00:06.000Z", + payload: { + name: "Bash", + input: { command: "pnpm test", cwd: "/home/coder/project" }, + display: { + label: "pnpm test", + blocks: [ + { + type: "terminal", + command: "pnpm test", + stdout: "1 test failed", + exitCode: 1, + }, + ], + }, + status: "output-error", + itemType: "command_execution", + error: "Command exited with code 1", + }, +} as const; + +/** Denied tool: output-denied → declined + tool.denied. */ +export const wsToolDenied = { + ...frameBase, + kind: "tool_call.completed", + toolCallId: "call-denied", + turnId: "u1", + createdAt: "2026-08-08T10:00:07.000Z", + payload: { + name: "Bash", + input: { command: "rm -rf /" }, + display: { label: "rm -rf /" }, + status: "output-denied", + itemType: "command_execution", + error: "denied by policy", + }, +} as const; + +/** MCP tool card: mcp____ naming (event-normalizer.ts:863-903). */ +export const wsMcpToolCall = { + ...frameBase, + kind: "tool_call.completed", + toolCallId: "call-mcp", + turnId: "u1", + createdAt: "2026-08-08T10:00:08.000Z", + payload: { + name: "mcp__linear__create_issue", + input: { title: "Fix flake" }, + display: { label: "linear: create_issue" }, + status: "output-available", + itemType: "mcp_tool_call", + result: '{"id":"LIN-1"}', + }, +} as const; + +/** Tool card carrying a todo_list display block (inline turn-plan chip). */ +export const wsTodoTool = { + ...frameBase, + kind: "tool_call.completed", + toolCallId: "call-todo", + turnId: "u1", + createdAt: "2026-08-08T10:00:09.000Z", + payload: { + name: "TodoWrite", + input: {}, + display: { + label: "Update plan", + blocks: [ + { + type: "todo_list", + items: [ + { text: "Reproduce the failure", status: "completed" }, + { text: "Fix the reducer", status: "in_progress" }, + { text: "Add a regression test", status: "pending" }, + ], + }, + ], + }, + status: "output-available", + itemType: "task_tracking", + }, +} as const; + +export const wsTurnCompleted = { + ...frameBase, + kind: "turn.completed", + turnId: "u1", + createdAt: "2026-08-08T10:00:10.000Z", + payload: { status: "completed" }, +} as const; + +export const wsTurnFailed = { + ...frameBase, + kind: "turn.failed", + turnId: "u1", + createdAt: "2026-08-08T10:00:10.000Z", + payload: { errorMessage: "agent crashed" }, +} as const; + +/** + * LIVE awaiting_input, questions: `{turnId, pendingInputId, toolCallId, + * payload:{toolName, input}}` — NO `kind`, NO `tool_id` on this path. + */ +export const wsAwaitingInputQuestions = { + ...frameBase, + kind: "turn.awaiting_input", + turnId: "u1", + pendingInputId: "pi-1", + toolCallId: "call-ask", + createdAt: "2026-08-08T10:00:11.000Z", + payload: { + toolName: "ask_user", + input: { + questions: [ + { + id: "q1", + header: "Approach", + question: "Which approach should I take?", + options: [ + { label: "Patch the reducer", description: "Smallest change" }, + // description absent on the wire — the mapper synthesizes it. + { label: "Rewrite the module" }, + ], + multiSelect: false, + }, + ], + }, + }, +} as const; + +export const wsAwaitingInputPlan = { + ...frameBase, + kind: "turn.awaiting_input", + turnId: "u1", + pendingInputId: "pi-2", + toolCallId: "call-plan", + createdAt: "2026-08-08T10:00:12.000Z", + payload: { + toolName: "propose_plan", + input: { + summary: "Fix the reducer bug", + plan: "1. Reproduce\n2. Fix\n3. Test", + }, + }, +} as const; + +export const wsAwaitingInputStopTask = { + ...frameBase, + kind: "turn.awaiting_input", + turnId: "u1", + pendingInputId: "pi-3", + toolCallId: "call-stop", + createdAt: "2026-08-08T10:00:13.000Z", + payload: { toolName: "stop_task", input: {} }, +} as const; + +export const wsConversationTruncated = { + ...frameBase, + kind: "conversation.truncated", + messageId: "m1", + createdAt: "2026-08-08T10:00:14.000Z", + payload: { anchorMessageId: "m1" }, +} as const; + +export const wsSlashCommandsUpdated = { + ...frameBase, + kind: "slash_commands.updated", + createdAt: "2026-08-08T10:00:15.000Z", + payload: { slashCommands: [{ name: "review", description: "Review the diff" }] }, +} as const; + +/** A kind newer servers may emit — must be dropped after one log, never crash. */ +export const wsUnknownKindFrame = { + ...frameBase, + kind: "usage.updated", + createdAt: "2026-08-08T10:00:16.000Z", + payload: { inputTokens: 1200 }, +} as const; + +/** The golden live-turn sequence, in wire order. */ +export const wsGoldenTurn = [ + wsThinkingDelta, + wsThinkingCompleted, + wsAssistantDelta, + wsAssistantDelta2, + wsStreamComplete, + wsAssistantCompleted, + wsCodexFileChange, + wsClaudeEdit, + wsCommandStarted, + wsCommandFailed, + wsToolDenied, + wsMcpToolCall, + wsTodoTool, + wsTurnCompleted, +] as const; + +// --------------------------------------------------------------------------- +// REST (durable transport) +// --------------------------------------------------------------------------- + +const taskBase = { + id: FIXTURE_TASK_ID, + project_id: "project-1", + name: "Fix the flaky test", + agent_type: "codex", + model: "gpt-5.6-sol", + interaction_mode: "default", +} as const; + +export const taskProcessing: AetherTask = { + ...taskBase, + latest_sequence: 3, + status: "processing", + run_context: { workspace_id: "ws-1", started_at: "2026-08-08T10:00:00Z" }, +}; + +/** The idle state between EVERY pair of turns — READY, no state emission. */ +export const taskAwaitingMessage: AetherTask = { + ...taskBase, + latest_sequence: 9, + status: "awaiting_input", + run_context: { workspace_id: "ws-1", started_at: "2026-08-08T10:00:00Z" }, + awaiting_input: { kind: "message" }, +}; + +/** REST projection of the SAME pending input as wsAwaitingInputQuestions. */ +export const taskAwaitingQuestions: AetherTask = { + ...taskBase, + latest_sequence: 9, + status: "awaiting_input", + run_context: { workspace_id: "ws-1", started_at: "2026-08-08T10:00:00Z" }, + awaiting_input: { + kind: "questions", + tool_id: "pi-1", + input: wsAwaitingInputQuestions.payload.input, + }, +}; + +export const taskAwaitingPlan: AetherTask = { + ...taskBase, + latest_sequence: 9, + status: "awaiting_input", + run_context: { workspace_id: "ws-1", started_at: "2026-08-08T10:00:00Z" }, + awaiting_input: { + kind: "plan", + tool_id: "pi-2", + input: wsAwaitingInputPlan.payload.input, + }, +}; + +export const taskErrored: AetherTask = { + ...taskBase, + latest_sequence: 9, + status: "errored", + run_context: null, + error: "VM provisioning failed", + completed_at: "2026-08-08T10:05:00Z", +}; + +/** + * Durable timeline rows — the REST twins of the golden live turn. The + * assistant text row uses the durable `assistant:`-prefixed id form the spec + * calls out for dedupe; the thinking row is `thinking:`-prefixed on BOTH + * transports. + */ +export const deltaRows: ReadonlyArray = [ + { + id: "u1", + role: "user", + content: "fix the flaky test", + deliveryStatus: "delivered", + timestamp: "2026-08-08T10:00:00.000Z", + sequence: 1, + }, + { + id: "thinking:m2", + role: "assistant", + variant: "thinking", + content: "The test asserts…", + isStreaming: false, + timestamp: "2026-08-08T10:00:01.500Z", + sequence: 2, + }, + { + id: "assistant:m1", + role: "assistant", + variant: "text", + content: "Looking at the failing test.", + timestamp: "2026-08-08T10:00:02.000Z", + sequence: 3, + }, + { + id: "row-fc", + role: "assistant", + variant: "tool", + tool: { + id: "call-fc-codex", + name: "apply_patch", + input: wsCodexFileChange.payload.input, + status: "output-available", + itemType: "file_change", + display: { label: "Edit src/app.ts, src/new.ts" }, + }, + timestamp: "2026-08-08T10:00:03.000Z", + sequence: 4, + }, + { + id: "seam-1", + role: "assistant", + variant: "seam", + seam: { reason: "compaction" }, + timestamp: "2026-08-08T10:00:04.000Z", + sequence: 5, + }, +]; + +export function makeDelta(overrides?: { + readonly task?: AetherTask; + readonly messages?: ReadonlyArray; + readonly latestSequence?: number; + readonly activeProcessingTurn?: { readonly messageId: string; readonly startedAt: string } | null; + readonly truncated?: boolean; +}): AetherConversationDelta { + return { + task: overrides?.task ?? taskAwaitingMessage, + messages: overrides?.messages ?? deltaRows, + activity: [], + activeProcessingTurn: overrides?.activeProcessingTurn ?? null, + latestSequence: + overrides?.latestSequence ?? + Math.max(0, ...(overrides?.messages ?? deltaRows).map((row) => row.sequence)), + removedMessageIds: [], + truncated: overrides?.truncated ?? false, + }; +} diff --git a/apps/server/src/provider/Layers/aether/eventMapper.test.ts b/apps/server/src/provider/Layers/aether/eventMapper.test.ts new file mode 100644 index 000000000000..93da0bd18c61 --- /dev/null +++ b/apps/server/src/provider/Layers/aether/eventMapper.test.ts @@ -0,0 +1,779 @@ +import { describe, expect, it } from "@effect/vitest"; +import { + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; + +import { makeAetherEventMapper, parseAetherQuestions } from "./eventMapper.ts"; +import type { AetherTask } from "./restSchemas.ts"; +import { + FIXTURE_TASK_ID, + deltaRows, + makeDelta, + taskAwaitingMessage, + taskAwaitingPlan, + taskAwaitingQuestions, + taskErrored, + taskProcessing, + wsAssistantDelta, + wsAwaitingInputPlan, + wsAwaitingInputQuestions, + wsAwaitingInputStopTask, + wsClaudeEdit, + wsCodexFileChange, + wsCommandFailed, + wsCommandStarted, + wsConversationTruncated, + wsGoldenTurn, + wsMcpToolCall, + wsSlashCommandsUpdated, + wsThinkingDelta, + wsTodoTool, + wsToolDenied, + wsTurnCompleted, + wsTurnFailed, +} from "./eventMapper.fixtures.ts"; +import { parseAetherAgentFrame, type AetherAgentEvent } from "./wireEvents.ts"; + +const NOW = "2026-08-08T12:00:00.000Z"; + +const makeMapper = (initialSequence = 0) => + makeAetherEventMapper({ + provider: ProviderDriverKind.make("aether"), + instanceId: ProviderInstanceId.make("aether"), + threadId: ThreadId.make("thread-1"), + taskId: FIXTURE_TASK_ID, + initialSequence, + }); + +/** Fixtures are RAW wire frames; route them through the real frame parser so + * the fixtures also pin the wireEvents schemas to the golden shapes. */ +function parseFrame(frame: unknown): AetherAgentEvent { + const result = parseAetherAgentFrame(JSON.stringify(frame)); + if (result._tag !== "event") { + throw new Error(`fixture did not parse as an event: ${JSON.stringify(result)}`); + } + return result.event; +} + +function eventIds(events: ReadonlyArray): ReadonlyArray { + return events.map((event) => event.eventId); +} + +describe("AetherEventMapper — live WS events", () => { + it("snapshots the full golden turn (13-kind coverage)", () => { + const mapper = makeMapper(); + const events = wsGoldenTurn.flatMap((frame) => [...mapper.mapWsEvent(parseFrame(frame), NOW)]); + expect(events).toMatchSnapshot(); + }); + + it("maps assistant deltas to assistant_text with deterministic ids", () => { + const mapper = makeMapper(); + const [event] = mapper.mapWsEvent(parseFrame(wsAssistantDelta), NOW); + expect(event).toMatchObject({ + type: "content.delta", + eventId: "aether:task-1:stream:m1:1", + itemId: "m1", + turnId: "aether-turn-u1", + payload: { streamKind: "assistant_text", delta: "Looking at the" }, + }); + }); + + it("maps thinking deltas to reasoning_text, never assistant_text", () => { + const mapper = makeMapper(); + const [event] = mapper.mapWsEvent(parseFrame(wsThinkingDelta), NOW); + expect(event).toMatchObject({ + type: "content.delta", + itemId: "thinking:m2", + payload: { streamKind: "reasoning_text" }, + }); + }); + + it("parses codex file_change input into data.files path chips", () => { + const mapper = makeMapper(); + const [event] = mapper.mapWsEvent(parseFrame(wsCodexFileChange), NOW); + expect(event).toMatchObject({ + type: "item.completed", + eventId: "aether:task-1:tool:call-fc-codex:output-available", + itemId: "call-fc-codex", + payload: { + itemType: "file_change", + status: "completed", + data: { + toolCallId: "call-fc-codex", + files: [{ path: "src/app.ts" }, { path: "src/new.ts" }], + }, + }, + }); + }); + + it("parses a claude Edit into a single data.files entry", () => { + const mapper = makeMapper(); + const [event] = mapper.mapWsEvent(parseFrame(wsClaudeEdit), NOW); + expect(event).toMatchObject({ + payload: { + itemType: "file_change", + data: { files: [{ path: "src/util.ts" }] }, + }, + }); + }); + + it("tracks command lifecycle and appends the nonzero exit-code marker", () => { + const mapper = makeMapper(); + const [started] = mapper.mapWsEvent(parseFrame(wsCommandStarted), NOW); + expect(started).toMatchObject({ + type: "item.started", + payload: { + itemType: "command_execution", + status: "inProgress", + data: { item: { command: "pnpm test", cwd: "/home/coder/project" } }, + }, + }); + const [failed] = mapper.mapWsEvent(parseFrame(wsCommandFailed), NOW); + expect(failed).toMatchObject({ + type: "item.completed", + eventId: "aether:task-1:tool:call-bash:output-error", + payload: { itemType: "command_execution", status: "failed" }, + }); + expect(failed?.type === "item.completed" && failed.payload.detail).toBe( + "pnpm test\n1 test failed\n", + ); + }); + + it("maps output-denied to declined plus a tool.denied event", () => { + const mapper = makeMapper(); + const events = mapper.mapWsEvent(parseFrame(wsToolDenied), NOW); + expect(events.map((event) => event.type)).toEqual(["item.completed", "tool.denied"]); + expect(events[0]).toMatchObject({ payload: { status: "declined" } }); + expect(events[1]).toMatchObject({ + payload: { toolName: "Bash", toolUseId: "call-denied", reason: "denied by policy" }, + }); + }); + + it("shapes mcp tool cards as data.item {server, tool, args, result}", () => { + const mapper = makeMapper(); + const [event] = mapper.mapWsEvent(parseFrame(wsMcpToolCall), NOW); + expect(event).toMatchObject({ + payload: { + itemType: "mcp_tool_call", + data: { + item: { + server: "linear", + tool: "create_issue", + args: { title: "Fix flake" }, + result: '{"id":"LIN-1"}', + }, + }, + }, + }); + }); + + it("projects todo_list display blocks into turn.plan.updated", () => { + const mapper = makeMapper(); + const events = mapper.mapWsEvent(parseFrame(wsTodoTool), NOW); + const plan = events.find((event) => event.type === "turn.plan.updated"); + expect(plan).toMatchObject({ + payload: { + plan: [ + { step: "Reproduce the failure", status: "completed" }, + { step: "Fix the reducer", status: "inProgress" }, + { step: "Add a regression test", status: "pending" }, + ], + }, + }); + }); + + it("settles turn.completed exactly once per wire turn", () => { + const mapper = makeMapper(); + const first = mapper.mapWsEvent(parseFrame(wsTurnCompleted), NOW); + expect(first).toHaveLength(1); + expect(first[0]).toMatchObject({ + type: "turn.completed", + eventId: "aether:task-1:turn:u1:settled", + turnId: "aether-turn-u1", + payload: { state: "completed" }, + }); + expect(mapper.mapWsEvent(parseFrame(wsTurnCompleted), NOW)).toHaveLength(0); + }); + + it("maps turn.failed to a failed settle plus runtime.error", () => { + const mapper = makeMapper(); + const events = mapper.mapWsEvent(parseFrame(wsTurnFailed), NOW); + expect(events.map((event) => event.type)).toEqual(["turn.completed", "runtime.error"]); + expect(events[0]).toMatchObject({ + payload: { state: "failed", errorMessage: "agent crashed" }, + }); + // NO turnId on the error card: ingestion's runtime.error branch would + // reinstate activeTurnId to it AFTER the settle just cleared it, wedging + // the session on a settled turn. + expect(events[1]!.turnId).toBeUndefined(); + }); + + it("drops live deltas whose item.completed twin already landed (reconnect overlap window)", () => { + const mapper = makeMapper(); + // Frames queue from the moment the socket opens but drain only after the + // reconcile — a message that completed inside that window arrives twice: + // durable row first, stale live deltas second. + mapper.reconcileDelta(makeDelta(), NOW); + expect(mapper.mapWsEvent(parseFrame(wsAssistantDelta), NOW)).toHaveLength(0); + expect(mapper.mapWsEvent(parseFrame(wsThinkingDelta), NOW)).toHaveLength(0); + // A message the reconcile has NOT completed still streams normally. + const fresh = mapper.mapWsEvent(parseFrame({ ...wsAssistantDelta, messageId: "m9" }), NOW); + expect(fresh.map((event) => event.type)).toContain("content.delta"); + }); + + it("settles the displaced predecessor before the first event of a DIFFERENT live turn", () => { + const mapper = makeMapper(); + mapper.mapWsEvent(parseFrame(wsAssistantDelta), NOW); // tracks u1 + // u1's live-only settle was missed; u2 starting proves u1 ended. + const events = mapper.mapWsEvent( + parseFrame({ ...wsAssistantDelta, turnId: "u2", messageId: "m9" }), + NOW, + ); + expect(events.map((event) => event.type)).toEqual(["turn.completed", "content.delta"]); + expect(events[0]).toMatchObject({ + eventId: "aether:task-1:turn:u1:settled", + turnId: "aether-turn-u1", + payload: { state: "completed" }, + }); + }); + + it("maps live ask_user to settle + user-input.requested + waiting", () => { + const mapper = makeMapper(); + const events = mapper.mapWsEvent(parseFrame(wsAwaitingInputQuestions), NOW); + expect(events.map((event) => event.type)).toEqual([ + "turn.completed", + "user-input.requested", + "session.state.changed", + ]); + const requested = events[1]!; + expect(requested).toMatchObject({ + eventId: "aether:task-1:input:pi-1", + requestId: "pi-1", + turnId: "aether-turn-u1", + }); + if (requested.type !== "user-input.requested") { + throw new Error("expected user-input.requested"); + } + // The wire option without a description gets one synthesized (= label). + expect(requested.payload.questions).toEqual([ + { + id: "q1", + header: "Approach", + question: "Which approach should I take?", + options: [ + { label: "Patch the reducer", description: "Smallest change" }, + { label: "Rewrite the module", description: "Rewrite the module" }, + ], + multiSelect: false, + }, + ]); + expect(events[2]).toMatchObject({ payload: { state: "waiting" } }); + }); + + it("maps live propose_plan to settle + turn.proposed.completed + waiting", () => { + const mapper = makeMapper(); + const events = mapper.mapWsEvent(parseFrame(wsAwaitingInputPlan), NOW); + expect(events.map((event) => event.type)).toEqual([ + "turn.completed", + "turn.proposed.completed", + "session.state.changed", + ]); + expect(events[1]).toMatchObject({ + requestId: "pi-2", + payload: { planMarkdown: "1. Reproduce\n2. Fix\n3. Test" }, + }); + }); + + it("maps stop_task to settle only — no prompt, no waiting", () => { + const mapper = makeMapper(); + const events = mapper.mapWsEvent(parseFrame(wsAwaitingInputStopTask), NOW); + expect(events.map((event) => event.type)).toEqual(["turn.completed"]); + }); + + it("surfaces conversation.truncated as a runtime.warning", () => { + const mapper = makeMapper(); + const [event] = mapper.mapWsEvent(parseFrame(wsConversationTruncated), NOW); + expect(event).toMatchObject({ + type: "runtime.warning", + eventId: "aether:task-1:truncated:m1", + payload: { detail: { anchorMessageId: "m1" } }, + }); + }); + + it("maps slash_commands.updated to nothing", () => { + const mapper = makeMapper(); + expect(mapper.mapWsEvent(parseFrame(wsSlashCommandsUpdated), NOW)).toHaveLength(0); + }); +}); + +describe("AetherEventMapper — durable reconciliation", () => { + it("snapshots a full delta replay from a cold cursor", () => { + const mapper = makeMapper(); + expect(mapper.reconcileDelta(makeDelta(), NOW)).toMatchSnapshot(); + expect(mapper.latestSequence()).toBe(5); + }); + + it("dedupes REST twins of live completions, including prefixed id forms", () => { + const mapper = makeMapper(); + // Live path first: bare assistant id, thinking-prefixed thinking id. + const live = wsGoldenTurn.flatMap((frame) => [...mapper.mapWsEvent(parseFrame(frame), NOW)]); + expect(eventIds(live)).toContain("aether:task-1:item:m1"); + expect(eventIds(live)).toContain("aether:task-1:item:thinking:m2"); + // Durable twins: `assistant:m1` / `thinking:m2` rows plus the same tool + // re-emitted; the only NEW row is the compaction seam. + const replay = mapper.reconcileDelta(makeDelta(), NOW); + const types = replay.map((event) => event.type); + expect(types).not.toContain("user-input.requested"); + expect(eventIds(replay)).not.toContain("aether:task-1:item:m1"); + expect(eventIds(replay)).not.toContain("aether:task-1:item:thinking:m2"); + expect(types).toContain("thread.state.changed"); + }); + + it("replays a crash idempotently: identical deterministic eventIds", () => { + // Two fresh mappers on the same stale cursor (a restart) must emit the + // SAME ids so t3's eventId-keyed persistence collides instead of duping. + const first = makeMapper(0).reconcileDelta(makeDelta(), NOW); + const second = makeMapper(0).reconcileDelta(makeDelta(), NOW); + expect(eventIds(second)).toEqual(eventIds(first)); + expect(eventIds(first).length).toBeGreaterThan(0); + }); + + it("feeding the same delta twice through one mapper emits nothing new", () => { + const mapper = makeMapper(); + const first = mapper.reconcileDelta(makeDelta(), NOW); + expect(first.length).toBeGreaterThan(0); + expect(mapper.reconcileDelta(makeDelta(), NOW)).toHaveLength(0); + }); + + it("maps the compaction seam row to thread.state.changed compacted", () => { + const mapper = makeMapper(); + const events = mapper.reconcileDelta(makeDelta(), NOW); + const seam = events.find((event) => event.type === "thread.state.changed"); + expect(seam).toMatchObject({ + eventId: "aether:task-1:seq:5", + payload: { state: "compacted" }, + }); + }); + + it("completed→message settles the tracked turn with NO state emission", () => { + const mapper = makeMapper(); + // A processing delta tracks the in-flight turn via activeProcessingTurn. + mapper.reconcileDelta( + makeDelta({ + task: taskProcessing, + activeProcessingTurn: { messageId: "u1", startedAt: "2026-08-08T10:00:00Z" }, + }), + NOW, + ); + // The settle arrives ONLY via the REST status flip (turn.* is live-only). + const events = mapper.reconcileDelta( + makeDelta({ task: taskAwaitingMessage, messages: [] }), + NOW, + ); + expect(events.map((event) => event.type)).toEqual(["turn.completed"]); + expect(events[0]).toMatchObject({ + turnId: "aether-turn-u1", + payload: { state: "completed" }, + }); + // READY is the absence of a state emission — never `waiting` here. + expect(events.some((event) => event.type === "session.state.changed")).toBe(false); + }); + + it("completed→questions settles AND emits the pending input + waiting", () => { + const mapper = makeMapper(); + mapper.reconcileDelta( + makeDelta({ + task: taskProcessing, + activeProcessingTurn: { messageId: "u1", startedAt: "2026-08-08T10:00:00Z" }, + }), + NOW, + ); + const events = mapper.reconcileDelta( + makeDelta({ task: taskAwaitingQuestions, messages: [] }), + NOW, + ); + expect(events.map((event) => event.type)).toEqual([ + "turn.completed", + "user-input.requested", + "session.state.changed", + ]); + // requestId is the REST tool_id — the same value as the WS pendingInputId. + expect(events[1]).toMatchObject({ requestId: "pi-1" }); + expect(events[2]).toMatchObject({ payload: { state: "waiting" } }); + }); + + it("correlates the WS pendingInputId with the REST tool_id as ONE input", () => { + const mapper = makeMapper(); + const live = mapper.mapWsEvent(parseFrame(wsAwaitingInputQuestions), NOW); + expect(live.some((event) => event.type === "user-input.requested")).toBe(true); + // The durable projection of the SAME pending input must not double it. + const replay = mapper.reconcileDelta( + makeDelta({ task: taskAwaitingQuestions, messages: [] }), + NOW, + ); + expect(replay.some((event) => event.type === "user-input.requested")).toBe(false); + expect(replay.some((event) => event.type === "session.state.changed")).toBe(false); + }); + + it("projects the REST plan twin when the live event was missed", () => { + const mapper = makeMapper(); + const events = mapper.reconcileDelta(makeDelta({ task: taskAwaitingPlan, messages: [] }), NOW); + expect(events.map((event) => event.type)).toEqual([ + "turn.proposed.completed", + "session.state.changed", + ]); + }); + + it("projects an errored task once: failed settle + runtime.error", () => { + const mapper = makeMapper(); + mapper.reconcileDelta( + makeDelta({ + task: taskProcessing, + activeProcessingTurn: { messageId: "u1", startedAt: "2026-08-08T10:00:00Z" }, + }), + NOW, + ); + const events = mapper.reconcileDelta(makeDelta({ task: taskErrored, messages: [] }), NOW); + expect(events.map((event) => event.type)).toEqual(["turn.completed", "runtime.error"]); + expect(events[0]).toMatchObject({ + payload: { state: "failed", errorMessage: "VM provisioning failed" }, + }); + expect(events[1]).toMatchObject({ eventId: "aether:task-1:errored" }); + // Re-observing the errored task must not duplicate the surface. + expect(mapper.reconcileDelta(makeDelta({ task: taskErrored, messages: [] }), NOW)).toHaveLength( + 0, + ); + }); + + it("suppresses durable tool-row replays of (toolCallId, status) pairs already emitted", () => { + const mapper = makeMapper(); + // Live first: the rich projection (turnId + display blocks). + const live = mapper.mapWsEvent(parseFrame(wsCodexFileChange), NOW); + expect(eventIds(live)).toContain("aether:task-1:tool:call-fc-codex:output-available"); + // The durable twin re-fetched on reconnect shares the deterministic + // eventId but is a strict data downgrade (no turnId, no blocks) — + // re-emitting it would overwrite the richer live activity wholesale. + const replay = mapper.reconcileDelta(makeDelta(), NOW); + expect(eventIds(replay)).not.toContain("aether:task-1:tool:call-fc-codex:output-available"); + }); + + it("attributes REST-only rows to the in-flight turn so its settle owns them", () => { + const mapper = makeMapper(); + // WS-down degradation: the active turn's rows reach t3 ONLY as durable + // rows, which carry no turn field of their own. + const streamed = mapper.reconcileDelta( + makeDelta({ + task: taskProcessing, + activeProcessingTurn: { messageId: "u1", startedAt: "2026-08-08T10:00:00.000Z" }, + }), + NOW, + ); + expect(streamed.find((event) => event.eventId === "aether:task-1:item:m1")).toMatchObject({ + type: "item.completed", + turnId: "aether-turn-u1", + }); + expect( + streamed.find( + (event) => event.eventId === "aether:task-1:tool:call-fc-codex:output-available", + ), + ).toMatchObject({ type: "item.completed", turnId: "aether-turn-u1" }); + // The settle arrives only from the REST status flip — it must name the + // same turn the rows above were attributed to. + const settle = mapper.reconcileDelta( + makeDelta({ task: taskAwaitingMessage, messages: [] }), + NOW, + ); + expect(settle.map((event) => event.type)).toEqual(["turn.completed"]); + expect(settle[0]).toMatchObject({ turnId: "aether-turn-u1", payload: { state: "completed" } }); + }); + + it("stops attributing at the active turn's opening row: earlier rows stay unowned", () => { + const mapper = makeMapper(); + const events = mapper.reconcileDelta( + makeDelta({ + task: taskProcessing, + activeProcessingTurn: { messageId: "u2", startedAt: "2026-08-08T10:10:00.000Z" }, + messages: [ + { + id: "tail-of-u1", + role: "assistant", + variant: "text", + content: "Done with the first turn.", + timestamp: "2026-08-08T10:09:00.000Z", + sequence: 1, + }, + { + id: "u2", + role: "user", + content: "now fix the lint error", + deliveryStatus: "delivered", + timestamp: "2026-08-08T10:10:00.000Z", + sequence: 2, + }, + { + id: "assistant:m9", + role: "assistant", + variant: "text", + content: "Looking at the lint error.", + timestamp: "2026-08-08T10:10:01.000Z", + sequence: 3, + }, + ], + }), + NOW, + ); + expect(events.find((event) => event.eventId === "aether:task-1:item:tail-of-u1")?.turnId).toBe( + undefined, + ); + expect(events.find((event) => event.eventId === "aether:task-1:item:m9")).toMatchObject({ + turnId: "aether-turn-u2", + }); + }); + + it("attributes the previous turn's late tail across a warm turn transition", () => { + const mapper = makeMapper(); + // Warm the mapper: u1 is the tracked in-flight turn. + mapper.reconcileDelta( + makeDelta({ + task: taskProcessing, + activeProcessingTurn: { messageId: "u1", startedAt: "2026-08-08T10:00:00.000Z" }, + messages: [ + { + id: "u1", + role: "user", + content: "fix the bug", + deliveryStatus: "delivered", + timestamp: "2026-08-08T10:00:00.000Z", + sequence: 1, + }, + ], + }), + NOW, + ); + // The transition delta carries u1's late tail, u2's opener, and u2's + // first output — the reviewer's exact repro. The tail must stay owned by + // u1 (the mapper is already tracking it), never finalize unowned. + const events = mapper.reconcileDelta( + makeDelta({ + task: taskProcessing, + activeProcessingTurn: { messageId: "u2", startedAt: "2026-08-08T10:10:00.000Z" }, + messages: [ + { + id: "tail-of-u1", + role: "assistant", + variant: "text", + content: "Done with the first turn.", + timestamp: "2026-08-08T10:09:00.000Z", + sequence: 5, + }, + { + id: "u2", + role: "user", + content: "now fix the lint error", + deliveryStatus: "delivered", + timestamp: "2026-08-08T10:10:00.000Z", + sequence: 6, + }, + { + id: "assistant:m2", + role: "assistant", + variant: "text", + content: "Looking at the lint error.", + timestamp: "2026-08-08T10:10:01.000Z", + sequence: 7, + }, + ], + }), + NOW, + ); + expect(events.find((event) => event.eventId === "aether:task-1:item:tail-of-u1")).toMatchObject( + { turnId: "aether-turn-u1" }, + ); + expect(events.find((event) => event.eventId === "aether:task-1:item:m2")).toMatchObject({ + turnId: "aether-turn-u2", + }); + const settle = events.find( + (event) => event.type === "turn.completed" && event.turnId === "aether-turn-u1", + ); + expect(settle).toBeDefined(); + // Ordering: u1's tail is emitted before u1 settles, which happens before + // u2's first output. + const tailIndex = events.findIndex( + (event) => event.eventId === "aether:task-1:item:tail-of-u1", + ); + const settleIndex = events.findIndex( + (event) => event.type === "turn.completed" && event.turnId === "aether-turn-u1", + ); + const nextOutputIndex = events.findIndex((event) => event.eventId === "aether:task-1:item:m2"); + expect(tailIndex).toBeLessThan(settleIndex); + expect(settleIndex).toBeLessThan(nextOutputIndex); + }); + + it("owns rows and the pending input on a cold resume to an awaiting task", () => { + // activeProcessingTurn is null once a task parks at awaiting_input, but + // the delta still carries the opening user row — the opener itself is + // the turn boundary, so output AND the pending-input request must land + // owned by aether-turn-u1, never unowned. + const mapper = makeMapper(); + const events = mapper.reconcileDelta( + makeDelta({ + task: taskAwaitingQuestions, + activeProcessingTurn: null, + messages: [ + { + id: "u1", + role: "user", + content: "fix the bug", + deliveryStatus: "delivered", + timestamp: "2026-08-08T10:00:00.000Z", + sequence: 1, + }, + { + id: "assistant:a1", + role: "assistant", + variant: "text", + content: "I have a question first.", + timestamp: "2026-08-08T10:00:05.000Z", + sequence: 2, + }, + ], + }), + NOW, + ); + expect(events.find((event) => event.eventId === "aether:task-1:item:a1")).toMatchObject({ + turnId: "aether-turn-u1", + }); + const request = events.find((event) => event.type === "user-input.requested"); + expect(request).toBeDefined(); + expect(request?.turnId).toBe("aether-turn-u1"); + }); + + it("does not treat a queued user row as a turn opener", () => { + // A steer parks in the timeline with deliveryStatus queued while the + // current turn still streams — output after it belongs to the OLD turn. + const mapper = makeMapper(); + const events = mapper.reconcileDelta( + makeDelta({ + task: taskProcessing, + activeProcessingTurn: { messageId: "u1", startedAt: "2026-08-08T10:00:00.000Z" }, + messages: [ + { + id: "u1", + role: "user", + content: "fix the bug", + deliveryStatus: "delivered", + timestamp: "2026-08-08T10:00:00.000Z", + sequence: 1, + }, + { + id: "u-steer", + role: "user", + content: "also update the docs", + deliveryStatus: "queued", + timestamp: "2026-08-08T10:00:03.000Z", + sequence: 2, + }, + { + id: "assistant:a1", + role: "assistant", + variant: "text", + content: "Still working on the bug.", + timestamp: "2026-08-08T10:00:05.000Z", + sequence: 3, + }, + ], + }), + NOW, + ); + expect(events.find((event) => event.eventId === "aether:task-1:item:a1")).toMatchObject({ + turnId: "aether-turn-u1", + }); + }); + + it("settles a turn displaced by a NEW active wire turn between observations", () => { + const mapper = makeMapper(); + // awaiting_input→processing skipped between polls (fast remote respond): + // the only evidence turn u1 ended is u2 being active now. + mapper.reconcileDelta( + makeDelta({ + task: taskProcessing, + messages: [], + activeProcessingTurn: { messageId: "u1", startedAt: "2026-08-08T10:00:00Z" }, + }), + NOW, + ); + const events = mapper.reconcileDelta( + makeDelta({ + task: taskProcessing, + messages: [], + activeProcessingTurn: { messageId: "u2", startedAt: "2026-08-08T10:10:00Z" }, + }), + NOW, + ); + expect(events.map((event) => event.type)).toEqual(["turn.completed", "session.state.changed"]); + expect(events[0]).toMatchObject({ + turnId: "aether-turn-u1", + payload: { state: "completed" }, + }); + // The settle flipped ingestion to ready; the new turn re-projects running. + expect(events[1]).toMatchObject({ payload: { state: "running" } }); + }); + + it("projects the working indicator: queued→starting, processing→running, once per transition", () => { + const taskQueued: AetherTask = { ...taskProcessing, status: "queued", run_context: null }; + const mapper = makeMapper(); + const starting = mapper.reconcileTask(taskQueued, NOW); + expect(starting).toHaveLength(1); + expect(starting[0]).toMatchObject({ + type: "session.state.changed", + payload: { state: "starting" }, + }); + // Re-observing the same status emits nothing new. + expect(mapper.reconcileTask(taskQueued, NOW)).toHaveLength(0); + const running = mapper.reconcileTask(taskProcessing, NOW); + expect(running).toHaveLength(1); + expect(running[0]).toMatchObject({ payload: { state: "running" } }); + expect(mapper.reconcileTask(taskProcessing, NOW)).toHaveLength(0); + }); + + it("projects the error state on a COLD errored observation (no turn in flight)", () => { + const mapper = makeMapper(); + const events = mapper.reconcileTask(taskErrored, NOW); + expect(events.map((event) => event.type)).toEqual(["runtime.error", "session.state.changed"]); + expect(events[1]).toMatchObject({ + payload: { state: "error", reason: "VM provisioning failed" }, + }); + expect(mapper.reconcileTask(taskErrored, NOW)).toHaveLength(0); + }); + + it("never rewinds the cursor below the initial sequence", () => { + const mapper = makeMapper(deltaRows.length); + const events = mapper.reconcileDelta(makeDelta(), NOW); + // Every row is at or below the cursor: nothing replays. + expect(events).toHaveLength(0); + expect(mapper.latestSequence()).toBe(deltaRows.length); + }); +}); + +describe("parseAetherQuestions", () => { + it("reports malformed questions as issues instead of dropping silently", () => { + const { questions, issues } = parseAetherQuestions({ + questions: [ + { question: "Valid?", options: [{ label: "Yes" }] }, + { options: [{ label: "orphan option" }] }, + "not-an-object", + ], + }); + expect(questions).toHaveLength(1); + expect(issues).toHaveLength(2); + }); + + it("fails loudly (empty + issue) when there is no questions array", () => { + const { questions, issues } = parseAetherQuestions({ foo: 1 }); + expect(questions).toHaveLength(0); + expect(issues).toEqual(["ask_user input carries no questions array"]); + }); +}); diff --git a/apps/server/src/provider/Layers/aether/eventMapper.ts b/apps/server/src/provider/Layers/aether/eventMapper.ts new file mode 100644 index 000000000000..429690503ee1 --- /dev/null +++ b/apps/server/src/provider/Layers/aether/eventMapper.ts @@ -0,0 +1,1216 @@ +/** + * Aether event mapper — the single transform from Aether's two transports + * (workspace WS agent events + durable conversation rows) into t3 + * `ProviderRuntimeEvent`s, per docs/aether-driver-plumbing-spec.md §2.1. + * + * Design rules (spec §2.1 envelope row, build item 6): + * - Event IDs are DETERMINISTIC, derived from durable row identity — never + * a fresh UUID. t3 persists activities keyed by eventId and snapshots the + * resume cursor only at startSession/sendTurn/stopAll, so a crash replays + * already-ingested rows; deterministic IDs make the replay collide + * (idempotent) instead of duplicating: + * message items → `aether::item:` + * tool lifecycle → `aether::tool::` + * durable rows without an entity → `aether::seq:` + * turn settles → `aether::turn::` + * pending inputs → `aether::input:[:suffix]` + * - Durable wins: a completion seen live is not re-emitted from its REST + * twin and vice versa. Canonical message ids strip the durable + * `assistant:` prefix and always CARRY the `thinking:` prefix (the live + * stream already thinking-prefixes those ids — task-stream-emitter.ts). + * - `turnId`s are the deterministic `aether-turn-` family. The + * wire turn id IS the user message id that opened the turn + * (workspace-service handler.ts `const turnId = msg.messageId`), which is + * also `activeProcessingTurn.messageId` — so live and durable settles + * converge on the same t3 TurnId, matching `snapshotTurnsFromMessages`. + * - Status projection (spec resolved note 2): post-settle + * `awaiting_input(kind=message)` is the READY idle state — NO state + * emission; `waiting` is emitted ONLY for an actually-pending question or + * plan. Anything else keeps the just-settled turn flipped back to + * "Working" forever in t3's projection. + * + * The mapper is a plain synchronous state machine (no Effect, no I/O): the + * caller feeds parsed wire events / decoded REST payloads and emits the + * returned runtime events in order. + * + * @module provider/Layers/aether/eventMapper + */ +import { + EventId, + RuntimeItemId, + RuntimeRequestId, + TurnId, + type ProviderDriverKind, + type ProviderInstanceId, + type ProviderRuntimeEvent, + type RuntimeItemStatus, + type RuntimePlanStepStatus, + type ThreadId, + type UserInputQuestion, +} from "@t3tools/contracts"; + +import type { AetherConversationDelta, AetherTask, AetherTimelineTool } from "./restSchemas.ts"; +import { toolLifecycleItemTypeFromAether } from "./vendored/canonicalItemType.ts"; +import { parseFileChanges } from "./vendored/toolDisplay.ts"; +import type { AetherAgentEvent, AetherWsToolPayload } from "./wireEvents.ts"; + +// --------------------------------------------------------------------------- +// Public surface +// --------------------------------------------------------------------------- + +export interface AetherEventMapperOptions { + readonly provider: ProviderDriverKind; + readonly instanceId: ProviderInstanceId; + readonly threadId: ThreadId; + readonly taskId: string; + /** Replay point: durable rows at or below this sequence are already applied. */ + readonly initialSequence: number; +} + +export interface AetherEventMapper { + /** Map one parsed live WS agent event. `slash_commands.updated` maps to []. */ + readonly mapWsEvent: ( + event: AetherAgentEvent, + nowIso: string, + ) => ReadonlyArray; + /** + * Reconcile a durable conversation delta: replay rows above the cursor + * through the same mapping paths (durable-wins dedupe), then project the + * task status — the ONLY recovery for a turn settle missed while detached + * (turn.* events are live-only, never persisted). Drive this on every WS + * (re)connect and from the T6 turn engine's backstop poll. + */ + readonly reconcileDelta: ( + delta: AetherConversationDelta, + nowIso: string, + ) => ReadonlyArray; + /** + * Project a bare task read (status flips without new rows). Exposed as the + * poll hook the T6 turn engine drives; reconcileDelta calls it internally. + */ + readonly reconcileTask: (task: AetherTask, nowIso: string) => ReadonlyArray; + /** The highest durable sequence applied so far (in-memory cursor). */ + readonly latestSequence: () => number; +} + +// --------------------------------------------------------------------------- +// Small pure helpers +// --------------------------------------------------------------------------- + +/** + * Canonical message-item id shared by both transports. Durable assistant + * text rows may arrive `assistant:`-prefixed while the live stream uses the + * bare id — strip it. Thinking ids keep their `thinking:` prefix: the live + * stream ALSO prefixes them, so the prefixed form is already the shared one. + */ +function canonicalMessageItemId(messageId: string): string { + return messageId.startsWith("assistant:") ? messageId.slice("assistant:".length) : messageId; +} + +function canonicalThinkingItemId(messageId: string): string { + return messageId.startsWith("thinking:") ? messageId : `thinking:${messageId}`; +} + +/** Aether tool status → t3 item status (spec §2.1 tool-status row). */ +function runtimeItemStatusFromAether(status: string): RuntimeItemStatus { + switch (status) { + case "output-error": + return "failed"; + case "output-denied": + return "declined"; + case "output-available": + case "approval-responded": + return "completed"; + default: + return "inProgress"; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function readString(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === "string" ? value : undefined; +} + +/** A trimmed non-empty string or undefined — t3 detail/title fields are TrimmedNonEmptyString. */ +function trimmedOrUndefined(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed !== undefined && trimmed.length > 0 ? trimmed : undefined; +} + +const MCP_TOOL_NAME_RE = /^mcp__([^_].*?)__(.+)$/; + +interface TerminalBlockView { + readonly command: string; + readonly stdout: string | undefined; + readonly stderr: string | undefined; + readonly exitCode: number | undefined; +} + +/** First `terminal` display block, read defensively (blocks are untrusted). */ +function readTerminalBlock( + blocks: ReadonlyArray | undefined, +): TerminalBlockView | undefined { + for (const block of blocks ?? []) { + if (!isRecord(block) || block.type !== "terminal") { + continue; + } + const command = readString(block, "command"); + if (command === undefined) { + continue; + } + const exitCode = block.exitCode; + return { + command, + stdout: readString(block, "stdout"), + stderr: readString(block, "stderr"), + exitCode: typeof exitCode === "number" ? exitCode : undefined, + }; + } + return undefined; +} + +interface TodoView { + readonly step: string; + readonly status: RuntimePlanStepStatus; +} + +/** All `todo_list` items across display blocks (inline turn-plan chip source). */ +function readTodoItems(blocks: ReadonlyArray | undefined): ReadonlyArray { + const todos: Array = []; + for (const block of blocks ?? []) { + if (!isRecord(block) || block.type !== "todo_list" || !Array.isArray(block.items)) { + continue; + } + for (const item of block.items) { + if (!isRecord(item)) { + continue; + } + const step = trimmedOrUndefined(readString(item, "text")); + if (step === undefined) { + continue; + } + const status = item.status; + todos.push({ + step, + status: + status === "completed" + ? "completed" + : status === "in_progress" + ? "inProgress" + : "pending", + }); + } + } + return todos; +} + +interface ParsedQuestions { + readonly questions: ReadonlyArray; + readonly issues: ReadonlyArray; +} + +/** + * One loose parser over the ask_user input for BOTH transports (WS + * `payload.input`, REST `awaiting_input.input` — spec §2.4 question-card + * row). Missing option descriptions are synthesized (= label) so t3's + * parseUserInputQuestions never drops a question; malformed entries are + * reported as issues, never silently dropped. + */ +export function parseAetherQuestions(input: Record): ParsedQuestions { + const issues: Array = []; + const rawQuestions = Array.isArray(input.questions) ? input.questions : undefined; + if (rawQuestions === undefined) { + return { questions: [], issues: ["ask_user input carries no questions array"] }; + } + const questions: Array = []; + rawQuestions.forEach((raw, index) => { + if (!isRecord(raw)) { + issues.push(`question ${index} is not an object`); + return; + } + const question = trimmedOrUndefined(readString(raw, "question")); + if (question === undefined) { + issues.push(`question ${index} has no question text`); + return; + } + const options: Array<{ label: string; description: string }> = []; + if (Array.isArray(raw.options)) { + raw.options.forEach((rawOption, optionIndex) => { + if (!isRecord(rawOption)) { + issues.push(`question ${index} option ${optionIndex} is not an object`); + return; + } + const label = trimmedOrUndefined(readString(rawOption, "label")); + if (label === undefined) { + issues.push(`question ${index} option ${optionIndex} has no label`); + return; + } + // Synthesize a missing/blank description from the label. + options.push({ + label, + description: trimmedOrUndefined(readString(rawOption, "description")) ?? label, + }); + }); + } + questions.push({ + id: trimmedOrUndefined(readString(raw, "id")) ?? question, + header: trimmedOrUndefined(readString(raw, "header")) ?? `Question ${index + 1}`, + question, + options, + multiSelect: raw.multiSelect === true, + }); + }); + return { questions, issues }; +} + +// --------------------------------------------------------------------------- +// Mapper +// --------------------------------------------------------------------------- + +export function makeAetherEventMapper(options: AetherEventMapperOptions): AetherEventMapper { + const { provider, instanceId, threadId, taskId } = options; + + let lastSequence = options.initialSequence; + /** Canonical message ids whose item.completed already went out (either transport). */ + const completedItems = new Set(); + /** Wire turn ids that already received their single terminal settle. */ + const settledTurns = new Set(); + /** Pending-input ids (WS pendingInputId ≡ REST tool_id) already surfaced. */ + const requestedInputs = new Set(); + /** Tool call ids already seen (item.started vs item.updated). */ + const seenTools = new Set(); + /** + * `(toolCallId, wire status)` pairs already emitted. Live re-emits of a + * pair stay last-write-wins (the wire upserts full state), but a DURABLE + * row replay of a pair observed live is suppressed: the REST projection is + * strictly poorer (no turnId, no display blocks), and its identical + * deterministic eventId would overwrite the richer live activity wholesale. + */ + const emittedToolStatuses = new Set(); + /** Per-message content.delta counters (deterministic within a connection). */ + const deltaCounters = new Map(); + /** One-shot flags for deduped task-level projections. */ + let taskErrorEmitted = false; + const warnedOnce = new Set(); + /** The wire turn id currently in flight, for settles observed via REST. */ + let activeWireTurnId: string | undefined; + /** + * The session state ingestion currently believes, mirrored so the status + * projection (spec §2.1 working-indicator row) emits only on transitions. + * Updated by projectSessionState, by turn settles (ingestion flips a + * settled session to ready/error itself) and by the `waiting` emission. + */ + let lastProjectedState: "starting" | "running" | "waiting" | "ready" | "error" | undefined; + /** Transition counter — keeps re-entered states' eventIds distinct while staying deterministic for a replayed observation sequence. */ + let stateEmissions = 0; + /** Monotonic createdAt clock (spec: never decreasing). */ + let clockMs = Number.NEGATIVE_INFINITY; + let clockIso = ""; + + const stamp = (preferred: string | undefined, nowIso: string): string => { + for (const candidate of [preferred, nowIso]) { + if (candidate === undefined) { + continue; + } + const ms = Date.parse(candidate); + if (!Number.isFinite(ms)) { + continue; + } + if (ms <= clockMs) { + return clockIso; + } + clockMs = ms; + clockIso = candidate; + return candidate; + } + return clockIso; + }; + + const turnIdFor = (wireTurnId: string): TurnId => TurnId.make(`aether-turn-${wireTurnId}`); + + const base = (input: { + readonly eventId: string; + readonly createdAt: string; + readonly wireTurnId?: string | undefined; + readonly itemId?: string | undefined; + readonly requestId?: string | undefined; + }) => ({ + eventId: EventId.make(input.eventId), + provider, + providerInstanceId: instanceId, + threadId, + createdAt: input.createdAt, + ...(input.wireTurnId !== undefined ? { turnId: turnIdFor(input.wireTurnId) } : {}), + ...(input.itemId !== undefined ? { itemId: RuntimeItemId.make(input.itemId) } : {}), + ...(input.requestId !== undefined ? { requestId: RuntimeRequestId.make(input.requestId) } : {}), + }); + + /** + * Track the wire turn an event belongs to. A DIFFERENT unsettled turn + * becoming active proves the previously tracked turn ended (Aether runs + * one turn at a time), so its missed live-only settle is emitted here — + * otherwise an awaiting_input→processing transition between observations + * (fast remote respond) would silently overwrite the predecessor and its + * `turn.completed` would never surface, violating the exactly-one-terminal- + * settle-per-turn contract (spec §2.1 turn-settle row). + */ + const trackTurn = ( + wireTurnId: string | undefined, + createdAt: string, + ): ReadonlyArray => { + if (wireTurnId === undefined || settledTurns.has(wireTurnId)) { + return []; + } + const predecessor = + activeWireTurnId !== undefined && activeWireTurnId !== wireTurnId + ? settleTurn({ wireTurnId: activeWireTurnId, state: "completed", createdAt }) + : []; + activeWireTurnId = wireTurnId; + return predecessor; + }; + + /** + * The status projection (spec §2.1 working-indicator row): queued→starting, + * processing→running, errored→error, emitted only when the projected state + * actually changes. `waiting` and READY are NOT projected here — waiting is + * bound to an actually-pending input (pendingInput) and READY is the + * absence of an emission after a settle (spec resolved note 2). + */ + const projectSessionState = ( + state: "starting" | "running" | "error", + reason: string | undefined, + createdAt: string, + ): ReadonlyArray => { + if (lastProjectedState === state) { + return []; + } + lastProjectedState = state; + stateEmissions++; + const trimmedReason = trimmedOrUndefined(reason); + return [ + { + ...base({ eventId: `aether:${taskId}:state:${stateEmissions}:${state}`, createdAt }), + type: "session.state.changed", + payload: { state, ...(trimmedReason !== undefined ? { reason: trimmedReason } : {}) }, + }, + ]; + }; + + const warningOnce = ( + key: string, + message: string, + detail: unknown, + createdAt: string, + ): ReadonlyArray => { + if (warnedOnce.has(key)) { + return []; + } + warnedOnce.add(key); + return [ + { + ...base({ eventId: `aether:${taskId}:warn:${key}`, createdAt }), + type: "runtime.warning", + payload: { message, ...(detail !== undefined ? { detail } : {}) }, + }, + ]; + }; + + // -- message items -------------------------------------------------------- + + const messageItemCompleted = (input: { + readonly canonicalId: string; + readonly itemType: "assistant_message" | "reasoning"; + readonly content: string; + readonly wireTurnId: string | undefined; + readonly createdAt: string; + }): ReadonlyArray => { + if (completedItems.has(input.canonicalId)) { + return []; + } + completedItems.add(input.canonicalId); + const detail = trimmedOrUndefined(input.content); + return [ + { + ...base({ + eventId: `aether:${taskId}:item:${input.canonicalId}`, + createdAt: input.createdAt, + wireTurnId: input.wireTurnId, + itemId: input.canonicalId, + }), + type: "item.completed", + payload: { + itemType: input.itemType, + status: "completed", + ...(detail !== undefined ? { detail } : {}), + }, + }, + ]; + }; + + // -- tool lifecycle -------------------------------------------------------- + + interface ToolUpdate { + readonly toolCallId: string; + readonly name: string; + readonly input: Record; + readonly status: string; + readonly itemType: string | undefined; + readonly label: string; + readonly blocks: ReadonlyArray | undefined; + readonly result: string | undefined; + readonly error: string | undefined; + readonly wireTurnId: string | undefined; + readonly createdAt: string; + } + + const toolData = (update: ToolUpdate, terminal: TerminalBlockView | undefined): unknown => { + const itemType7 = toolLifecycleItemTypeFromAether(update.itemType ?? "unknown"); + if (itemType7 === "file_change") { + // The vendored parser understands every input shape the Aether + // normalizers emit (codex files[] with oldContent/newContent/diff, + // claude Edit/Write raw passthrough) and merges result-borne diffs. + // `data.files[].path` is a nesting t3's extractChangedFiles walks. + const files = parseFileChanges(update.input, update.result) + .map((change) => change.path) + .filter((path): path is string => path !== null) + .map((path) => ({ path })); + return { toolCallId: update.toolCallId, files }; + } + if (itemType7 === "command_execution") { + const command = readString(update.input, "command") ?? terminal?.command ?? update.label; + const cwd = readString(update.input, "cwd"); + return { + toolCallId: update.toolCallId, + item: { command, ...(cwd !== undefined ? { cwd } : {}) }, + }; + } + const mcpMatch = MCP_TOOL_NAME_RE.exec(update.name); + if (itemType7 === "mcp_tool_call" && mcpMatch !== null) { + return { + toolCallId: update.toolCallId, + item: { + server: mcpMatch[1], + tool: mcpMatch[2], + args: update.input, + ...(update.result !== undefined ? { result: update.result } : {}), + }, + }; + } + return { + toolCallId: update.toolCallId, + item: { name: update.name, input: update.input }, + }; + }; + + const toolDetail = ( + update: ToolUpdate, + terminal: TerminalBlockView | undefined, + ): string | undefined => { + const itemType7 = toolLifecycleItemTypeFromAether(update.itemType ?? "unknown"); + if (itemType7 !== "command_execution") { + return trimmedOrUndefined(update.error); + } + const command = readString(update.input, "command") ?? terminal?.command ?? update.label; + const output = terminal?.stdout ?? update.result; + const parts = [command]; + if (output !== undefined && output.trim().length > 0) { + parts.push(output); + } + // Exit-code marker: only when the terminal display block carries a + // nonzero exit code (spec §2.1 command_execution row). + if (terminal?.exitCode !== undefined && terminal.exitCode !== 0) { + parts.push(``); + } + return trimmedOrUndefined(parts.join("\n")); + }; + + const mapToolUpdate = ( + update: ToolUpdate, + source: "live" | "durable", + ): ReadonlyArray => { + const statusKey = `${update.toolCallId}:${update.status}`; + // Durable-wins for tool lifecycle: a durable row replay of a pair already + // emitted (live or durable) is a strict data downgrade — same + // deterministic eventId, but display blocks are absent on the REST + // projection at this boundary (and its turnId is only inferred), and t3's + // projector replaces the stored activity wholesale on id match. Live + // re-emits stay through: the wire upserts full state last-write-wins. + if (source === "durable" && emittedToolStatuses.has(statusKey)) { + return []; + } + emittedToolStatuses.add(statusKey); + const turnEvents = trackTurn(update.wireTurnId, update.createdAt); + const itemStatus = runtimeItemStatusFromAether(update.status); + const isTerminalStatus = itemStatus !== "inProgress"; + const firstSighting = !seenTools.has(update.toolCallId); + seenTools.add(update.toolCallId); + + const type = isTerminalStatus + ? "item.completed" + : firstSighting + ? "item.started" + : "item.updated"; + const terminal = readTerminalBlock(update.blocks); + const detail = toolDetail(update, terminal); + const events: Array = [ + ...turnEvents, + { + // (toolCallId, wire status) keys the lifecycle: full-state re-emits + // are last-write-wins on the same id — idempotent at ingestion. + ...base({ + eventId: `aether:${taskId}:tool:${update.toolCallId}:${update.status}`, + createdAt: update.createdAt, + wireTurnId: update.wireTurnId, + itemId: update.toolCallId, + }), + type, + payload: { + itemType: toolLifecycleItemTypeFromAether(update.itemType ?? "unknown"), + status: itemStatus, + ...(trimmedOrUndefined(update.label) !== undefined + ? { title: trimmedOrUndefined(update.label) } + : {}), + ...(detail !== undefined ? { detail } : {}), + data: toolData(update, terminal), + }, + }, + ]; + + if (itemStatus === "declined") { + events.push({ + ...base({ + eventId: `aether:${taskId}:tool:${update.toolCallId}:${update.status}:denied`, + createdAt: update.createdAt, + wireTurnId: update.wireTurnId, + }), + type: "tool.denied", + payload: { + toolName: update.name, + toolUseId: update.toolCallId, + ...(trimmedOrUndefined(update.error) !== undefined + ? { reason: trimmedOrUndefined(update.error) } + : {}), + }, + }); + } + + const todos = readTodoItems(update.blocks); + if (todos.length > 0) { + events.push({ + ...base({ + eventId: `aether:${taskId}:tool:${update.toolCallId}:${update.status}:plan`, + createdAt: update.createdAt, + wireTurnId: update.wireTurnId, + }), + type: "turn.plan.updated", + payload: { plan: todos }, + }); + } + return events; + }; + + const toolUpdateFromWs = ( + toolCallId: string, + payload: AetherWsToolPayload, + wireTurnId: string | undefined, + createdAt: string, + ): ToolUpdate => ({ + toolCallId, + name: payload.name, + input: payload.input, + status: payload.status, + itemType: payload.itemType, + label: payload.display.label, + blocks: payload.display.blocks, + result: payload.result, + error: payload.error, + wireTurnId, + createdAt, + }); + + const toolUpdateFromRow = ( + tool: AetherTimelineTool, + wireTurnId: string | undefined, + createdAt: string, + ): ToolUpdate => ({ + toolCallId: tool.id, + name: tool.name, + input: tool.input, + status: tool.status, + itemType: tool.itemType, + label: tool.display.label, + // The REST projection carries no display blocks at this boundary — the + // command detail falls back to the result string. + blocks: undefined, + result: tool.result, + error: tool.error, + // Durable rows carry no turn field; the caller attributes them (see + // reconcileDelta's active-turn boundary). + wireTurnId, + createdAt, + }); + + // -- turn settles & pending inputs ---------------------------------------- + + const settleTurn = (input: { + readonly wireTurnId: string; + readonly state: "completed" | "failed"; + readonly errorMessage?: string | undefined; + readonly createdAt: string; + }): ReadonlyArray => { + // Exactly one terminal settle per turn, no matter how many transports + // observe it (live turn.* + REST status projection). + if (settledTurns.has(input.wireTurnId)) { + return []; + } + settledTurns.add(input.wireTurnId); + if (activeWireTurnId === input.wireTurnId) { + activeWireTurnId = undefined; + } + // Ingestion flips the session to error/ready on a turn settle; mirror it + // so the status projection re-emits `running` for the NEXT turn. + lastProjectedState = input.state === "failed" ? "error" : "ready"; + const errorMessage = trimmedOrUndefined(input.errorMessage); + return [ + { + ...base({ + eventId: `aether:${taskId}:turn:${input.wireTurnId}:settled`, + createdAt: input.createdAt, + wireTurnId: input.wireTurnId, + }), + type: "turn.completed", + payload: { + state: input.state, + ...(errorMessage !== undefined ? { errorMessage } : {}), + }, + }, + ]; + }; + + const pendingInput = (input: { + readonly pendingId: string; + readonly toolName: "ask_user" | "propose_plan"; + readonly payload: Record; + readonly wireTurnId: string | undefined; + readonly createdAt: string; + }): ReadonlyArray => { + // pendingInputId (WS) and tool_id (REST) name the same pending input — + // exactly one user-input.requested / plan card per id across transports. + if (requestedInputs.has(input.pendingId)) { + return []; + } + const events: Array = []; + + if (input.toolName === "ask_user") { + const { questions, issues } = parseAetherQuestions(input.payload); + if (issues.length > 0) { + events.push( + ...warningOnce( + `input:${input.pendingId}:malformed`, + "Aether asked a question t3 could not fully parse.", + { issues }, + input.createdAt, + ), + ); + } + if (questions.length === 0) { + // Nothing renderable: the warning above is the loud surface. Do NOT + // mark the input consumed — a later, better-formed twin may land. + return events; + } + requestedInputs.add(input.pendingId); + events.push({ + ...base({ + eventId: `aether:${taskId}:input:${input.pendingId}`, + createdAt: input.createdAt, + wireTurnId: input.wireTurnId, + requestId: input.pendingId, + }), + type: "user-input.requested", + payload: { questions }, + }); + } else { + const plan = trimmedOrUndefined(readString(input.payload, "plan")); + if (plan === undefined) { + return warningOnce( + `input:${input.pendingId}:malformed`, + "Aether proposed a plan with no plan markdown.", + { input: input.payload }, + input.createdAt, + ); + } + requestedInputs.add(input.pendingId); + events.push({ + ...base({ + eventId: `aether:${taskId}:input:${input.pendingId}`, + createdAt: input.createdAt, + wireTurnId: input.wireTurnId, + requestId: input.pendingId, + }), + type: "turn.proposed.completed", + payload: { planMarkdown: plan }, + }); + } + + // An actually-pending question/plan is the ONLY state that maps to + // `waiting` (t3 projects waiting→running; the message-kind idle state + // must stay READY with no emission — spec resolved note 2). + lastProjectedState = "waiting"; + events.push({ + ...base({ + eventId: `aether:${taskId}:input:${input.pendingId}:waiting`, + createdAt: input.createdAt, + requestId: input.pendingId, + }), + type: "session.state.changed", + payload: { state: "waiting", reason: "Aether is waiting for your input." }, + }); + return events; + }; + + // -- WS events ------------------------------------------------------------- + + const mapWsEvent = ( + event: AetherAgentEvent, + nowIso: string, + ): ReadonlyArray => { + const createdAt = stamp(event.createdAt, nowIso); + switch (event.kind) { + case "tool_call.started": + case "tool_call.completed": + case "tool_call.failed": + return mapToolUpdate( + toolUpdateFromWs(event.toolCallId, event.payload, event.turnId, createdAt), + "live", + ); + + case "assistant_message.delta": { + const canonicalId = canonicalMessageItemId(event.messageId); + // Durable-wins applies to the STREAM too: live frames queue from the + // moment the socket opens but drain only after the reconnect + // reconciliation, so a delta whose item.completed twin was already + // ingested from the durable snapshot is a stale replay — emitting it + // would re-open or extend a finalized bubble (its live turn.completed + // twin is swallowed by settledTurns, so nothing would close it). + if (completedItems.has(canonicalId)) { + return []; + } + const turnEvents = trackTurn(event.turnId, createdAt); + const counter = (deltaCounters.get(canonicalId) ?? 0) + 1; + deltaCounters.set(canonicalId, counter); + return [ + ...turnEvents, + { + ...base({ + eventId: `aether:${taskId}:stream:${canonicalId}:${counter}`, + createdAt, + wireTurnId: event.turnId, + itemId: canonicalId, + }), + type: "content.delta", + payload: { streamKind: "assistant_text", delta: event.payload.delta }, + }, + ]; + } + + case "thinking.delta": { + const canonicalId = canonicalThinkingItemId(event.messageId); + // Same stale-replay gate as assistant deltas (durable wins). + if (completedItems.has(canonicalId)) { + return []; + } + const turnEvents = trackTurn(event.turnId, createdAt); + const counter = (deltaCounters.get(canonicalId) ?? 0) + 1; + deltaCounters.set(canonicalId, counter); + return [ + ...turnEvents, + { + ...base({ + eventId: `aether:${taskId}:stream:${canonicalId}:${counter}`, + createdAt, + wireTurnId: event.turnId, + itemId: canonicalId, + }), + // NEVER assistant_text: remapping thinking into the assistant + // stream renders reasoning as commentary (spec §2.1 thinking row). + type: "content.delta", + payload: { streamKind: "reasoning_text", delta: event.payload.delta }, + }, + ]; + } + + // A stream boundary marker; the *.completed twins carry the content. + case "stream.complete": + return []; + + case "assistant_message.completed": + return [ + ...trackTurn(event.turnId, createdAt), + ...messageItemCompleted({ + canonicalId: canonicalMessageItemId(event.messageId), + itemType: "assistant_message", + content: event.payload.content, + wireTurnId: event.turnId, + createdAt, + }), + ]; + + case "thinking.completed": + return [ + ...trackTurn(event.turnId, createdAt), + ...messageItemCompleted({ + canonicalId: canonicalThinkingItemId(event.messageId), + itemType: "reasoning", + content: event.payload.content, + wireTurnId: event.turnId, + createdAt, + }), + ]; + + case "turn.completed": + return [ + ...trackTurn(event.turnId, createdAt), + ...settleTurn({ wireTurnId: event.turnId, state: "completed", createdAt }), + ]; + + case "turn.failed": + return [ + ...trackTurn(event.turnId, createdAt), + ...settleTurn({ + wireTurnId: event.turnId, + state: "failed", + errorMessage: event.payload.errorMessage, + createdAt, + }), + { + // Deliberately NO turnId here (matching the REST errored path): + // ingestion's runtime.error branch reinstates + // `activeTurnId = event.turnId ?? null` AFTER the settle just + // cleared it, which would wedge the session on a settled turn and + // make the conflict guard drop every later turn.completed. + ...base({ + eventId: `aether:${taskId}:turn:${event.turnId}:error`, + createdAt, + }), + type: "runtime.error", + payload: { message: event.payload.errorMessage, class: "provider_error" }, + }, + ]; + + case "turn.awaiting_input": { + // An awaiting_input IS a settle: the remote turn ended and parked on + // a pending input. Dispatch on payload.toolName (the live shape has + // no `kind` — spec resolved note 12). + const settle = [ + ...trackTurn(event.turnId, createdAt), + ...settleTurn({ wireTurnId: event.turnId, state: "completed", createdAt }), + ]; + switch (event.payload.toolName) { + case "ask_user": + return [ + ...settle, + ...pendingInput({ + pendingId: event.pendingInputId, + toolName: "ask_user", + payload: event.payload.input, + wireTurnId: event.turnId, + createdAt, + }), + ]; + case "propose_plan": + return [ + ...settle, + ...pendingInput({ + pendingId: event.pendingInputId, + toolName: "propose_plan", + payload: event.payload.input, + wireTurnId: event.turnId, + createdAt, + }), + ]; + case "stop_task": + // Settle only: no prompt, no composer panel (spec §2.1 live row). + return settle; + default: + return [ + ...settle, + ...warningOnce( + `awaiting-tool:${event.payload.toolName}`, + `Aether reported an unknown pending-input tool '${event.payload.toolName}'. The task is waiting for input t3 cannot render — respond from the Aether app.`, + undefined, + createdAt, + ), + ]; + } + } + + case "conversation.truncated": + // No t3 event means "the remote conversation was truncated" (revert / + // rewind): thread.state values (compacted/closed/…) all misstate it, + // so surface a visible warning; the durable delta refetch on the next + // reconcile carries the actual removals (spec §2.1 runtime.warning row). + return [ + { + ...base({ + eventId: `aether:${taskId}:truncated:${event.payload.anchorMessageId}`, + createdAt, + }), + type: "runtime.warning", + payload: { + message: "Aether truncated the remote conversation (a revert or rewind).", + detail: { anchorMessageId: event.payload.anchorMessageId }, + }, + }, + ]; + + // No t3 slash-command surface for cloud sessions; the caller logs once. + case "slash_commands.updated": + return []; + } + }; + + // -- durable reconciliation ------------------------------------------------- + + const reconcileTask = (task: AetherTask, nowIso: string): ReadonlyArray => { + const createdAt = stamp(undefined, nowIso); + switch (task.status) { + // Status projection (spec §2.1 working-indicator row, must): + // queued→starting, processing→running — without these a passive resume + // onto a mid-turn task shows an idle thread receiving assistant output. + case "queued": + return projectSessionState( + "starting", + "Aether queued the task; waiting for a workspace.", + createdAt, + ); + case "processing": + return projectSessionState("running", undefined, createdAt); + + case "awaiting_input": { + const events: Array = []; + // The pending input belongs to the turn that ASKED — capture it + // before the settle clears the tracking, so the request event lands + // owned (the WS twin carries the same pairing). + const requestWireTurnId = activeWireTurnId; + // A task at awaiting_input has no turn in flight: settle a tracked + // one that never saw its live-only turn.* event (missed settles are + // recoverable ONLY here — spec §2.1 turn-settle row). + if (activeWireTurnId !== undefined) { + events.push( + ...settleTurn({ wireTurnId: activeWireTurnId, state: "completed", createdAt }), + ); + } + switch (task.awaiting_input.kind) { + case "message": + // The idle state between EVERY pair of turns: session READY, + // deliberately NO state emission (spec resolved note 2). + return events; + case "questions": + return [ + ...events, + ...pendingInput({ + pendingId: task.awaiting_input.tool_id, + toolName: "ask_user", + payload: isRecord(task.awaiting_input.input) ? task.awaiting_input.input : {}, + wireTurnId: requestWireTurnId, + createdAt, + }), + ]; + case "plan": + return [ + ...events, + ...pendingInput({ + pendingId: task.awaiting_input.tool_id, + toolName: "propose_plan", + payload: isRecord(task.awaiting_input.input) ? task.awaiting_input.input : {}, + wireTurnId: requestWireTurnId, + createdAt, + }), + ]; + case "unknown-kind": + return [ + ...events, + ...warningOnce( + `awaiting-kind:${task.awaiting_input.rawKind}`, + `Aether reported an unrecognized awaiting-input kind '${task.awaiting_input.rawKind}'. The task is waiting for input t3 cannot render — respond from the Aether app.`, + undefined, + createdAt, + ), + ]; + } + // Exhaustive switch above; unreachable. + return events; + } + + case "errored": { + const events: Array = []; + if (activeWireTurnId !== undefined) { + events.push( + ...settleTurn({ + wireTurnId: activeWireTurnId, + state: "failed", + errorMessage: task.error, + createdAt, + }), + ); + } + if (!taskErrorEmitted) { + taskErrorEmitted = true; + events.push({ + ...base({ eventId: `aether:${taskId}:errored`, createdAt }), + type: "runtime.error", + payload: { message: task.error, class: "provider_error" }, + }); + } + // errored→error (spec §2.1 working-indicator row). When a tracked + // turn just settled as failed, settleTurn already mirrored the error + // state and this emits nothing; it fires for a COLD observation of an + // errored task (resume with no turn in flight). + events.push(...projectSessionState("error", task.error, createdAt)); + return events; + } + + case "unknown-status": + return warningOnce( + `status:${task.rawStatus}`, + `Aether reported an unrecognized task status '${task.rawStatus}'. Live updates may be incomplete until t3's Aether driver is updated.`, + undefined, + createdAt, + ); + } + }; + + const reconcileDelta = ( + delta: AetherConversationDelta, + nowIso: string, + ): ReadonlyArray => { + const events: Array = []; + + if (delta.truncated) { + events.push( + ...warningOnce( + `delta-truncated:${lastSequence}`, + "Aether's change feed was truncated; some intermediate updates were skipped.", + undefined, + stamp(undefined, nowIso), + ), + ); + } + + const rows = [...delta.messages].sort((left, right) => left.sequence - right.sequence); + // Durable rows carry no turn field. Attribution walks the batch with a + // running opener: the wire turn id IS the opening user row (the mapper's + // core invariant), so a delivered user row opens the turn every + // subsequent row belongs to — including the awaiting_input resume case + // where activeProcessingTurn is already null. Queued/cancelled user rows + // are NOT openers: they park in the timeline ahead of their turn while + // the current one still streams. Rows before the first opener fall back + // to the turn the mapper already tracks (warm reconcile across a turn + // transition), and a batch with no opener at all sits mid-turn, so the + // delta's activeProcessingTurn owns it (opener consumed by an earlier + // delta). A cold mapper with none of the three leaves rows unowned — + // attribution would be a guess. + const activeWireTurnIdForRows = delta.activeProcessingTurn?.messageId; + const isTurnOpener = (row: (typeof rows)[number]): boolean => + row.role === "user" && row.deliveryStatus !== "queued" && row.deliveryStatus !== "cancelled"; + let runningWireTurnId = activeWireTurnId; + if (runningWireTurnId === undefined && !rows.some(isTurnOpener)) { + runningWireTurnId = activeWireTurnIdForRows; + } + + for (const row of rows) { + if (row.sequence <= lastSequence) { + continue; + } + lastSequence = row.sequence; + const createdAt = stamp(row.timestamp, nowIso); + if (row.role === "user") { + // t3 persists user bubbles only from its own thread.turn.start; + // remote-originated turn detection is build item 13, and answered + // tool responses resolve in T7. Rows still advance the cursor — + // and delivered openers move the running turn so later rows (and + // reconcileTask's pending-input events) are owned even on a cold + // resume to an already-awaiting task. + if (isTurnOpener(row)) { + runningWireTurnId = row.id; + events.push(...trackTurn(runningWireTurnId, createdAt)); + } + continue; + } + const wireTurnId = runningWireTurnId; + switch (row.variant) { + case "text": + events.push( + ...trackTurn(wireTurnId, createdAt), + ...messageItemCompleted({ + canonicalId: canonicalMessageItemId(row.id), + itemType: "assistant_message", + content: row.content, + wireTurnId, + createdAt, + }), + ); + break; + case "thinking": + events.push( + ...trackTurn(wireTurnId, createdAt), + ...messageItemCompleted({ + canonicalId: canonicalThinkingItemId(row.id), + itemType: "reasoning", + content: row.content, + wireTurnId, + createdAt, + }), + ); + break; + case "tool": + events.push( + ...mapToolUpdate(toolUpdateFromRow(row.tool, wireTurnId, createdAt), "durable"), + ); + break; + case "seam": + // The compaction seam is durable-ONLY (never live) and maps to + // t3's compacted thread state (spec §2.1 'Context compacted' row). + // Other seam reasons (teleport, …) have no t3 semantic. + if (row.seam.reason === "compaction") { + events.push({ + ...base({ eventId: `aether:${taskId}:seq:${row.sequence}`, createdAt }), + type: "thread.state.changed", + payload: { state: "compacted" }, + }); + } + break; + } + } + + if (delta.latestSequence > lastSequence) { + lastSequence = delta.latestSequence; + } + if (delta.activeProcessingTurn !== null) { + // The wire turn id is the user message id that opened the turn. A turn + // TRANSITION observed here (awaiting_input→processing skipped between + // observations) settles the displaced predecessor — see trackTurn. An + // attributed row above already opened the turn (this is then a no-op); + // this covers the status-flip-with-no-new-rows observation. + events.push( + ...trackTurn( + delta.activeProcessingTurn.messageId, + stamp(delta.activeProcessingTurn.startedAt, nowIso), + ), + ); + } + events.push(...reconcileTask(delta.task, nowIso)); + return events; + }; + + return { + mapWsEvent, + reconcileDelta, + reconcileTask, + latestSequence: () => lastSequence, + }; +} diff --git a/apps/server/src/provider/Layers/aether/restClient.ts b/apps/server/src/provider/Layers/aether/restClient.ts index df9ed182cf70..e9ab3f377094 100644 --- a/apps/server/src/provider/Layers/aether/restClient.ts +++ b/apps/server/src/provider/Layers/aether/restClient.ts @@ -35,6 +35,8 @@ import { AetherCreateTaskResponse, AetherProjectListResponse, AetherRespondToTaskResponse, + decodeAetherConnectConflict, + decodeAetherConnectResponse, decodeAetherTask, type AetherConversationDelta, type AetherConversationMessagesPage, @@ -43,6 +45,7 @@ import { type AetherRespondToTaskRequest, type AetherTask, type AetherUpdateTaskRequest, + type AetherWorkspaceConnectOutcome, } from "./restSchemas.ts"; // --------------------------------------------------------------------------- @@ -236,6 +239,20 @@ export interface AetherRestClient { taskId: string, after: number, ) => Effect.Effect; + /** + * `POST /workspaces/{id}/connect?start=...` → the state-discriminated + * connect union. `start` is the POSITIVE permission to boot the workspace + * (a query flag on the aether router, workspaces_openapi.go:69-78): + * `start:false` is the passive attach that treats a not-running workspace + * as durable-only and NEVER boots a VM just to view; `start:true` is + * reserved for user-initiated turns (T6). The 409 conflict union decodes + * into the `conflict` outcome variant — it is a state to branch on, not an + * error. + */ + readonly connectWorkspace: ( + workspaceId: string, + input: { readonly start: boolean }, + ) => Effect.Effect; /** `GET /projects` → the caller's linked projects. */ readonly listProjects: () => Effect.Effect, AetherRestError>; /** `GET /profile` — identity probe (same schema the provider probe uses). */ @@ -506,6 +523,38 @@ export function makeAetherRestClient(options: AetherRestClientOptions): AetherRe ); }, + connectWorkspace: (workspaceId, input) => { + const endpoint = "POST /workspaces/{id}/connect"; + const url = `${baseUrl}/workspaces/${workspaceId}/connect?start=${input.start ? "true" : "false"}`; + // The 409 conflict union is a decoded OUTCOME here, so this request + // cannot go through `execute` (which maps every non-2xx to an error). + return httpClient.execute(prepare(HttpClientRequest.post(url))).pipe( + Effect.timeout(timeoutMs), + Effect.mapError( + (cause) => + new AetherApiTransportError({ + endpoint, + detail: `Request failed before a response arrived: ${String(cause)}`, + cause, + }), + ), + Effect.flatMap((response) => { + if (response.status >= 200 && response.status < 300) { + return readJson(endpoint, response).pipe( + Effect.flatMap(decodeWith(endpoint, decodeAetherConnectResponse)), + ); + } + if (response.status === 409) { + return readJson(endpoint, response).pipe( + Effect.flatMap(decodeWith(endpoint, decodeAetherConnectConflict)), + Effect.map((conflict) => ({ state: "conflict", conflict }) as const), + ); + } + return mapErrorStatus(endpoint, response); + }), + ); + }, + listProjects: () => getJson("GET /projects", `${baseUrl}/projects`, decodeProjectListResponse).pipe( Effect.map((response) => response.projects), diff --git a/apps/server/src/provider/Layers/aether/restSchemas.ts b/apps/server/src/provider/Layers/aether/restSchemas.ts index a679a3e3a506..084005601927 100644 --- a/apps/server/src/provider/Layers/aether/restSchemas.ts +++ b/apps/server/src/provider/Layers/aether/restSchemas.ts @@ -368,6 +368,109 @@ export const AetherRespondToTaskResponse = Schema.Struct({ }); export type AetherRespondToTaskResponse = typeof AetherRespondToTaskResponse.Type; +// --------------------------------------------------------------------------- +// Workspace connect (state-discriminated union + 409 conflict union) +// --------------------------------------------------------------------------- + +// `POST /workspaces/{id}/connect` 200 body — a oneOf discriminated on +// `state` (apps/api/apitypes/workspaces.go ConnectWorkspaceResponse): +// `transport` belongs to running, `retry_after_ms` to connecting. +const AetherConnectRunning = Schema.Struct({ + state: Schema.Literal("running"), + transport: Schema.Struct({ + websocket_path: Schema.String, + preview_token: Schema.String, + }), +}); +const AetherConnectConnecting = Schema.Struct({ + state: Schema.Literal("connecting"), + retry_after_ms: Schema.Number, +}); + +// The three kinds of connect 409, discriminated by what the client should DO +// (apitypes RegisterWorkspaceConnectConflictSchema): `transitional` — ask +// again after retry_after_ms; `startable` — a connect with start=true would +// actually start the workspace; `not_connectable` — nothing the client does +// will change it. +const AetherConnectConflictTransitional = Schema.Struct({ + kind: Schema.Literal("transitional"), + error: Schema.String, + retry_after_ms: Schema.Number, +}); +const AetherConnectConflictStartable = Schema.Struct({ + kind: Schema.Literal("startable"), + error: Schema.String, +}); +const AetherConnectConflictNotConnectable = Schema.Struct({ + kind: Schema.Literal("not_connectable"), + error: Schema.String, + display_state: Schema.String, +}); + +export type AetherWorkspaceConnectConflict = + | typeof AetherConnectConflictTransitional.Type + | typeof AetherConnectConflictStartable.Type + | typeof AetherConnectConflictNotConnectable.Type; + +/** + * The decoded connect outcome. The 409 conflict union is DATA, not an error: + * a suspended workspace answering a passive attach is an expected state the + * caller must branch on (durable-only mode), never an exception path. + */ +export type AetherWorkspaceConnectOutcome = + | typeof AetherConnectRunning.Type + | typeof AetherConnectConnecting.Type + | { readonly state: "conflict"; readonly conflict: AetherWorkspaceConnectConflict }; + +const decodeStateProbe = Schema.decodeUnknownEffect(Schema.Struct({ state: Schema.String })); +const decodeConnectRunning = Schema.decodeUnknownEffect(AetherConnectRunning); +const decodeConnectConnecting = Schema.decodeUnknownEffect(AetherConnectConnecting); + +/** Decode the 200 connect union. An unknown state fails loudly. */ +export const decodeAetherConnectResponse = ( + input: unknown, +): Effect.Effect => + Effect.gen(function* () { + const probe = yield* decodeStateProbe(input); + switch (probe.state) { + case "running": + return yield* decodeConnectRunning(input); + case "connecting": + return yield* decodeConnectConnecting(input); + default: + // Unlike the growable task-status enum, connect states are the two + // halves of one handshake — a third one means this client cannot + // know whether a socket exists, so it must fail, not guess. + return yield* decodeConnectRunning(input); + } + }); + +const decodeConflictKindProbe = Schema.decodeUnknownEffect(Schema.Struct({ kind: Schema.String })); +const decodeConflictTransitional = Schema.decodeUnknownEffect(AetherConnectConflictTransitional); +const decodeConflictStartable = Schema.decodeUnknownEffect(AetherConnectConflictStartable); +const decodeConflictNotConnectable = Schema.decodeUnknownEffect( + AetherConnectConflictNotConnectable, +); + +/** Decode the 409 conflict union. An unknown kind fails loudly. */ +export const decodeAetherConnectConflict = ( + input: unknown, +): Effect.Effect => + Effect.gen(function* () { + const probe = yield* decodeConflictKindProbe(input); + switch (probe.kind) { + case "transitional": + return yield* decodeConflictTransitional(input); + case "startable": + return yield* decodeConflictStartable(input); + case "not_connectable": + return yield* decodeConflictNotConnectable(input); + default: + // The kind IS the client's next move; an unknown one is undecidable. + return yield* decodeConflictTransitional(input); + } + }); + // --------------------------------------------------------------------------- // Projects // --------------------------------------------------------------------------- diff --git a/apps/server/src/provider/Layers/aether/wireEvents.ts b/apps/server/src/provider/Layers/aether/wireEvents.ts new file mode 100644 index 000000000000..a55216b733c4 --- /dev/null +++ b/apps/server/src/provider/Layers/aether/wireEvents.ts @@ -0,0 +1,308 @@ +/** + * Aether workspace WS agent-channel wire events — parse-at-boundary shapes + * for the live event stream the AetherDriver subscribes to. + * + * Wire source (aether repo, read-only reference): + * `packages/workspace-protocol/src/messages.ts` — the 13-kind agent task + * event union (3 tool kinds + 10 non-tool kinds), envelope + * `{channel:"agent", type:"task_event", taskId, kind, createdAt?}`. + * + * The SERVER emits `z.strictObject` shapes, but this client parses LOOSELY + * (plain `Schema.Struct`, open string enums): a newer Aether server adding a + * field or an event kind must never crash the driver's stream. The contract + * here (spec §3.11 + build item 5) is: + * - additive fields on known kinds: tolerated by construction (loose structs) + * - unknown event kinds: an explicit `unknown-kind` carrier — the caller + * logs once per kind and drops the frame + * - a KNOWN kind whose payload no longer parses: an explicit `malformed` + * carrier — the caller warns loudly and drops the frame; the REST delta + * reconciliation on the next (re)connect is the durable backstop + * - frames for other channels: an `ignored` carrier (never an error — the + * socket multiplexes channels by design) + * Parsing never fails the stream and never kills the socket. + * + * @module provider/Layers/aether/wireEvents + */ +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; + +// --------------------------------------------------------------------------- +// Payload schemas (loose twins of the strict wire schemas) +// --------------------------------------------------------------------------- + +/** + * Tool display: `label` plus the display-block list. Blocks stay opaque + * (`unknown`) at this boundary — the event mapper reads the two block types + * it consumes (`terminal`, `todo_list`) defensively, so a new block type is + * additive instead of fatal. + */ +const AetherWsToolDisplay = Schema.Struct({ + label: Schema.String, + blocks: Schema.optional(Schema.Array(Schema.Unknown)), +}); +export type AetherWsToolDisplay = typeof AetherWsToolDisplay.Type; + +const AetherWsToolPayload = Schema.Struct({ + name: Schema.String, + input: Schema.Record(Schema.String, Schema.Unknown), + display: AetherWsToolDisplay, + // Open server enums (tool status, canonical item type) decode as strings. + status: Schema.String, + itemType: Schema.optional(Schema.String), + result: Schema.optional(Schema.String), + error: Schema.optional(Schema.String), +}); +export type AetherWsToolPayload = typeof AetherWsToolPayload.Type; + +const envelopeFields = { + taskId: Schema.String, + createdAt: Schema.optional(Schema.String), +} as const; + +const messageFields = { + ...envelopeFields, + messageId: Schema.String, + turnId: Schema.optional(Schema.String), +} as const; + +const AetherWsToolCallEvent = Schema.Struct({ + ...envelopeFields, + kind: Schema.Literals(["tool_call.started", "tool_call.completed", "tool_call.failed"]), + toolCallId: Schema.String, + turnId: Schema.optional(Schema.String), + payload: AetherWsToolPayload, +}); +export type AetherWsToolCallEvent = typeof AetherWsToolCallEvent.Type; + +const AetherWsAssistantDeltaEvent = Schema.Struct({ + ...messageFields, + kind: Schema.Literal("assistant_message.delta"), + payload: Schema.Struct({ delta: Schema.String }), +}); +export type AetherWsAssistantDeltaEvent = typeof AetherWsAssistantDeltaEvent.Type; + +const AetherWsThinkingDeltaEvent = Schema.Struct({ + ...messageFields, + kind: Schema.Literal("thinking.delta"), + payload: Schema.Struct({ delta: Schema.String }), +}); +export type AetherWsThinkingDeltaEvent = typeof AetherWsThinkingDeltaEvent.Type; + +const AetherWsStreamCompleteEvent = Schema.Struct({ + ...messageFields, + kind: Schema.Literal("stream.complete"), +}); +export type AetherWsStreamCompleteEvent = typeof AetherWsStreamCompleteEvent.Type; + +const AetherWsAssistantCompletedEvent = Schema.Struct({ + ...messageFields, + kind: Schema.Literal("assistant_message.completed"), + payload: Schema.Struct({ content: Schema.String }), +}); +export type AetherWsAssistantCompletedEvent = typeof AetherWsAssistantCompletedEvent.Type; + +const AetherWsThinkingCompletedEvent = Schema.Struct({ + ...messageFields, + kind: Schema.Literal("thinking.completed"), + payload: Schema.Struct({ content: Schema.String }), +}); +export type AetherWsThinkingCompletedEvent = typeof AetherWsThinkingCompletedEvent.Type; + +const AetherWsTurnCompletedEvent = Schema.Struct({ + ...envelopeFields, + kind: Schema.Literal("turn.completed"), + turnId: Schema.String, + payload: Schema.Struct({ status: Schema.String }), +}); +export type AetherWsTurnCompletedEvent = typeof AetherWsTurnCompletedEvent.Type; + +/** + * The LIVE awaiting-input shape (messages.ts:377-384): `pendingInputId` + + * `payload.{toolName, input}`. There is deliberately NO `kind` discriminator + * and NO `tool_id` here — those exist only on the persisted REST projection + * (spec resolved note 12). `pendingInputId` names the same pending input as + * the REST `tool_id`. + */ +const AetherWsTurnAwaitingInputEvent = Schema.Struct({ + ...envelopeFields, + kind: Schema.Literal("turn.awaiting_input"), + turnId: Schema.String, + pendingInputId: Schema.String, + toolCallId: Schema.String, + payload: Schema.Struct({ + toolName: Schema.String, + input: Schema.Record(Schema.String, Schema.Unknown), + }), +}); +export type AetherWsTurnAwaitingInputEvent = typeof AetherWsTurnAwaitingInputEvent.Type; + +const AetherWsTurnFailedEvent = Schema.Struct({ + ...envelopeFields, + kind: Schema.Literal("turn.failed"), + turnId: Schema.String, + payload: Schema.Struct({ errorMessage: Schema.String }), +}); +export type AetherWsTurnFailedEvent = typeof AetherWsTurnFailedEvent.Type; + +const AetherWsConversationTruncatedEvent = Schema.Struct({ + ...messageFields, + kind: Schema.Literal("conversation.truncated"), + payload: Schema.Struct({ anchorMessageId: Schema.String }), +}); +export type AetherWsConversationTruncatedEvent = typeof AetherWsConversationTruncatedEvent.Type; + +// Payload deliberately untyped: t3 has no slash-command surface for cloud +// sessions yet; the event is acknowledged and dropped (logged once). +const AetherWsSlashCommandsUpdatedEvent = Schema.Struct({ + ...envelopeFields, + kind: Schema.Literal("slash_commands.updated"), +}); +export type AetherWsSlashCommandsUpdatedEvent = typeof AetherWsSlashCommandsUpdatedEvent.Type; + +/** The full parsed agent event union — all 13 wire kinds. */ +export type AetherAgentEvent = + | AetherWsToolCallEvent + | AetherWsAssistantDeltaEvent + | AetherWsThinkingDeltaEvent + | AetherWsStreamCompleteEvent + | AetherWsAssistantCompletedEvent + | AetherWsThinkingCompletedEvent + | AetherWsTurnCompletedEvent + | AetherWsTurnAwaitingInputEvent + | AetherWsTurnFailedEvent + | AetherWsConversationTruncatedEvent + | AetherWsSlashCommandsUpdatedEvent; + +// --------------------------------------------------------------------------- +// Frame parsing +// --------------------------------------------------------------------------- + +export type AetherFrameParseResult = + /** A fully parsed agent task event. */ + | { readonly _tag: "event"; readonly event: AetherAgentEvent } + /** A frame for another channel / message type — not ours, silently skipped. */ + | { readonly _tag: "ignored"; readonly channel: string; readonly type: string } + /** + * A `channel:"error"` frame — the workspace-service reporting ITS OWN + * failure (initialization error before a close, or strict-parse rejection + * of a client message with the socket left open). NOT another multiplexed + * channel: silently skipping it leaves a connected-but-mute socket with + * zero diagnostics under protocol skew. The caller must surface it. + */ + | { readonly _tag: "server-error"; readonly detail: string } + /** An agent task event whose kind this build does not know. Log once, drop. */ + | { readonly _tag: "unknown-kind"; readonly kind: string } + /** Not JSON, no envelope, or a KNOWN kind whose payload failed to parse. */ + | { readonly _tag: "malformed"; readonly kind: string | undefined; readonly detail: string }; + +const decodeJsonFrame = Schema.decodeUnknownResult(Schema.fromJsonString(Schema.Unknown)); +const decodeEnvelopeProbe = Schema.decodeUnknownResult( + Schema.Struct({ channel: Schema.String, type: Schema.String }), +); +const decodeKindProbe = Schema.decodeUnknownResult(Schema.Struct({ kind: Schema.String })); +// The server error frame is `{channel:"error", type:"error", error: string}` +// (workspace-service server.ts); probed loosely like everything else. +const decodeErrorProbe = Schema.decodeUnknownResult(Schema.Struct({ error: Schema.String })); + +const decodeToolCall = Schema.decodeUnknownResult(AetherWsToolCallEvent); +const decodeAssistantDelta = Schema.decodeUnknownResult(AetherWsAssistantDeltaEvent); +const decodeThinkingDelta = Schema.decodeUnknownResult(AetherWsThinkingDeltaEvent); +const decodeStreamComplete = Schema.decodeUnknownResult(AetherWsStreamCompleteEvent); +const decodeAssistantCompleted = Schema.decodeUnknownResult(AetherWsAssistantCompletedEvent); +const decodeThinkingCompleted = Schema.decodeUnknownResult(AetherWsThinkingCompletedEvent); +const decodeTurnCompleted = Schema.decodeUnknownResult(AetherWsTurnCompletedEvent); +const decodeTurnAwaitingInput = Schema.decodeUnknownResult(AetherWsTurnAwaitingInputEvent); +const decodeTurnFailed = Schema.decodeUnknownResult(AetherWsTurnFailedEvent); +const decodeConversationTruncated = Schema.decodeUnknownResult(AetherWsConversationTruncatedEvent); +const decodeSlashCommandsUpdated = Schema.decodeUnknownResult(AetherWsSlashCommandsUpdatedEvent); + +const decodeByKind = ( + kind: string, + frame: unknown, +): Result.Result | undefined => { + switch (kind) { + case "tool_call.started": + case "tool_call.completed": + case "tool_call.failed": + return decodeToolCall(frame); + case "assistant_message.delta": + return decodeAssistantDelta(frame); + case "thinking.delta": + return decodeThinkingDelta(frame); + case "stream.complete": + return decodeStreamComplete(frame); + case "assistant_message.completed": + return decodeAssistantCompleted(frame); + case "thinking.completed": + return decodeThinkingCompleted(frame); + case "turn.completed": + return decodeTurnCompleted(frame); + case "turn.awaiting_input": + return decodeTurnAwaitingInput(frame); + case "turn.failed": + return decodeTurnFailed(frame); + case "conversation.truncated": + return decodeConversationTruncated(frame); + case "slash_commands.updated": + return decodeSlashCommandsUpdated(frame); + default: + return undefined; + } +}; + +/** + * Parse one raw WS frame into the tolerant result union. Pure and + * synchronous, and it NEVER throws: every problem is an explicit carrier so + * the socket loop can log-and-drop without a catch-all that would also + * swallow real bugs. + */ +export function parseAetherAgentFrame(raw: string): AetherFrameParseResult { + const json = decodeJsonFrame(raw); + if (Result.isFailure(json)) { + return { _tag: "malformed", kind: undefined, detail: "Frame is not valid JSON." }; + } + const frame: unknown = json.success; + + const envelope = decodeEnvelopeProbe(frame); + if (Result.isFailure(envelope)) { + return { + _tag: "malformed", + kind: undefined, + detail: "Frame carries no {channel, type} envelope.", + }; + } + if (envelope.success.channel === "error") { + const probe = decodeErrorProbe(frame); + return { + _tag: "server-error", + detail: Result.isSuccess(probe) + ? probe.success.error + : "server sent an error frame carrying no string `error` field", + }; + } + if (envelope.success.channel !== "agent" || envelope.success.type !== "task_event") { + return { _tag: "ignored", channel: envelope.success.channel, type: envelope.success.type }; + } + + const probe = decodeKindProbe(frame); + if (Result.isFailure(probe)) { + return { + _tag: "malformed", + kind: undefined, + detail: "Agent task event carries no string `kind`.", + }; + } + + const decoded = decodeByKind(probe.success.kind, frame); + if (decoded === undefined) { + return { _tag: "unknown-kind", kind: probe.success.kind }; + } + if (Result.isFailure(decoded)) { + return { + _tag: "malformed", + kind: probe.success.kind, + detail: `Known event kind '${probe.success.kind}' failed to parse: ${String(decoded.failure)}`, + }; + } + return { _tag: "event", event: decoded.success }; +} diff --git a/apps/server/src/provider/Layers/aether/workspaceSocket.test.ts b/apps/server/src/provider/Layers/aether/workspaceSocket.test.ts new file mode 100644 index 000000000000..bc421df75119 --- /dev/null +++ b/apps/server/src/provider/Layers/aether/workspaceSocket.test.ts @@ -0,0 +1,606 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as TestClock from "effect/testing/TestClock"; + +import { AetherApiTransportError } from "./restClient.ts"; +import type { AetherRestClient, AetherRestError } from "./restClient.ts"; +import type { AetherTask, AetherWorkspaceConnectOutcome } from "./restSchemas.ts"; +import { + connectForTransport, + resolveTaskWorkspace, + runAetherAgentStream, + aetherWorkspaceSocketUrl, + type AetherAgentStreamOptions, + type AetherWebSocketLike, +} from "./workspaceSocket.ts"; +import type { AetherAgentEvent } from "./wireEvents.ts"; +import { taskProcessing, wsAssistantDelta, wsUnknownKindFrame } from "./eventMapper.fixtures.ts"; + +const taskQueued: AetherTask = { + ...taskProcessing, + status: "queued", + run_context: null, +}; + +const taskParked: AetherTask = { + ...taskProcessing, + status: "awaiting_input", + run_context: null, + awaiting_input: { kind: "message" }, +}; + +const taskErrored: AetherTask = { + ...taskProcessing, + status: "errored", + run_context: null, + error: "provisioning failed", + completed_at: "2026-08-08T10:05:00Z", +}; + +const taskUnknown: AetherTask = { + ...taskProcessing, + status: "unknown-status", + rawStatus: "hibernating", +}; + +const ZERO_TIMING = { + pollInitialMs: 0, + pollMaxMs: 0, + reconnectInitialMs: 0, + reconnectMaxMs: 0, + connectDefaultRetryMs: 0, +} as const; + +/** getTask fake returning scripted answers in order (last one repeats). */ +function scriptedGetTask(answers: ReadonlyArray): { + readonly getTask: AetherRestClient["getTask"]; + readonly calls: () => number; +} { + let calls = 0; + return { + getTask: () => { + const answer = answers[Math.min(calls, answers.length - 1)]!; + calls++; + return Effect.succeed(answer); + }, + calls: () => calls, + }; +} + +const runningOutcome: AetherWorkspaceConnectOutcome = { + state: "running", + transport: { websocket_path: "/workspaces/ws-1/ws", preview_token: "t".repeat(32) }, +}; + +function scriptedConnect(answers: ReadonlyArray): { + readonly connectWorkspace: AetherRestClient["connectWorkspace"]; + readonly calls: () => number; +} { + let calls = 0; + return { + connectWorkspace: () => { + const answer = answers[Math.min(calls, answers.length - 1)]!; + calls++; + return "state" in answer ? Effect.succeed(answer) : Effect.fail(answer); + }, + calls: () => calls, + }; +} + +describe("resolveTaskWorkspace", () => { + it.effect("proceeds on processing with the run_context workspace id", () => + Effect.gen(function* () { + const fake = scriptedGetTask([taskProcessing]); + const resolution = yield* resolveTaskWorkspace({ + getTask: fake.getTask, + taskId: "task-1", + timing: ZERO_TIMING, + }); + expect(resolution).toMatchObject({ _tag: "workspace", workspaceId: "ws-1" }); + expect(fake.calls()).toBe(1); + }), + ); + + it.effect("backs off through queued until the dispatcher flips to processing", () => + Effect.gen(function* () { + const fake = scriptedGetTask([taskQueued, taskQueued, taskProcessing]); + const resolution = yield* resolveTaskWorkspace({ + getTask: fake.getTask, + taskId: "task-1", + timing: ZERO_TIMING, + }); + expect(resolution._tag).toBe("workspace"); + expect(fake.calls()).toBe(3); + }), + ); + + it.effect("treats null-context awaiting_input as TERMINAL: parked, zero further polls", () => + Effect.gen(function* () { + // The parked state is STABLE (all queued messages cancelled before + // workspace assignment); backing off on it would poll forever. + const fake = scriptedGetTask([taskParked]); + const resolution = yield* resolveTaskWorkspace({ + getTask: fake.getTask, + taskId: "task-1", + timing: ZERO_TIMING, + }); + expect(resolution._tag).toBe("parked"); + expect(fake.calls()).toBe(1); + }), + ); + + it.effect("fails loudly with the task's error payload when errored before assignment", () => + Effect.gen(function* () { + const fake = scriptedGetTask([taskErrored]); + const error = yield* Effect.flip( + resolveTaskWorkspace({ getTask: fake.getTask, taskId: "task-1", timing: ZERO_TIMING }), + ); + expect(error._tag).toBe("AetherTaskErroredError"); + if (error._tag === "AetherTaskErroredError") { + expect(error.error).toBe("provisioning failed"); + expect(error.completedAt).toBe("2026-08-08T10:05:00Z"); + } + // No infinite poll: exactly one read. + expect(fake.calls()).toBe(1); + }), + ); + + it.effect("fails loudly on the unknown-status carrier, never treating it as pending", () => + Effect.gen(function* () { + const fake = scriptedGetTask([taskUnknown]); + const error = yield* Effect.flip( + resolveTaskWorkspace({ getTask: fake.getTask, taskId: "task-1", timing: ZERO_TIMING }), + ); + expect(error._tag).toBe("AetherTaskUnknownStatusError"); + expect(fake.calls()).toBe(1); + }), + ); +}); + +describe("connectForTransport", () => { + it.effect("loops through connecting and the transitional 409 to running", () => + Effect.gen(function* () { + const fake = scriptedConnect([ + { state: "connecting", retry_after_ms: 0 }, + { + state: "conflict", + conflict: { kind: "transitional", error: "suspending", retry_after_ms: 0 }, + }, + runningOutcome, + ]); + const resolution = yield* connectForTransport({ + connectWorkspace: fake.connectWorkspace, + workspaceId: "ws-1", + start: false, + timing: ZERO_TIMING, + }); + expect(resolution).toMatchObject({ _tag: "transport", websocketPath: "/workspaces/ws-1/ws" }); + expect(fake.calls()).toBe(3); + }), + ); + + it.effect("treats the startable 409 as unavailable — passive attach never boots a VM", () => + Effect.gen(function* () { + const fake = scriptedConnect([ + { state: "conflict", conflict: { kind: "startable", error: "workspace is idle" } }, + ]); + const resolution = yield* connectForTransport({ + connectWorkspace: fake.connectWorkspace, + workspaceId: "ws-1", + start: false, + timing: ZERO_TIMING, + }); + expect(resolution).toMatchObject({ _tag: "unavailable" }); + expect(fake.calls()).toBe(1); + }), + ); + + it.effect("treats not_connectable as unavailable, naming the display state", () => + Effect.gen(function* () { + const fake = scriptedConnect([ + { + state: "conflict", + conflict: { + kind: "not_connectable", + error: "workspace deleted", + display_state: "deleted", + }, + }, + ]); + const resolution = yield* connectForTransport({ + connectWorkspace: fake.connectWorkspace, + workspaceId: "ws-1", + start: false, + timing: ZERO_TIMING, + }); + expect(resolution).toMatchObject({ _tag: "unavailable" }); + if (resolution._tag === "unavailable") { + expect(resolution.reason).toContain("deleted"); + } + }), + ); + + it.effect("fails loudly after the connecting retry budget", () => + Effect.gen(function* () { + const fake = scriptedConnect([{ state: "connecting", retry_after_ms: 0 }]); + const error = yield* Effect.flip( + connectForTransport({ + connectWorkspace: fake.connectWorkspace, + workspaceId: "ws-1", + start: false, + timing: { ...ZERO_TIMING, connectMaxAttempts: 3 }, + }), + ); + expect(error._tag).toBe("AetherWorkspaceConnectTimeoutError"); + expect(fake.calls()).toBe(3); + }), + ); +}); + +describe("aetherWorkspaceSocketUrl", () => { + it("builds a wss URL with the key as the token query", () => { + expect( + aetherWorkspaceSocketUrl("https://api.runaether.dev", "/workspaces/ws-1/ws", "aether_k+y"), + ).toBe("wss://api.runaether.dev/workspaces/ws-1/ws?token=aether_k%2By"); + }); + + it("refuses protocol-relative and query-carrying paths", () => { + expect(() => + aetherWorkspaceSocketUrl("https://api.runaether.dev", "//evil.example/ws", "k"), + ).toThrow("same-origin"); + expect(() => aetherWorkspaceSocketUrl("https://api.runaether.dev", "/ws?x=1", "k")).toThrow( + "no query", + ); + }); +}); + +// --------------------------------------------------------------------------- +// Socket loop +// --------------------------------------------------------------------------- + +type Listener = (event: never) => void; + +class FakeSocket implements AetherWebSocketLike { + readonly sent: Array = []; + closed = false; + /** When set, the socket errors instead of opening (upgrade failure). */ + failOpen = false; + private opened = false; + private readonly listeners = new Map void>>(); + + addEventListener(type: string, listener: Listener): void { + const list = this.listeners.get(type) ?? []; + list.push(listener as (event: unknown) => void); + this.listeners.set(type, list); + // The loop registers its open listener strictly after construction; + // firing on registration models an already-open upgrade deterministically. + if (type === "open" && this.opened) { + (listener as () => void)(); + } + // Same trick for a deterministic upgrade failure. + if (type === "error" && this.failOpen) { + (listener as (event: unknown) => void)(new Error("upgrade refused")); + } + } + + send(data: string): void { + this.sent.push(data); + } + + close(): void { + if (this.closed) { + return; + } + this.closed = true; + this.fire("close", { code: 1000, reason: "client closed" }); + } + + open(): void { + this.opened = true; + this.fire("open", undefined); + } + + serverClose(code: number, reason: string): void { + this.closed = true; + this.fire("close", { code, reason }); + } + + message(frame: unknown): void { + this.fire("message", { data: JSON.stringify(frame) }); + } + + private fire(type: string, event: unknown): void { + for (const listener of this.listeners.get(type) ?? []) { + listener(event); + } + } +} + +interface StreamHarness { + readonly sockets: Array; + readonly events: Array; + readonly dropped: Array<{ key: string; detail: string }>; + readonly durableOnly: Array; + readonly connects: Array; + readonly connectRetries: Array<{ consecutiveFailures: number; detail: string }>; + readonly options: AetherAgentStreamOptions; +} + +function makeHarness( + restClient: Pick, + configureSocket?: (socket: FakeSocket, index: number) => void, +): StreamHarness { + const sockets: Array = []; + const events: Array = []; + const dropped: Array<{ key: string; detail: string }> = []; + const durableOnly: Array = []; + const connects: Array = []; + const connectRetries: Array<{ consecutiveFailures: number; detail: string }> = []; + return { + sockets, + events, + dropped, + durableOnly, + connects, + connectRetries, + options: { + restClient, + apiBaseUrl: "https://api.runaether.dev", + apiKey: "aether_test_key", + taskId: "task-1", + timing: ZERO_TIMING, + webSocketFactory: () => { + const socket = new FakeSocket(); + configureSocket?.(socket, sockets.length); + sockets.push(socket); + if (!socket.failOpen) { + // Model an instantly-successful upgrade: the loop's open listener + // fires on registration (see FakeSocket.addEventListener). + socket.open(); + } + return socket; + }, + onConnected: () => Effect.sync(() => void connects.push(sockets.length)), + onEvent: (event) => Effect.sync(() => void events.push(event)), + onFrameDropped: (problem) => Effect.sync(() => void dropped.push({ ...problem })), + onConnectRetry: (failure) => Effect.sync(() => void connectRetries.push({ ...failure })), + onDurableOnly: (reason) => Effect.sync(() => void durableOnly.push(reason)), + }, + }; +} + +const settlePump = Effect.gen(function* () { + // Let the forked pump run through its queued signals; the zero-duration + // clock adjustments release the zero backoff sleeps. + for (let i = 0; i < 8; i++) { + yield* TestClock.adjust("0 millis"); + yield* Effect.yieldNow; + } +}); + +describe("runAetherAgentStream", () => { + it.effect("attaches, subscribes, streams frames, and drops unknown kinds once", () => + Effect.gen(function* () { + const harness = makeHarness({ + getTask: scriptedGetTask([taskProcessing]).getTask, + connectWorkspace: scriptedConnect([runningOutcome]).connectWorkspace, + }); + const fiber = yield* Effect.forkChild(runAetherAgentStream(harness.options)); + yield* settlePump; + + expect(harness.connects).toEqual([1]); + const socket = harness.sockets[0]!; + // Agent-channel subscription for exactly this task. + expect(socket.sent[0]).toBe('{"channel":"agent","type":"subscribe","taskId":"task-1"}'); + + socket.message(wsAssistantDelta); + socket.message(wsUnknownKindFrame); + socket.message(wsUnknownKindFrame); + socket.message({ channel: "files", type: "change", path: "/x", action: "modify" }); + yield* settlePump; + + expect(harness.events).toHaveLength(1); + expect(harness.events[0]).toMatchObject({ kind: "assistant_message.delta" }); + // Unknown kind: logged once, dropped, socket alive. + expect(harness.dropped).toEqual([ + { + key: "unknown-kind:usage.updated", + detail: expect.stringContaining("usage.updated"), + }, + ]); + expect(socket.closed).toBe(false); + + yield* Fiber.interrupt(fiber); + // Scope teardown closes the socket (session-scope ownership). + expect(socket.closed).toBe(true); + }), + ); + + it.effect("reconnects after a server close: full re-attach + resubscribe + onConnected", () => + Effect.gen(function* () { + const harness = makeHarness({ + getTask: scriptedGetTask([taskProcessing]).getTask, + connectWorkspace: scriptedConnect([runningOutcome]).connectWorkspace, + }); + const fiber = yield* Effect.forkChild(runAetherAgentStream(harness.options)); + yield* settlePump; + expect(harness.connects).toEqual([1]); + + harness.sockets[0]!.serverClose(1006, "vm went away"); + yield* settlePump; + + // A second socket, resubscribed, and a second onConnected (which is + // where the caller replays the durable delta from its cursor). + expect(harness.sockets).toHaveLength(2); + expect(harness.connects).toEqual([1, 2]); + expect(harness.sockets[1]!.sent[0]).toBe( + '{"channel":"agent","type":"subscribe","taskId":"task-1"}', + ); + + yield* Fiber.interrupt(fiber); + }), + ); + + it.effect("settles into durable-only mode on a parked task with zero further polls", () => + Effect.gen(function* () { + const fake = scriptedGetTask([taskParked]); + const harness = makeHarness({ + getTask: fake.getTask, + connectWorkspace: scriptedConnect([runningOutcome]).connectWorkspace, + }); + // The pump ENDS on its own — no interrupt needed. + yield* runAetherAgentStream(harness.options); + expect(harness.durableOnly).toHaveLength(1); + expect(harness.durableOnly[0]).toContain("awaiting input"); + expect(fake.calls()).toBe(1); + expect(harness.sockets).toHaveLength(0); + expect(harness.connects).toEqual([]); + }), + ); + + it.effect("settles into durable-only mode when the workspace is not running", () => + Effect.gen(function* () { + const harness = makeHarness({ + getTask: scriptedGetTask([taskProcessing]).getTask, + connectWorkspace: scriptedConnect([ + { state: "conflict", conflict: { kind: "startable", error: "idle" } }, + ]).connectWorkspace, + }); + yield* runAetherAgentStream(harness.options); + expect(harness.durableOnly).toHaveLength(1); + expect(harness.sockets).toHaveLength(0); + }), + ); + + it.effect("propagates the errored-task failure instead of polling forever", () => + Effect.gen(function* () { + const harness = makeHarness({ + getTask: scriptedGetTask([taskErrored]).getTask, + connectWorkspace: scriptedConnect([runningOutcome]).connectWorkspace, + }); + const error = yield* Effect.flip(runAetherAgentStream(harness.options)); + expect(error._tag).toBe("AetherTaskErroredError"); + }), + ); + + it.effect("fires onConnectRetry on open failures and keeps retrying until an open succeeds", () => + Effect.gen(function* () { + const harness = makeHarness( + { + getTask: scriptedGetTask([taskProcessing]).getTask, + connectWorkspace: scriptedConnect([runningOutcome]).connectWorkspace, + }, + // The first two upgrades fail; the third opens. + (socket, index) => { + socket.failOpen = index < 2; + }, + ); + const fiber = yield* Effect.forkChild(runAetherAgentStream(harness.options)); + yield* settlePump; + + // Each failed open surfaced the degradation (the caller reconciles the + // durable feed on this beat) with a growing consecutive count, and the + // loop still reached a successful attach — never a silent dead loop. + expect(harness.connectRetries.map((retry) => retry.consecutiveFailures)).toEqual([1, 2]); + expect(harness.connects).toEqual([3]); + // The acquireRelease finalizer only registers after a successful open, + // and open failures retry forever — every pre-open failure must close + // its raw socket itself or each retry leaks one. + expect(harness.sockets[0]!.closed).toBe(true); + expect(harness.sockets[1]!.closed).toBe(true); + + yield* Fiber.interrupt(fiber); + }), + ); + + it.effect("surfaces server error-channel frames through onFrameDropped, once per detail", () => + Effect.gen(function* () { + const harness = makeHarness({ + getTask: scriptedGetTask([taskProcessing]).getTask, + connectWorkspace: scriptedConnect([runningOutcome]).connectWorkspace, + }); + const fiber = yield* Effect.forkChild(runAetherAgentStream(harness.options)); + yield* settlePump; + + const socket = harness.sockets[0]!; + // The workspace-service rejects a client message it cannot parse and + // keeps the socket open — without surfacing this, the driver would sit + // attached-but-mute with zero diagnostics. + socket.message({ channel: "error", type: "error", error: "invalid subscribe message" }); + socket.message({ channel: "error", type: "error", error: "invalid subscribe message" }); + socket.message({ channel: "error", type: "error" }); + yield* settlePump; + + expect(harness.dropped).toEqual([ + { + key: "server-error:invalid subscribe message", + detail: expect.stringContaining("invalid subscribe message"), + }, + { + key: "server-error:server sent an error frame carrying no string `error` field", + detail: expect.stringContaining("no string `error` field"), + }, + ]); + expect(socket.closed).toBe(false); + + yield* Fiber.interrupt(fiber); + }), + ); + + it.effect("re-enters the backoff ladder on a transport-class REST failure after connecting", () => + Effect.gen(function* () { + const transportBlip = new AetherApiTransportError({ + endpoint: "GET /tasks/task-1", + detail: "socket hang up", + }); + // Attach OK → socket drops → re-attach getTask blips → next attempt OK. + let getTaskCalls = 0; + const answers: ReadonlyArray = [ + taskProcessing, + transportBlip, + taskProcessing, + ]; + const harness = makeHarness({ + getTask: () => { + const answer = answers[Math.min(getTaskCalls, answers.length - 1)]!; + getTaskCalls++; + return "_tag" in answer ? Effect.fail(answer) : Effect.succeed(answer); + }, + connectWorkspace: scriptedConnect([runningOutcome]).connectWorkspace, + }); + const fiber = yield* Effect.forkChild(runAetherAgentStream(harness.options)); + yield* settlePump; + expect(harness.connects).toEqual([1]); + + harness.sockets[0]!.serverClose(1006, "vm suspended"); + yield* settlePump; + + // The blip fired the retry surface instead of killing the pump, and + // the following attempt re-attached. + expect(harness.connectRetries).toEqual([ + { consecutiveFailures: 1, detail: expect.stringContaining("socket hang up") }, + ]); + expect(harness.connects).toEqual([1, 2]); + + yield* Fiber.interrupt(fiber); + }), + ); + + it.effect("still fails loudly on a transport-class REST failure BEFORE the first connect", () => + Effect.gen(function* () { + // A misconfigured base URL / dead API must surface at startSession, + // not spin silently: the retry ladder only covers re-attach. + const harness = makeHarness({ + getTask: () => + Effect.fail( + new AetherApiTransportError({ endpoint: "GET /tasks/task-1", detail: "ECONNREFUSED" }), + ), + connectWorkspace: scriptedConnect([runningOutcome]).connectWorkspace, + }); + const error = yield* Effect.flip(runAetherAgentStream(harness.options)); + expect(error._tag).toBe("AetherApiTransportError"); + expect(harness.connectRetries).toEqual([]); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/aether/workspaceSocket.ts b/apps/server/src/provider/Layers/aether/workspaceSocket.ts new file mode 100644 index 000000000000..0d9b37bdc1ea --- /dev/null +++ b/apps/server/src/provider/Layers/aether/workspaceSocket.ts @@ -0,0 +1,690 @@ +/** + * Aether workspace attach + WS transport (build item 5). + * + * Three layers, composed by `runAetherAgentStream`: + * 1. `resolveTaskWorkspace` — poll `GET /tasks/{id}` branching on the + * DISCRIMINATED status. Every variant is handled explicitly and none + * falls through to "keep polling": `processing` proceeds (run_context is + * non-null by construction), `queued` backs off and repolls, + * null-context `awaiting_input` is TERMINAL durable-only (a STABLE + * state — every queued message was cancelled before workspace + * assignment, and nothing creates a workspace until the next /respond, + * which is exactly when the active path re-attaches), `errored` fails + * loudly with the task's error payload, and the unknown-status carrier + * fails loudly (never treated as pending). + * 2. `connectForTransport` — `POST /workspaces/{id}/connect` with + * `start=false` (passive). The connecting variant and the transitional + * 409 retry after `retry_after_ms`; the startable / not_connectable + * 409s mean the workspace is not running — durable-only mode, NEVER a + * VM boot just to view a thread (`start=true` is reserved for + * user-initiated turns, T6). + * 3. The socket loop — wss upgrade with the API key, agent-channel + * subscribe, loose frame parsing (unknown kinds logged once per kind + * and dropped, server error-channel frames surfaced via + * `onFrameDropped`; the socket is never killed by a frame), a + * user-activity keep-alive hook for the T6 turn engine, and a reconnect + * ladder with capped exponential backoff that re-runs the FULL attach + * (statuses change while detached) and triggers durable delta + * reconciliation via `onConnected` on every (re)connect. An attach that + * never reaches subscribe (open failure, or a transport-class REST + * error once connected before) fires `onConnectRetry` so the caller + * surfaces the degradation and drives the REST backstop while the + * ladder keeps retrying. + * + * The returned effect runs until the workspace becomes durable-only or the + * owning scope interrupts it (session stop); the socket is closed by a + * finalizer either way (OpenCodeAdapter startEventPump pattern). + * + * @module provider/Layers/aether/workspaceSocket + */ +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Queue from "effect/Queue"; +import * as Schema from "effect/Schema"; + +import type { AetherRestClient, AetherRestError } from "./restClient.ts"; +import type { AetherTask } from "./restSchemas.ts"; +import { parseAetherAgentFrame, type AetherAgentEvent } from "./wireEvents.ts"; + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +/** The task errored (possibly before workspace assignment) — attach must fail loudly, never poll forever. */ +export class AetherTaskErroredError extends Schema.TaggedErrorClass()( + "AetherTaskErroredError", + { + taskId: Schema.String, + error: Schema.String, + completedAt: Schema.String, + }, +) { + override get message(): string { + return `Aether task '${this.taskId}' errored: ${this.error}`; + } +} + +/** The forward-compat unknown-status carrier — never treated as pending. */ +export class AetherTaskUnknownStatusError extends Schema.TaggedErrorClass()( + "AetherTaskUnknownStatusError", + { + taskId: Schema.String, + rawStatus: Schema.String, + }, +) { + override get message(): string { + return `Aether task '${this.taskId}' reports an unrecognized status '${this.rawStatus}'; refusing to guess whether it is attachable.`; + } +} + +/** The connect handshake never produced a transport within the retry budget. */ +export class AetherWorkspaceConnectTimeoutError extends Schema.TaggedErrorClass()( + "AetherWorkspaceConnectTimeoutError", + { + workspaceId: Schema.String, + attempts: Schema.Number, + }, +) { + override get message(): string { + return `Aether workspace '${this.workspaceId}' stayed in the connecting state after ${this.attempts} attempts.`; + } +} + +/** The WebSocket upgrade did not reach the open state. */ +export class AetherSocketOpenError extends Schema.TaggedErrorClass()( + "AetherSocketOpenError", + { + url: Schema.String, + detail: Schema.String, + }, +) { + override get message(): string { + return `Aether workspace socket failed to open: ${this.detail}`; + } +} + +export type AetherAttachError = + | AetherTaskErroredError + | AetherTaskUnknownStatusError + | AetherWorkspaceConnectTimeoutError + | AetherRestError; + +// --------------------------------------------------------------------------- +// WebSocket seam (injectable for tests; defaults to the Node global) +// --------------------------------------------------------------------------- + +export interface AetherWebSocketLike { + addEventListener(type: "open", listener: () => void): void; + addEventListener(type: "message", listener: (event: { data: unknown }) => void): void; + addEventListener( + type: "close", + listener: (event: { code?: number; reason?: string }) => void, + ): void; + addEventListener(type: "error", listener: (event: unknown) => void): void; + send(data: string): void; + close(code?: number, reason?: string): void; +} + +export type AetherWebSocketFactory = (url: string) => AetherWebSocketLike; + +const defaultWebSocketFactory: AetherWebSocketFactory = (url) => + // Node >= 22 ships a spec-compliant global WebSocket (undici). + new WebSocket(url) as unknown as AetherWebSocketLike; + +/** + * Build the wss URL from the API origin + the connect transport's + * `websocket_path`, carrying the API key as `?token=`. + * + * Auth-form choice: auth.go's `ExtractTokenFromRequest` accepts three forms + * (Authorization header, `Sec-WebSocket-Protocol: bearer, `, and + * `?token=`). The query form is the one Aether's own first-party clients use + * for exactly this socket (packages/workspace-client/src/websocket-url.ts), + * so it is the proven path; the subprotocol form is no more confidential + * (the key leaves the process either way, TLS covers both) and depends on + * the server echoing a subprotocol back for undici to keep the connection. + */ +export function aetherWorkspaceSocketUrl( + apiBaseUrl: string, + websocketPath: string, + apiKey: string, +): string { + if (!websocketPath.startsWith("/") || websocketPath.startsWith("//")) { + throw new Error( + `Workspace websocket path must be a same-origin absolute path: ${websocketPath}`, + ); + } + if (websocketPath.includes("?") || websocketPath.includes("#")) { + throw new Error(`Workspace websocket path must carry no query or fragment: ${websocketPath}`); + } + const base = apiBaseUrl.replace(/\/+$/, ""); + const schemeEnd = base.indexOf("://"); + const scheme = schemeEnd === -1 ? "" : base.slice(0, schemeEnd).toLowerCase(); + const rest = base.slice(schemeEnd + "://".length); + let wsScheme: string; + if (scheme === "https") { + wsScheme = "wss://"; + } else if (scheme === "http") { + wsScheme = "ws://"; + } else { + throw new Error(`Unsupported API base URL scheme for the workspace websocket: ${apiBaseUrl}`); + } + if (rest.length === 0 || rest.startsWith("/")) { + throw new Error(`API base URL has no host: ${apiBaseUrl}`); + } + return `${wsScheme}${rest}${websocketPath}?token=${encodeURIComponent(apiKey)}`; +} + +// --------------------------------------------------------------------------- +// Timing knobs (injectable so tests never sleep real time) +// --------------------------------------------------------------------------- + +export interface AetherStreamTiming { + /** First task-poll backoff step; doubles up to pollMaxMs. */ + readonly pollInitialMs: number; + readonly pollMaxMs: number; + /** First reconnect backoff step; doubles up to reconnectMaxMs. */ + readonly reconnectInitialMs: number; + readonly reconnectMaxMs: number; + /** Budget for one WebSocket open handshake. */ + readonly openTimeoutMs: number; + /** Cap on consecutive connecting/transitional answers before failing loudly. */ + readonly connectMaxAttempts: number; + /** Fallback wait when the server sends no retry_after_ms. */ + readonly connectDefaultRetryMs: number; +} + +const DEFAULT_TIMING: AetherStreamTiming = { + pollInitialMs: 500, + pollMaxMs: 10_000, + reconnectInitialMs: 1_000, + reconnectMaxMs: 30_000, + openTimeoutMs: 15_000, + connectMaxAttempts: 60, + connectDefaultRetryMs: 1_000, +}; + +const backoffMs = (initialMs: number, maxMs: number, attempt: number): number => + Math.min(maxMs, initialMs * 2 ** Math.min(attempt, 30)); + +// --------------------------------------------------------------------------- +// 1. Task → workspace resolution +// --------------------------------------------------------------------------- + +export type AetherTaskWorkspaceResolution = + /** The task has an execution context — connect against this workspace. */ + | { readonly _tag: "workspace"; readonly workspaceId: string; readonly task: AetherTask } + /** + * Null-context awaiting_input: STABLE, not transient. Zero further polls — + * reattach rides the next /respond (spec resolved note 19). + */ + | { readonly _tag: "parked"; readonly task: AetherTask }; + +export const resolveTaskWorkspace = Effect.fn("resolveTaskWorkspace")(function* (options: { + readonly getTask: AetherRestClient["getTask"]; + readonly taskId: string; + readonly timing?: Partial; +}): Effect.fn.Return< + AetherTaskWorkspaceResolution, + AetherTaskErroredError | AetherTaskUnknownStatusError | AetherRestError +> { + const timing = { ...DEFAULT_TIMING, ...options.timing }; + for (let attempt = 0; ; attempt++) { + const task = yield* options.getTask(options.taskId); + switch (task.status) { + case "processing": + // run_context is non-null by construction on this variant. + return { _tag: "workspace", workspaceId: task.run_context.workspace_id, task } as const; + case "queued": + // Assignment is coming (a message is queued); poll with backoff. + // A queued task that already reports a run_context is still not + // attachable-for-processing — wait for the dispatcher to flip it. + yield* Effect.sleep( + Duration.millis(backoffMs(timing.pollInitialMs, timing.pollMaxMs, attempt)), + ); + continue; + case "awaiting_input": + if (task.run_context === null) { + return { _tag: "parked", task } as const; + } + // Parked on input but a workspace exists (it may be suspended) — + // the passive connect decides live vs durable-only. + return { _tag: "workspace", workspaceId: task.run_context.workspace_id, task } as const; + case "errored": + return yield* new AetherTaskErroredError({ + taskId: options.taskId, + error: task.error, + completedAt: task.completed_at, + }); + case "unknown-status": + return yield* new AetherTaskUnknownStatusError({ + taskId: options.taskId, + rawStatus: task.rawStatus, + }); + } + } +}); + +// --------------------------------------------------------------------------- +// 2. Connect → transport +// --------------------------------------------------------------------------- + +export type AetherTransportResolution = + | { + readonly _tag: "transport"; + readonly websocketPath: string; + readonly previewToken: string; + } + /** Not running and this attach may not start it — durable-only mode. */ + | { readonly _tag: "unavailable"; readonly reason: string }; + +export const connectForTransport = Effect.fn("connectForTransport")(function* (options: { + readonly connectWorkspace: AetherRestClient["connectWorkspace"]; + readonly workspaceId: string; + /** `true` ONLY on a user-initiated turn (T6); passive attach is `false`. */ + readonly start: boolean; + readonly timing?: Partial; +}): Effect.fn.Return< + AetherTransportResolution, + AetherWorkspaceConnectTimeoutError | AetherRestError +> { + const timing = { ...DEFAULT_TIMING, ...options.timing }; + for (let attempt = 1; attempt <= timing.connectMaxAttempts; attempt++) { + const outcome = yield* options.connectWorkspace(options.workspaceId, { start: options.start }); + switch (outcome.state) { + case "running": + return { + _tag: "transport", + websocketPath: outcome.transport.websocket_path, + previewToken: outcome.transport.preview_token, + } as const; + case "connecting": + yield* Effect.sleep(Duration.millis(outcome.retry_after_ms)); + continue; + case "conflict": + switch (outcome.conflict.kind) { + case "transitional": + // A lifecycle operation is settling; the same request answers + // differently once it finishes. + yield* Effect.sleep(Duration.millis(outcome.conflict.retry_after_ms)); + continue; + case "startable": + // A start WOULD start a VM — exactly what a passive attach must + // never do (viewing never boots a workspace). + return { + _tag: "unavailable", + reason: `workspace is not running (startable): ${outcome.conflict.error}`, + } as const; + case "not_connectable": + return { + _tag: "unavailable", + reason: `workspace is ${outcome.conflict.display_state}: ${outcome.conflict.error}`, + } as const; + } + } + } + return yield* new AetherWorkspaceConnectTimeoutError({ + workspaceId: options.workspaceId, + attempts: timing.connectMaxAttempts, + }); +}); + +// --------------------------------------------------------------------------- +// 3. Socket loop +// --------------------------------------------------------------------------- + +/** Live-connection handle handed to `onConnected`. */ +export interface AetherAgentConnection { + /** + * Send one `user_activity` keep-alive ping. The T6 turn engine drives this + * (throttled to ACTIVITY_PING_THROTTLE_MS) while a turn is active so the + * VM's interactive idle hold stays alive; nothing calls it yet. + */ + readonly sendUserActivity: () => Effect.Effect; +} + +export interface AetherAgentStreamOptions { + readonly restClient: Pick; + readonly apiBaseUrl: string; + readonly apiKey: string; + readonly taskId: string; + readonly webSocketFactory?: AetherWebSocketFactory; + readonly timing?: Partial; + /** + * Fires after every successful attach+subscribe, BEFORE live frames are + * handled — drive the conversation/delta reconciliation from the resume + * cursor here (the ONLY recovery for live-only turn.* events missed while + * detached). + */ + readonly onConnected: (connection: AetherAgentConnection) => Effect.Effect; + /** One parsed agent event. */ + readonly onEvent: (event: AetherAgentEvent) => Effect.Effect; + /** + * A dropped frame (unknown kind, malformed known kind, or a server + * error-channel frame). Called once per distinct key per stream — the + * caller logs / warns; the socket lives on. + */ + readonly onFrameDropped: (problem: { + readonly key: string; + readonly detail: string; + }) => Effect.Effect; + /** + * One (re)connect attempt failed before reaching subscribe: the WS open + * failed, or (after the stream has connected at least once) a + * transport-class REST error hit the re-attach. The loop keeps retrying + * with backoff; while it does, THIS callback is the only beat on which the + * caller can surface the degradation and advance the transcript from the + * durable feed (spec §3.11 REST-delta-only degrade — `onConnected`, the + * normal reconcile trigger, never fires while opens keep failing). + */ + readonly onConnectRetry: (failure: { + /** Consecutive failed attempts since the last successful subscribe. */ + readonly consecutiveFailures: number; + readonly detail: string; + }) => Effect.Effect; + /** + * The stream settled into durable-only mode (parked task or not-running + * workspace). Terminal for this attach: the next sendTurn re-attaches. + */ + readonly onDurableOnly: (reason: string) => Effect.Effect; +} + +type SocketSignal = + | { readonly _tag: "message"; readonly data: string } + | { readonly _tag: "closed"; readonly code: number; readonly reason: string }; + +// Outbound client messages, encoded through the schema JSON codec (the wire +// twins are AgentSubscribeMessageSchema / UserActivityMessageSchema in +// aether's workspace-protocol). +const encodeSubscribeMessage = Schema.encodeSync( + Schema.fromJsonString( + Schema.Struct({ + channel: Schema.Literal("agent"), + type: Schema.Literal("subscribe"), + taskId: Schema.String, + }), + ), +); +const encodeUserActivityMessage = Schema.encodeSync( + Schema.fromJsonString( + Schema.Struct({ + channel: Schema.Literal("activity"), + type: Schema.Literal("user_activity"), + }), + ), +); + +const openSocket = ( + factory: AetherWebSocketFactory, + url: string, + openTimeoutMs: number, +): Effect.Effect< + { readonly socket: AetherWebSocketLike; readonly signals: Queue.Queue }, + AetherSocketOpenError +> => + Effect.gen(function* () { + const socket = factory(url); + const signals = yield* Queue.unbounded(); + // Listeners registered before the open await so no frame can slip + // between open and subscription. offerUnsafe: listener callbacks are + // synchronous, and an unbounded queue cannot reject. + socket.addEventListener("message", (event) => { + Queue.offerUnsafe(signals, { + _tag: "message", + data: typeof event.data === "string" ? event.data : String(event.data), + }); + }); + socket.addEventListener("close", (event) => { + Queue.offerUnsafe(signals, { + _tag: "closed", + code: event.code ?? 0, + reason: event.reason ?? "", + }); + }); + + const awaitOpen = Effect.callback((resume) => { + let settled = false; + const settle = (effect: Effect.Effect) => { + if (!settled) { + settled = true; + resume(effect); + } + }; + socket.addEventListener("open", () => settle(Effect.void)); + socket.addEventListener("error", () => + settle( + Effect.fail(new AetherSocketOpenError({ url, detail: "socket errored before opening" })), + ), + ); + socket.addEventListener("close", (event) => + settle( + Effect.fail( + new AetherSocketOpenError({ + url, + detail: `socket closed before opening (code ${event.code ?? 0})`, + }), + ), + ), + ); + }); + yield* awaitOpen.pipe( + Effect.timeout(Duration.millis(openTimeoutMs)), + Effect.catchTag("TimeoutError", () => + Effect.fail( + new AetherSocketOpenError({ url, detail: `open timed out after ${openTimeoutMs}ms` }), + ), + ), + // EVERY pre-open exit must close the raw socket itself: the + // acquireRelease finalizer only registers after open succeeds, and the + // reconnect ladder retries open failures indefinitely — an upgrade + // error/close/timeout that skipped this close would leak one socket + // per attempt. close() is idempotent, so overlap with the close + // listener is harmless. + Effect.tapError(() => Effect.sync(() => socket.close())), + Effect.onInterrupt(() => Effect.sync(() => socket.close())), + ); + + return { socket, signals }; + }); + +/** + * The full attach → subscribe → pump → reconnect loop. Runs until + * durable-only mode or interruption (session scope close). Typed failures + * (task errored, unknown status, connect budget exhausted, REST auth/…) + * propagate — the caller decides how to surface them. + */ +export const runAetherAgentStream = Effect.fn("runAetherAgentStream")(function* ( + options: AetherAgentStreamOptions, +): Effect.fn.Return { + const timing = { ...DEFAULT_TIMING, ...options.timing }; + const factory = options.webSocketFactory ?? defaultWebSocketFactory; + const droppedKeys = new Set(); + let reconnectAttempt = 0; + let consecutiveFailures = 0; + let everConnected = false; + + while (true) { + // Re-resolve the FULL attach every iteration: task status and workspace + // state both change while detached, and a stale workspace id would + // reconnect to a torn-down VM. + const attach = Effect.gen(function* () { + const resolution = yield* resolveTaskWorkspace({ + getTask: options.restClient.getTask, + taskId: options.taskId, + timing, + }); + if (resolution._tag === "parked") { + return { _tag: "parked" } as const; + } + const transport = yield* connectForTransport({ + connectWorkspace: options.restClient.connectWorkspace, + workspaceId: resolution.workspaceId, + start: false, + timing, + }); + if (transport._tag === "unavailable") { + return { _tag: "unavailable", reason: transport.reason } as const; + } + return { _tag: "transport", websocketPath: transport.websocketPath } as const; + }); + + // Once the stream has subscribed at least once, a transport-class REST + // failure during re-attach (network blip, 5xx — often the very outage + // that dropped the socket) re-enters the backoff ladder instead of + // killing the pump for the rest of the session. Everything else (auth, + // 404, task errored, unknown status, connect budget) still fails loudly, + // and the FIRST attach fails loudly on any error so a misconfiguration + // surfaces immediately at startSession. + const attached = yield* everConnected + ? attach.pipe( + Effect.catchTag("AetherApiTransportError", (error) => + Effect.succeed({ _tag: "retry", detail: error.message } as const), + ), + ) + : attach; + + if (attached._tag === "parked") { + yield* options.onDurableOnly( + "task is awaiting input with no workspace (all queued messages were cancelled); the next response re-attaches", + ); + return; + } + if (attached._tag === "unavailable") { + yield* options.onDurableOnly(attached.reason); + return; + } + + const pumped = + attached._tag === "retry" + ? attached + : yield* Effect.scoped( + Effect.gen(function* () { + const url = aetherWorkspaceSocketUrl( + options.apiBaseUrl, + attached.websocketPath, + options.apiKey, + ); + const opened = yield* Effect.acquireRelease( + openSocket(factory, url, timing.openTimeoutMs), + ({ socket }) => Effect.sync(() => socket.close()), + ); + opened.socket.send( + encodeSubscribeMessage({ + channel: "agent", + type: "subscribe", + taskId: options.taskId, + }), + ); + reconnectAttempt = 0; + consecutiveFailures = 0; + everConnected = true; + yield* options.onConnected({ + sendUserActivity: () => + Effect.sync(() => + opened.socket.send( + encodeUserActivityMessage({ channel: "activity", type: "user_activity" }), + ), + ), + }); + + while (true) { + const signal = yield* Queue.take(opened.signals); + if (signal._tag === "closed") { + return signal; + } + const parsed = parseAetherAgentFrame(signal.data); + switch (parsed._tag) { + case "event": { + // The socket is workspace-scoped and frames carry their + // own task id: a stale frame after reconnect (or a future + // multiplexing change) must never be stamped with this + // session's task and pollute the thread. + if (parsed.event.taskId !== options.taskId) { + const key = `cross-task:${parsed.event.taskId}`; + if (!droppedKeys.has(key)) { + droppedKeys.add(key); + yield* options.onFrameDropped({ + key, + detail: `Dropped a frame for task '${parsed.event.taskId}' on the socket subscribed to '${options.taskId}'.`, + }); + } + break; + } + yield* options.onEvent(parsed.event); + break; + } + case "ignored": + // Another channel multiplexed on the same socket — not ours. + break; + case "server-error": { + // The workspace-service reporting its own failure (e.g. + // strict-parse rejection of our subscribe under protocol + // skew) — without this a rejected subscribe leaves a + // connected-but-mute socket with zero diagnostics. + const key = `server-error:${parsed.detail}`; + if (!droppedKeys.has(key)) { + droppedKeys.add(key); + yield* options.onFrameDropped({ + key, + detail: `Aether workspace server reported an error over the socket: ${parsed.detail}`, + }); + } + break; + } + case "unknown-kind": { + const key = `unknown-kind:${parsed.kind}`; + if (!droppedKeys.has(key)) { + droppedKeys.add(key); + yield* options.onFrameDropped({ + key, + detail: `Unknown Aether agent event kind '${parsed.kind}' — frame dropped (logged once per kind).`, + }); + } + break; + } + case "malformed": { + const key = `malformed:${parsed.kind ?? "envelope"}`; + if (!droppedKeys.has(key)) { + droppedKeys.add(key); + yield* options.onFrameDropped({ key, detail: parsed.detail }); + } + break; + } + } + } + }), + ).pipe( + // An open failure is a reconnect case, not a stream failure: the + // workspace may have suspended between connect and upgrade. + Effect.catchTag("AetherSocketOpenError", (error) => + Effect.succeed({ _tag: "retry", detail: error.detail } as const), + ), + ); + + if (pumped._tag === "retry") { + // The attach never reached subscribe — onConnected (the reconcile + // trigger) did not fire, so surface the degradation and let the caller + // run the durable backstop from here. The loop keeps retrying forever + // by design: REST-delta-only operation is the mandated degrade (§3.11); + // exactly-once session.exited after an exhausted budget is build item 13. + consecutiveFailures++; + yield* Effect.logWarning("aether.socket.connect-failed", { + taskId: options.taskId, + consecutiveFailures, + detail: pumped.detail, + }); + yield* options.onConnectRetry({ consecutiveFailures, detail: pumped.detail }); + } else { + yield* Effect.logInfo("aether.socket.closed", { + taskId: options.taskId, + code: pumped.code, + reason: pumped.reason, + }); + } + reconnectAttempt++; + yield* Effect.sleep( + Duration.millis( + backoffMs(timing.reconnectInitialMs, timing.reconnectMaxMs, reconnectAttempt - 1), + ), + ); + } +}); From c652e67d89d361a54b85a75c026fe6f0336d2d3b Mon Sep 17 00:00:00 2001 From: Pranav Sharan Date: Sat, 8 Aug 2026 13:42:52 -0700 Subject: [PATCH 06/44] =?UTF-8?q?feat(aether):=20turn=20lifecycle,=20mirro?= =?UTF-8?q?r=20sync=20engine,=20write=20guards=20=E2=80=94=20Aether=20goes?= =?UTF-8?q?=20live=20(#5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(aether): turn lifecycle, mirror sync engine, write guards, gate flip T6 — Aether becomes selectable end to end. sendTurn: create (composite slug + effort validation, base_branch from preflight, turn-1 wire id harvested from the timeline), respond (deterministic epoch-scoped client_message_id), steer (deferred turn.started until pickup, FIFO). interruptTurn: stop with discard, re-offered steer texts, read-side confirmation, interrupted settle through the pipeline. Mirror engine: fingerprint verify (content-tree via temp index; catches edits, untracked files, local commits) → git-channel diff over the session socket → fetch + resolve the diff's own baseRef → reset --hard + clean -fd → reconstructed unified diff apply (modes, renames, no-newline, binary via files read) → only then settle; detached settles skip lazily; pauses are loud, never silent. Acceptance tests run against real temp git repos. Fork-side guards: refcounted mirror registry + ws.ts refusals at all seven dispatch sites, removeWorktree keyed on resolved target (basename bypass covered). The T1 availability gate is removed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * fix(aether): router-before-onConnected, turn-start ordering, binary path guard Review round 1 on T6. The request-response router now drains before onConnected fires (an onConnected reconcile that requests a git diff completes instead of deadlocking — regression test with a hang guard); sendTurn records the turn and emits turn.started before forking the attach pipeline so a fast first settle cannot precede its start; binary diff paths (oldPath removal AND newPath write) are validated repo-relative — absolute paths, '..' segments, and resolved escapes pause loudly and touch nothing outside the mirror, pinned by sentinel tests. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * fix(aether): idempotent first-turn retry; validate binary paths before mutating Review round 2 on T6. A createTask that succeeded but failed its turn-1 harvest now leaves the session in an explicit firstTurnPending state: the retry re-enters the first-turn path (no second create, no respond — the prompt can never double-send), a different-text retry refuses loudly, and bring-up completion clears the flag. Binary diff application is two-phase: every oldPath/newPath in the batch validates repo-relative BEFORE any removal or write, so a rename with a safe oldPath and an escaping newPath refuses with the mirror untouched. Both pinned by tests. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * fix(aether): full dispatch fingerprint on first-turn retry; refuse .git paths Review round 3 on T6. The pending-first-turn guard now fingerprints every dispatch-relevant input (prompt, resolved slug, effort, interaction mode, attachment payloads; length-prefixed control-char join — compared, never parsed) so a same-text retry with changed attachments or model refuses instead of silently proceeding. The binary path validator additionally refuses any '.git' segment — direct writes bypass git's refusal to track such paths, and .git/hooks would be code execution on the next git invocation; both cases pinned in the escape test matrix. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * fix(aether): writeFile guard checks the resolved target, not just cwd Review round 4 on T6: projects.writeFile resolves relativePath under cwd, so a parent-project cwd could descend into an active mirror without owning it. New ownsPathWithin containment check (at-or-under any claim) guards the resolved target; prefix-sharing neighbours and siblings stay writable, pinned by tests. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h --------- Co-authored-by: Claude Fable 5 --- .../src/provider/AetherMirrorGuards.test.ts | 90 ++ .../server/src/provider/AetherMirrorGuards.ts | 83 ++ .../src/provider/AetherMirrorRegistry.test.ts | 86 ++ .../src/provider/AetherMirrorRegistry.ts | 118 ++ .../src/provider/Drivers/AetherDriver.ts | 7 + .../src/provider/Layers/AetherAdapter.test.ts | 1078 +++++++++++++- .../src/provider/Layers/AetherAdapter.ts | 1234 +++++++++++++++-- .../provider/Layers/AetherProvider.test.ts | 51 +- .../src/provider/Layers/AetherProvider.ts | 89 +- .../provider/Layers/ProviderRegistry.test.ts | 6 + .../Layers/aether/eventMapper.test.ts | 40 + .../src/provider/Layers/aether/eventMapper.ts | 47 +- .../provider/Layers/aether/mirrorSync.test.ts | 842 +++++++++++ .../src/provider/Layers/aether/mirrorSync.ts | 741 ++++++++++ .../src/provider/Layers/aether/wireEvents.ts | 148 ++ .../Layers/aether/workspaceSocket.test.ts | 278 ++++ .../provider/Layers/aether/workspaceSocket.ts | 270 +++- apps/server/src/server.test.ts | 21 +- apps/server/src/server.ts | 9 +- apps/server/src/ws.ts | 147 +- apps/web/src/session-logic.ts | 8 +- packages/contracts/src/project.ts | 2 + 22 files changed, 5118 insertions(+), 277 deletions(-) create mode 100644 apps/server/src/provider/AetherMirrorGuards.test.ts create mode 100644 apps/server/src/provider/AetherMirrorGuards.ts create mode 100644 apps/server/src/provider/AetherMirrorRegistry.test.ts create mode 100644 apps/server/src/provider/AetherMirrorRegistry.ts create mode 100644 apps/server/src/provider/Layers/aether/mirrorSync.test.ts create mode 100644 apps/server/src/provider/Layers/aether/mirrorSync.ts diff --git a/apps/server/src/provider/AetherMirrorGuards.test.ts b/apps/server/src/provider/AetherMirrorGuards.test.ts new file mode 100644 index 000000000000..e438d51f02bd --- /dev/null +++ b/apps/server/src/provider/AetherMirrorGuards.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { + AETHER_MIRROR_REFUSAL, + aetherMirrorWriteFileError, + guardAetherRemoveWorktree, + guardAetherVcsMutation, +} from "./AetherMirrorGuards.ts"; +import { make } from "./AetherMirrorRegistry.ts"; + +describe("AetherMirrorGuards", () => { + it.effect("guardAetherVcsMutation refuses only while a thread owns the cwd", () => + Effect.gen(function* () { + const registry = yield* make; + yield* registry.register("/repos/mirror", "aether:thread-1"); + + const refused = yield* Effect.flip( + guardAetherVcsMutation(registry, "vcs.pull", "/repos/mirror", Effect.succeed("ran")), + ); + expect(refused._tag).toBe("GitCommandError"); + expect(refused.detail).toBe(AETHER_MIRROR_REFUSAL); + + expect( + yield* guardAetherVcsMutation(registry, "vcs.pull", "/repos/other", Effect.succeed("ran")), + ).toBe("ran"); + + yield* registry.deregister("/repos/mirror", "aether:thread-1"); + expect( + yield* guardAetherVcsMutation(registry, "vcs.pull", "/repos/mirror", Effect.succeed("ran")), + ).toBe("ran"); + }), + ); + + it.effect( + "guardAetherRemoveWorktree refuses a parent-repo cwd targeting the mirror (spec note 20)", + () => + Effect.gen(function* () { + const registry = yield* make; + yield* registry.register("/repos/parent/.worktrees/aether-mirror", "aether:thread-1"); + + // The bypass shape: cwd is the ORDINARY parent repo, the target is + // the active mirror — by relative path, by absolute path, and by the + // bare unique basename `git worktree remove` also accepts. + for (const path of [ + ".worktrees/aether-mirror", + "/repos/parent/.worktrees/aether-mirror", + "aether-mirror", + ]) { + const refused = yield* Effect.flip( + guardAetherRemoveWorktree( + registry, + { cwd: "/repos/parent", path }, + Effect.succeed("removed"), + ), + ); + expect(refused._tag).toBe("GitCommandError"); + expect(refused.detail).toContain("active Aether cloud-session mirror"); + } + + // The mirror's OWN cwd is refused even for an unrelated target. + const cwdRefused = yield* Effect.flip( + guardAetherRemoveWorktree( + registry, + { cwd: "/repos/parent/.worktrees/aether-mirror", path: ".worktrees/other" }, + Effect.succeed("removed"), + ), + ); + expect(cwdRefused.detail).toContain(AETHER_MIRROR_REFUSAL); + + // A sibling worktree from an unowned cwd stays removable. + expect( + yield* guardAetherRemoveWorktree( + registry, + { cwd: "/repos/parent", path: ".worktrees/other" }, + Effect.succeed("removed"), + ), + ).toBe("removed"); + }), + ); + + it("aetherMirrorWriteFileError carries the refusal as its message", () => { + const error = aetherMirrorWriteFileError({ cwd: "/repos/mirror", relativePath: "src/a.ts" }); + expect(error._tag).toBe("ProjectWriteFileError"); + expect(error.failure).toBe("operation_failed"); + // Pins decodedProjectErrorMessage honoring the caller-provided message — + // the web UI renders exactly this text. + expect(error.message).toBe(AETHER_MIRROR_REFUSAL); + }); +}); diff --git a/apps/server/src/provider/AetherMirrorGuards.ts b/apps/server/src/provider/AetherMirrorGuards.ts new file mode 100644 index 000000000000..b7b00be37734 --- /dev/null +++ b/apps/server/src/provider/AetherMirrorGuards.ts @@ -0,0 +1,83 @@ +/** + * AetherMirrorGuards — the refusal logic behind the Aether cloud-session + * write guard's ws.ts dispatch sites (spec build item 8a). + * + * While an Aether thread owns a cwd, that checkout is a one-way mirror of + * the cloud VM: local writes never reach the VM and silently break the next + * turn's reset-and-apply sync. The guarded RPCs dispatch straight into + * workspaceFileSystem/gitWorkflow (they never cross ProviderAdapter), so the + * refusal lives at the dispatch sites — extracted here so the guard + * behavior, including the removeWorktree parent-cwd bypass (spec resolved + * note 20) and the typed writeFile refusal, is testable against a real + * registry instead of only readable in ws.ts. + * + * @module provider/AetherMirrorGuards + */ +import { GitCommandError, ProjectWriteFileError } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +/** The ownership queries the guards need — structurally AetherMirrorRegistry. */ +export interface AetherMirrorOwnership { + readonly ownsCwd: (cwd: string) => Effect.Effect; + readonly ownsTargetPath: (cwd: string, target: string) => Effect.Effect; +} + +export const AETHER_MIRROR_REFUSAL = + "This checkout is mirrored from an Aether cloud task. Local saves, commits, pulls and branch changes are unavailable while the cloud session is active — changes flow one way, from the cloud workspace into this checkout."; + +/** Refuse a cwd-scoped VCS mutation while an Aether thread owns the cwd. */ +export const guardAetherVcsMutation = ( + registry: AetherMirrorOwnership, + operation: string, + cwd: string, + effect: Effect.Effect, +): Effect.Effect => + Effect.gen(function* () { + if (yield* registry.ownsCwd(cwd)) { + return yield* new GitCommandError({ + operation, + command: "", + cwd, + detail: AETHER_MIRROR_REFUSAL, + }); + } + return yield* effect; + }); + +/** + * removeWorktree is DESTRUCTIVE and takes `{cwd, path}`: guard the resolved + * TARGET too, so a parent-repo cwd cannot delete an active mirror (spec + * resolved note 20). + */ +export const guardAetherRemoveWorktree = ( + registry: AetherMirrorOwnership, + input: { readonly cwd: string; readonly path: string }, + effect: Effect.Effect, +): Effect.Effect => + Effect.gen(function* () { + const ownsCwd = yield* registry.ownsCwd(input.cwd); + const ownsTarget = yield* registry.ownsTargetPath(input.cwd, input.path); + if (ownsCwd || ownsTarget) { + return yield* new GitCommandError({ + operation: "vcs.removeWorktree", + command: "", + cwd: input.cwd, + detail: ownsTarget + ? `The target worktree is an active Aether cloud-session mirror and cannot be removed mid-thread. ${AETHER_MIRROR_REFUSAL}` + : AETHER_MIRROR_REFUSAL, + }); + } + return yield* effect; + }); + +/** The typed `projects.writeFile` refusal (fully type-checked construction). */ +export const aetherMirrorWriteFileError = (input: { + readonly cwd: string; + readonly relativePath: string; +}): ProjectWriteFileError => + new ProjectWriteFileError({ + cwd: input.cwd, + relativePath: input.relativePath, + failure: "operation_failed", + message: AETHER_MIRROR_REFUSAL, + }); diff --git a/apps/server/src/provider/AetherMirrorRegistry.test.ts b/apps/server/src/provider/AetherMirrorRegistry.test.ts new file mode 100644 index 000000000000..12910eb4ecdc --- /dev/null +++ b/apps/server/src/provider/AetherMirrorRegistry.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { make } from "./AetherMirrorRegistry.ts"; + +describe("AetherMirrorRegistry", () => { + it.effect("owns a cwd only while at least one claim is registered", () => + Effect.gen(function* () { + const registry = yield* make; + expect(yield* registry.ownsCwd("/repos/mirror")).toBe(false); + + yield* registry.register("/repos/mirror", "aether:thread-1"); + yield* registry.register("/repos/mirror", "aether:thread-2"); + expect(yield* registry.ownsCwd("/repos/mirror")).toBe(true); + // Normalization: trailing slashes and dot segments hit the same claim. + expect(yield* registry.ownsCwd("/repos/mirror/")).toBe(true); + expect(yield* registry.ownsCwd("/repos/other/../mirror")).toBe(true); + + yield* registry.deregister("/repos/mirror", "aether:thread-1"); + expect(yield* registry.ownsCwd("/repos/mirror")).toBe(true); + yield* registry.deregister("/repos/mirror", "aether:thread-2"); + expect(yield* registry.ownsCwd("/repos/mirror")).toBe(false); + }), + ); + + it.effect("deregistering an unknown claim is a no-op, never an error", () => + Effect.gen(function* () { + const registry = yield* make; + yield* registry.deregister("/repos/never-registered", "aether:thread-9"); + expect(yield* registry.ownsCwd("/repos/never-registered")).toBe(false); + }), + ); + + it.effect("removeWorktree bypass: a parent-repo cwd cannot hide a mirror target", () => + Effect.gen(function* () { + const registry = yield* make; + yield* registry.register("/repos/parent/.worktrees/aether-mirror", "aether:thread-1"); + + // The dangerous call shape: cwd = ordinary parent repo, target path = + // the active mirror (relative or absolute) — must be recognized. + expect(yield* registry.ownsCwd("/repos/parent")).toBe(false); + expect(yield* registry.ownsTargetPath("/repos/parent", ".worktrees/aether-mirror")).toBe( + true, + ); + expect( + yield* registry.ownsTargetPath("/repos/parent", "/repos/parent/.worktrees/aether-mirror"), + ).toBe(true); + // git identifies a worktree by a UNIQUE last path component too: + // `git worktree remove aether-mirror` from the parent deletes the + // mirror even though the resolved path never matches the claim. + expect(yield* registry.ownsTargetPath("/repos/parent", "aether-mirror")).toBe(true); + expect(yield* registry.ownsTargetPath("/somewhere/else", "aether-mirror")).toBe(true); + // A sibling worktree stays removable. + expect(yield* registry.ownsTargetPath("/repos/parent", ".worktrees/other")).toBe(false); + }), + ); + + it.effect("writeFile bypass: a parent-repo cwd cannot descend INTO a mirror", () => + Effect.gen(function* () { + const registry = yield* make; + yield* registry.register("/repos/parent/.worktrees/aether-mirror", "aether:thread-1"); + + // projects.writeFile resolves relativePath under cwd — a parent cwd + // reaching a file inside the mirror must be recognized as within it. + expect( + yield* registry.ownsPathWithin("/repos/parent", ".worktrees/aether-mirror/app.ts"), + ).toBe(true); + expect( + yield* registry.ownsPathWithin( + "/somewhere/else", + "/repos/parent/.worktrees/aether-mirror/deep/nested.ts", + ), + ).toBe(true); + // The mirror root itself counts; writes from the mirror cwd stay refused. + expect( + yield* registry.ownsPathWithin("/repos/parent/.worktrees/aether-mirror", "app.ts"), + ).toBe(true); + // Neighbours are untouched: a sibling file, and a path whose name + // merely SHARES the mirror's prefix, both stay writable. + expect(yield* registry.ownsPathWithin("/repos/parent", "src/app.ts")).toBe(false); + expect( + yield* registry.ownsPathWithin("/repos/parent", ".worktrees/aether-mirror-notes.md"), + ).toBe(false); + }), + ); +}); diff --git a/apps/server/src/provider/AetherMirrorRegistry.ts b/apps/server/src/provider/AetherMirrorRegistry.ts new file mode 100644 index 000000000000..d558fb00256b --- /dev/null +++ b/apps/server/src/provider/AetherMirrorRegistry.ts @@ -0,0 +1,118 @@ +/** + * AetherMirrorRegistry — the server-side ownership registry behind the + * fork-side cloud-session write guard (spec build item 8a). + * + * While an Aether thread runs, its local checkout is a driver-owned one-way + * mirror of the cloud VM. Local writes never reach the VM and silently break + * the next reset-and-apply, so the mutating RPC dispatch sites in `ws.ts` + * (`projects.writeFile`, `git.runStackedAction`, `vcs.pull`/`createWorktree`/ + * `removeWorktree`/`createRef`/`switchRef`) refuse with a typed error while a + * registered mirror owns the cwd. `vcs.removeWorktree` is ADDITIONALLY keyed + * on its resolved TARGET path — its `{cwd, path}` input lets a caller pass + * the parent repository as cwd and the active mirror as target, and a + * cwd-only key would leave the mirror deletable mid-thread (spec resolved + * note 20). + * + * The AetherAdapter registers each session's cwd at startSession and + * deregisters on disconnect AND adapter teardown, keyed per + * (instance, thread) so overlapping registrations refcount instead of + * clobbering each other. + * + * @module provider/AetherMirrorRegistry + */ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; + +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; + +const normalize = (cwd: string): string => NodePath.resolve(cwd); + +export class AetherMirrorRegistry extends Context.Service< + AetherMirrorRegistry, + { + /** Claim `cwd` for an Aether thread (key = instance:thread). */ + readonly register: (cwd: string, key: string) => Effect.Effect; + /** Release one claim; the cwd unlocks when its last claim goes. */ + readonly deregister: (cwd: string, key: string) => Effect.Effect; + /** Does any active Aether thread own this cwd? */ + readonly ownsCwd: (cwd: string) => Effect.Effect; + /** + * Does `target` (resolved against `cwd` when relative) name an active + * mirror? The removeWorktree guard: a parent-repo cwd must not bypass. + */ + readonly ownsTargetPath: (cwd: string, target: string) => Effect.Effect; + /** + * Does `target` (resolved against `cwd` when relative) sit AT or UNDER + * an active mirror? The file-write guard: `projects.writeFile` resolves + * `relativePath` under its `cwd`, so a PARENT project cwd can descend + * into a mirror (`cwd=/repo, relativePath=.worktrees/mirror/app.ts`) + * without ever owning the cwd itself. + */ + readonly ownsPathWithin: (cwd: string, target: string) => Effect.Effect; + } +>()("t3/provider/AetherMirrorRegistry") {} + +export const make = Effect.sync(() => { + const claims = new Map>(); + return AetherMirrorRegistry.of({ + register: (cwd, key) => + Effect.sync(() => { + const normalized = normalize(cwd); + const keys = claims.get(normalized) ?? new Set(); + keys.add(key); + claims.set(normalized, keys); + }), + deregister: (cwd, key) => + Effect.sync(() => { + const normalized = normalize(cwd); + const keys = claims.get(normalized); + if (keys === undefined) { + return; + } + keys.delete(key); + if (keys.size === 0) { + claims.delete(normalized); + } + }), + ownsCwd: (cwd) => Effect.sync(() => claims.has(normalize(cwd))), + ownsTargetPath: (cwd, target) => + Effect.sync(() => { + const resolved = NodePath.isAbsolute(target) + ? normalize(target) + : normalize(NodePath.join(cwd, target)); + if (claims.has(resolved)) { + return true; + } + // `git worktree remove` also accepts a bare UNIQUE last path + // component: `{cwd: parent, path: "aether-mirror"}` deletes + // `parent/.worktrees/aether-mirror` even though the resolved path + // never matches the claim. Refuse on a basename match against any + // active mirror too — over-refusal is a loud, recoverable + // inconvenience; deleting a live mirror mid-thread is not (spec + // resolved note 20). + const targetBasename = NodePath.basename(resolved); + for (const claim of claims.keys()) { + if (NodePath.basename(claim) === targetBasename) { + return true; + } + } + return false; + }), + ownsPathWithin: (cwd, target) => + Effect.sync(() => { + const resolved = NodePath.isAbsolute(target) + ? normalize(target) + : normalize(NodePath.join(cwd, target)); + for (const claim of claims.keys()) { + if (resolved === claim || resolved.startsWith(claim + NodePath.sep)) { + return true; + } + } + return false; + }), + }); +}); + +export const layer = Layer.effect(AetherMirrorRegistry, make); diff --git a/apps/server/src/provider/Drivers/AetherDriver.ts b/apps/server/src/provider/Drivers/AetherDriver.ts index ecdf041548ea..d18d17b3c13d 100644 --- a/apps/server/src/provider/Drivers/AetherDriver.ts +++ b/apps/server/src/provider/Drivers/AetherDriver.ts @@ -14,6 +14,7 @@ import { AetherSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Schema from "effect/Schema"; import { HttpClient } from "effect/unstable/http"; @@ -22,6 +23,7 @@ import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { makeAetherTextGeneration } from "../../textGeneration/AetherTextGeneration.ts"; import { GitVcsDriver } from "../../vcs/GitVcsDriver.ts"; +import { AetherMirrorRegistry } from "../AetherMirrorRegistry.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeAetherAdapter } from "../Layers/AetherAdapter.ts"; import { makeAetherRestClient } from "../Layers/aether/restClient.ts"; @@ -56,8 +58,10 @@ const MAINTENANCE = makeManualOnlyProviderMaintenanceCapabilities({ }); export type AetherDriverEnv = + | AetherMirrorRegistry | BackgroundPolicy.BackgroundPolicy | Crypto.Crypto + | FileSystem.FileSystem | GitVcsDriver | HttpClient.HttpClient | ServerConfig @@ -118,10 +122,13 @@ export const AetherDriver: ProviderDriver = { apiKey, httpClient, }); + const mirrorRegistry = yield* AetherMirrorRegistry; const adapter = yield* makeAetherAdapter({ instanceId, defaultCwd: serverConfig.cwd, git: gitVcsDriver, + attachmentsDir: serverConfig.attachmentsDir, + mirrorRegistry, restClient, socket: apiKey === undefined ? undefined : { apiBaseUrl: effectiveConfig.apiBaseUrl, apiKey }, diff --git a/apps/server/src/provider/Layers/AetherAdapter.test.ts b/apps/server/src/provider/Layers/AetherAdapter.test.ts index ace97c08d520..e095eb10da27 100644 --- a/apps/server/src/provider/Layers/AetherAdapter.test.ts +++ b/apps/server/src/provider/Layers/AetherAdapter.test.ts @@ -1,3 +1,4 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; import { ProviderInstanceId, ThreadId, type ProviderRuntimeEvent } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; @@ -6,17 +7,24 @@ import * as Fiber from "effect/Fiber"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; +import type { ChildProcessSpawner } from "effect/unstable/process"; -import type { GitStatusDetails } from "../../vcs/GitVcsDriver.ts"; +import type { ExecuteGitResult, GitStatusDetails } from "../../vcs/GitVcsDriver.ts"; import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; import type { ProviderAdapterError } from "../Errors.ts"; import { makeAetherAdapter, parseAetherResume, type AetherAdapterSocketOptions, + type AetherMirrorRegistration, type AetherSessionGit, + type AetherTurnTiming, } from "./AetherAdapter.ts"; -import { AetherApiNotFoundError, type AetherRestClient } from "./aether/restClient.ts"; +import { + AetherApiNotFoundError, + AetherApiTransportError, + type AetherRestClient, +} from "./aether/restClient.ts"; import type { AetherConversationDelta, AetherProject, @@ -47,14 +55,54 @@ const cleanStatus: GitStatusDetails = { aheadOfDefaultCount: 0, }; +const gitResult = (stdout: string): ExecuteGitResult => ({ + exitCode: 0 as ChildProcessSpawner.ExitCode, + stdout, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, +}); + +/** + * Canned git executor: a stable HEAD ("headsha") whose content tree is + * "treesha", so the mirror engine's fingerprint verify passes ("clean tree + * at the baseline HEAD") and reset/clean/apply succeed silently. + */ +const fakeGitExecute: AetherSessionGit["execute"] = ({ args }) => { + const first = args[0]; + const last = args[args.length - 1] ?? ""; + if (first === "rev-parse") { + return Effect.succeed(gitResult(last.endsWith("^{tree}") ? "treesha" : "headsha")); + } + if (first === "write-tree") { + return Effect.succeed(gitResult("treesha")); + } + return Effect.succeed(gitResult("")); +}; + const gitWith = ( status: GitStatusDetails, originUrl: string | null = "git@github.com:acme/aether.git", ): AetherSessionGit => ({ statusDetails: () => Effect.succeed(status), readConfigValue: (_cwd, key) => Effect.succeed(key === "remote.origin.url" ? originUrl : null), + execute: fakeGitExecute, }); +const noopMirrorRegistry: AetherMirrorRegistration = { + register: () => Effect.void, + deregister: () => Effect.void, +}; + +/** Zero-wait turn pacing so tests drive everything from the TestClock. */ +const zeroTurnTiming: Partial = { + settlePollMs: 0, + harvestPollMs: 0, + harvestMaxAttempts: 3, + interruptPollMs: 0, + interruptMaxAttempts: 5, +}; + /** Every method defects — override exactly what a test expects to be called. */ const unusedRestClient: AetherRestClient = { createTask: () => Effect.die("createTask must not be called"), @@ -114,6 +162,7 @@ const withAdapter = ( readonly restClient?: AetherRestClient | undefined; readonly hasRestClient?: boolean; readonly socket?: AetherAdapterSocketOptions; + readonly mirrorRegistry?: AetherMirrorRegistration; }, use: (adapter: ProviderAdapterShape) => Effect.Effect, ) => @@ -122,12 +171,19 @@ const withAdapter = ( instanceId, defaultCwd: "/default-cwd", git: options.git ?? gitWith(cleanStatus), + attachmentsDir: "/nonexistent-attachments-dir", + mirrorRegistry: options.mirrorRegistry ?? noopMirrorRegistry, restClient: options.hasRestClient === false ? undefined : (options.restClient ?? unusedRestClient), socket: options.socket, + turnTiming: zeroTurnTiming, }); return yield* use(adapter); - }).pipe(Effect.scoped, Effect.provideService(Crypto.Crypto, testCrypto)); + }).pipe( + Effect.scoped, + Effect.provideService(Crypto.Crypto, testCrypto), + Effect.provide(NodeServices.layer), + ); const expectStartFailure = (options: { readonly git?: AetherSessionGit; @@ -543,17 +599,18 @@ describe("AetherAdapter session lifecycle", () => { ), ); - it.effect("turn-surface methods stay loud typed not-implemented stubs", () => + it.effect("turn methods fail session-not-found for unknown threads; T7+ stubs stay loud", () => withAdapter({}, (adapter) => Effect.gen(function* () { const threadId = ThreadId.make("thread-1"); const sendTurn = yield* Effect.flip(adapter.sendTurn({ threadId, input: "hi" })); - expect(sendTurn._tag).toBe("ProviderAdapterRequestError"); - expect(sendTurn.message).toContain("not implemented"); + expect(sendTurn._tag).toBe("ProviderAdapterSessionNotFoundError"); const interrupt = yield* Effect.flip(adapter.interruptTurn(threadId)); - expect(interrupt._tag).toBe("ProviderAdapterRequestError"); + expect(interrupt._tag).toBe("ProviderAdapterSessionNotFoundError"); + // Questions/revert land with build items 9/10 — still typed stubs. const rollback = yield* Effect.flip(adapter.rollbackThread(threadId, 1)); expect(rollback._tag).toBe("ProviderAdapterRequestError"); + expect(rollback.message).toContain("not implemented"); }), ), ); @@ -860,13 +917,41 @@ const settleAdapterPump = Effect.gen(function* () { } }); +const zeroSocketTiming = { + pollInitialMs: 0, + pollMaxMs: 0, + reconnectInitialMs: 0, + reconnectMaxMs: 0, + connectDefaultRetryMs: 0, + requestTimeoutMs: 0, +}; + +/** A fake socket whose workspace side answers every git diff request. */ +const diffAnsweringSocket = (): FakeAdapterSocket => { + const socket = new FakeAdapterSocket(); + const originalSend = socket.send.bind(socket); + socket.send = (data: string) => { + originalSend(data); + const frame = JSON.parse(data) as { channel?: string; requestId?: string }; + if (frame.channel === "git" && typeof frame.requestId === "string") { + socket.message({ + channel: "git", + type: "diff", + requestId: frame.requestId, + success: true, + diff: { baseRef: "headsha", files: [] }, + }); + } + }; + socket.open(); + return socket; +}; + describe("AetherAdapter event pipeline", () => { - const zeroTiming = { - pollInitialMs: 0, - pollMaxMs: 0, - reconnectInitialMs: 0, - reconnectMaxMs: 0, - connectDefaultRetryMs: 0, + const idleMessageTask: AetherTask = { + ...processingTask, + status: "awaiting_input", + awaiting_input: { kind: "message" }, }; const streamingRestClient = (deltaSequences: Array): AetherRestClient => ({ @@ -881,7 +966,10 @@ describe("AetherAdapter event pipeline", () => { getConversationDelta: (_taskId, after) => Effect.sync(() => { deltaSequences.push(after); - }).pipe(Effect.as(emptyDelta(processingTask, after))), + // First reconcile: still processing. Later (settle-poll) beats: the + // turn is over — an idle task, so the backstop emits nothing new. + return emptyDelta(deltaSequences.length === 1 ? processingTask : idleMessageTask, after); + }), }); it.effect("attaches passively on resume and streams mapped live events", () => @@ -894,11 +982,12 @@ describe("AetherAdapter event pipeline", () => { socket: { apiBaseUrl: "https://api.runaether.dev", apiKey: "aether_test_key", - timing: zeroTiming, + // The diff request must resolve, never time out — the workspace + // side (diffAnsweringSocket) answers it synchronously. + timing: { ...zeroSocketTiming, requestTimeoutMs: 60_000 }, webSocketFactory: () => { - const socket = new FakeAdapterSocket(); + const socket = diffAnsweringSocket(); sockets.push(socket); - socket.open(); return socket; }, }, @@ -906,7 +995,7 @@ describe("AetherAdapter event pipeline", () => { (adapter) => Effect.gen(function* () { const collector = yield* adapter.streamEvents.pipe( - Stream.take(5), + Stream.take(7), Stream.runCollect, Effect.forkScoped, ); @@ -938,17 +1027,130 @@ describe("AetherAdapter event pipeline", () => { // The reconcile's status projection: resuming onto a // processing task shows the session as running, not idle. "session.state.changed", + // Resume-onto-processing adoption: the first live observation + // of the in-flight wire turn reconstructs it (spec §2.3). + "turn.started", "content.delta", + // The settle ran the mirror sync over the LIVE connection — + // the git diff answered from inside the event pipeline, then + // the checkpoint went out strictly before the settle. + "turn.diff.updated", "turn.completed", "session.exited", ]); expect(events[1]).toMatchObject({ payload: { state: "running" } }); - const delta = events[2]!; + expect(events[2]).toMatchObject({ + eventId: "aether:task-1:turn:u1:started", + turnId: "aether-turn-u1", + }); + const delta = events[3]!; expect(delta).toMatchObject({ eventId: "aether:task-1:stream:m1:1", threadId: session.threadId, payload: { streamKind: "assistant_text", delta: "Looking at the" }, }); + expect(events[4]).toMatchObject({ + eventId: "aether:task-1:turn:u1:diff", + turnId: "aether-turn-u1", + }); + // The diff request went out over the git channel. + expect(sockets[0]!.sent.some((frame) => frame.includes('"channel":"git"'))).toBe(true); + }), + ); + }), + ); + + it.effect("resume onto an in-flight task adopts the turn and arms the settle backstop", () => + Effect.gen(function* () { + const sockets: Array = []; + let deltaCalls = 0; + let taskIdle = false; + const restClient: AetherRestClient = { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + getTask: () => Effect.succeed(processingTask), + connectWorkspace: () => + Effect.succeed({ + state: "running", + transport: { websocket_path: "/workspaces/ws-1/ws", preview_token: "t".repeat(32) }, + } as const), + getConversationDelta: (_taskId, after) => + Effect.sync(() => { + deltaCalls++; + return { + task: taskIdle ? idleMessageTask : processingTask, + messages: [], + activity: [], + activeProcessingTurn: taskIdle + ? null + : { messageId: "m9", startedAt: "2026-08-08T10:02:00Z" }, + latestSequence: after, + removedMessageIds: [], + truncated: false, + } satisfies AetherConversationDelta; + }), + }; + yield* withAdapter( + { + restClient, + socket: { + apiBaseUrl: "https://api.runaether.dev", + apiKey: "aether_test_key", + timing: { ...zeroSocketTiming, requestTimeoutMs: 60_000 }, + webSocketFactory: () => { + const socket = diffAnsweringSocket(); + sockets.push(socket); + return socket; + }, + }, + }, + (adapter) => + Effect.gen(function* () { + const collector = yield* adapter.streamEvents.pipe( + Stream.take(5), + Stream.runCollect, + Effect.forkScoped, + ); + yield* adapter.startSession( + startInput({ + resumeCursor: { schemaVersion: 1, taskId: "task-1", latestSequence: 7 }, + }), + ); + yield* settleAdapterPump; + + // The in-flight wire turn was reconstructed from the delta's + // activeProcessingTurn: listSessions is in lockstep and Stop has + // something to grab (spec §2.3) — no sendTurn ever ran. + const mid = (yield* adapter.listSessions())[0]!; + expect(mid.status).toBe("running"); + expect(mid.activeTurnId).toBe("aether-turn-m9"); + + // The turn settles through the REST backstop poll alone (the + // socket never delivers a live settle frame). + taskIdle = true; + yield* settleAdapterPump; + const events = yield* Fiber.join(collector); + expect(events.map((event) => event.type)).toEqual([ + "session.started", + "turn.started", + "session.state.changed", + "turn.diff.updated", + "turn.completed", + ]); + expect(events[1]).toMatchObject({ + eventId: "aether:task-1:turn:m9:started", + turnId: "aether-turn-m9", + }); + expect(events[4]).toMatchObject({ + turnId: "aether-turn-m9", + payload: { state: "completed" }, + }); + // More than the single attach reconcile ran — the poll is armed. + expect(deltaCalls).toBeGreaterThan(1); + + const after = (yield* adapter.listSessions())[0]!; + expect(after.status).toBe("ready"); + expect(after.activeTurnId).toBeUndefined(); }), ); }), @@ -963,7 +1165,7 @@ describe("AetherAdapter event pipeline", () => { socket: { apiBaseUrl: "https://api.runaether.dev", apiKey: "aether_test_key", - timing: zeroTiming, + timing: zeroSocketTiming, webSocketFactory: () => { const socket = new FakeAdapterSocket(); sockets.push(socket); @@ -983,3 +1185,839 @@ describe("AetherAdapter event pipeline", () => { }), ); }); + +// --------------------------------------------------------------------------- +// Turn lifecycle (T6, build item 7): create / respond / steer / interrupt +// --------------------------------------------------------------------------- + +describe("AetherAdapter turn lifecycle", () => { + const messageIdleTask: AetherTask = { + ...processingTask, + status: "awaiting_input", + awaiting_input: { kind: "message" }, + }; + + const userRow = (id: string, sequence: number): AetherTimelineMessage => ({ + id, + role: "user", + content: `message ${id}`, + deliveryStatus: "delivered", + timestamp: `t${sequence}`, + sequence, + }); + + const assistantRow = (id: string, sequence: number): AetherTimelineMessage => ({ + id, + role: "assistant", + variant: "text", + content: `answer ${id}`, + timestamp: `t${sequence}`, + sequence, + }); + + const delta = (input: { + readonly task: AetherTask; + readonly messages?: ReadonlyArray; + readonly activeMessageId?: string; + readonly latestSequence: number; + }): AetherConversationDelta => ({ + task: input.task, + messages: input.messages ?? [], + activity: [], + activeProcessingTurn: + input.activeMessageId !== undefined + ? { messageId: input.activeMessageId, startedAt: "2026-08-08T10:02:00Z" } + : null, + latestSequence: input.latestSequence, + removedMessageIds: [], + truncated: false, + }); + + /** Delta answers scripted per call; the last repeats (reconciles are idempotent). */ + const scriptedDeltas = (answers: ReadonlyArray) => { + let calls = 0; + return { + getConversationDelta: (_taskId: string, _after: number) => { + const answer = answers[Math.min(calls, answers.length - 1)]!; + calls++; + return Effect.succeed(answer); + }, + calls: () => calls, + }; + }; + + const drainPoll = Effect.gen(function* () { + for (let i = 0; i < 12; i++) { + yield* TestClock.adjust("0 millis"); + yield* Effect.yieldNow; + } + }); + + it.effect( + "first sendTurn creates the task, emits turn.started, settles READY via the backstop", + () => + Effect.gen(function* () { + const createRequests: Array = []; + const deltas = scriptedDeltas([ + // Harvest: the first user row names turn 1's wire id. + delta({ + task: processingTask, + messages: [userRow("u1", 1)], + activeMessageId: "u1", + latestSequence: 1, + }), + // Backstop settle: assistant output + message-kind idle (READY). + delta({ + task: messageIdleTask, + messages: [userRow("u1", 1), assistantRow("a1", 2)], + latestSequence: 2, + }), + ]); + const registrations: Array = []; + yield* withAdapter( + { + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + createTask: (request) => + Effect.sync(() => { + createRequests.push(request); + }).pipe(Effect.as({ id: "task-9", name: "Fix the flaky test" })), + getConversationDelta: deltas.getConversationDelta, + }, + mirrorRegistry: { + register: (cwd) => Effect.sync(() => void registrations.push(`+${cwd}`)), + deregister: (cwd) => Effect.sync(() => void registrations.push(`-${cwd}`)), + }, + }, + (adapter) => + Effect.gen(function* () { + const collector = yield* adapter.streamEvents.pipe( + Stream.take(3), + Stream.runCollect, + Effect.forkScoped, + ); + const session = yield* adapter.startSession(startInput()); + const result = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "fix the bug", + }); + expect(result.turnId).toBe("aether-turn-u1"); + expect(result.resumeCursor).toMatchObject({ taskId: "task-9" }); + + // The create request carries the spec'd shape. + expect(createRequests).toHaveLength(1); + expect(createRequests[0]).toMatchObject({ + project_id: "project-1", + prompt: "fix the bug", + base_branch: "feature/demo", + agent_type: "codex", + model: "gpt-5.6-sol", + interaction_mode: "default", + auto_fix_ci: false, + auto_fix_pr_comments: false, + auto_rebase: false, + }); + + // Mid-turn: the session shows the active turn. + const midTurn = (yield* adapter.listSessions())[0]!; + expect(midTurn.status).toBe("running"); + expect(midTurn.activeTurnId).toBe("aether-turn-u1"); + + yield* drainPoll; + + const events = yield* Fiber.join(collector); + expect(events.map((event) => event.type)).toEqual([ + "turn.started", + "item.completed", + "turn.completed", + ]); + expect(events[0]).toMatchObject({ + eventId: "aether:task-9:turn:u1:started", + turnId: "aether-turn-u1", + payload: { model: "codex/gpt-5.6-sol" }, + }); + expect(events[2]).toMatchObject({ + turnId: "aether-turn-u1", + payload: { state: "completed" }, + }); + // The message-kind idle settle maps to READY: deliberately NO + // session.state.changed (waiting would re-flip to Working). + expect(events.some((event) => event.type === "session.state.changed")).toBe(false); + + const settled = (yield* adapter.listSessions())[0]!; + expect(settled.status).toBe("ready"); + expect(settled.activeTurnId).toBeUndefined(); + + // The mirror guard owned the cwd from startSession. + expect(registrations).toEqual(["+/repo"]); + }), + ); + }), + ); + + it.effect( + "a first turn already settled in the attach reconcile still starts before it completes", + () => + Effect.gen(function* () { + // The create path forks the socket pipeline, and the attach's + // onConnected reconcile can settle the turn on its very first beat. + // The turn must therefore be recorded (mapper + activeTurn + + // turn.started) BEFORE that fork: a settle observed against an + // unrecorded turn emits turn.completed with no turn.started ahead of + // it and strands activeTurn afterwards. + const sockets: Array = []; + // activeTurnId as it stood on every conversation-delta call: the + // harvest (before the turn exists) and then the attach reconcile. + const activeTurnPerDeltaCall: Array = []; + let adapterRef: ProviderAdapterShape | undefined; + const deltas = scriptedDeltas([ + // Harvest: the first user row names turn 1's wire id. + delta({ + task: processingTask, + messages: [userRow("u1", 1)], + activeMessageId: "u1", + latestSequence: 1, + }), + // The attach reconcile already sees the WHOLE turn, settled. + delta({ + task: messageIdleTask, + messages: [userRow("u1", 1), assistantRow("a1", 2)], + latestSequence: 2, + }), + ]); + yield* withAdapter( + { + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + createTask: () => Effect.succeed({ id: "task-9", name: "n" }), + getTask: () => Effect.succeed(processingTask), + connectWorkspace: () => + Effect.succeed({ + state: "running", + transport: { + websocket_path: "/workspaces/ws-1/ws", + preview_token: "t".repeat(32), + }, + } as const), + getConversationDelta: (taskId, after) => + Effect.gen(function* () { + const answer = deltas.getConversationDelta(taskId, after); + const sessions = adapterRef === undefined ? [] : yield* adapterRef.listSessions(); + activeTurnPerDeltaCall.push(sessions[0]?.activeTurnId); + return yield* answer; + }), + }, + socket: { + apiBaseUrl: "https://api.runaether.dev", + apiKey: "aether_test_key", + timing: { ...zeroSocketTiming, requestTimeoutMs: 60_000 }, + webSocketFactory: () => { + const socket = diffAnsweringSocket(); + sockets.push(socket); + return socket; + }, + }, + }, + (adapter) => + Effect.gen(function* () { + adapterRef = adapter; + const collector = yield* adapter.streamEvents.pipe( + Stream.take(5), + Stream.runCollect, + Effect.forkScoped, + ); + const session = yield* adapter.startSession(startInput()); + const result = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "go", + }); + expect(result.turnId).toBe("aether-turn-u1"); + yield* drainPoll; + + const events = yield* Fiber.join(collector); + expect(events.map((event) => event.type)).toEqual([ + "turn.started", + "session.started", + "item.completed", + "turn.diff.updated", + "turn.completed", + ]); + expect(events[0]).toMatchObject({ turnId: "aether-turn-u1" }); + expect(events[4]).toMatchObject({ + turnId: "aether-turn-u1", + payload: { state: "completed" }, + }); + // The ordering contract itself: the attach reconcile (delta + // call 2 — the one that carries the settle) ran against an + // ALREADY-recorded turn. Call 1 is the pre-turn harvest. + expect(activeTurnPerDeltaCall.slice(0, 2)).toEqual([undefined, "aether-turn-u1"]); + // The settle landed on the turn the driver had already + // recorded: no stale active turn survives it. + const settled = (yield* adapter.listSessions())[0]!; + expect(settled.status).toBe("ready"); + expect(settled.activeTurnId).toBeUndefined(); + }), + ); + }), + { timeout: 15_000 }, + ); + + it.effect("a settle into a pending QUESTION emits waiting (unlike message-idle)", () => + Effect.gen(function* () { + const questionTask: AetherTask = { + ...processingTask, + status: "awaiting_input", + awaiting_input: { + kind: "questions", + tool_id: "input-1", + input: { questions: [{ id: "q1", question: "Which db?", options: [] }] }, + }, + }; + const deltas = scriptedDeltas([ + delta({ + task: processingTask, + messages: [userRow("u1", 1)], + activeMessageId: "u1", + latestSequence: 1, + }), + delta({ task: questionTask, messages: [userRow("u1", 1)], latestSequence: 1 }), + ]); + yield* withAdapter( + { + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + createTask: () => Effect.succeed({ id: "task-9", name: "n" }), + getConversationDelta: deltas.getConversationDelta, + }, + }, + (adapter) => + Effect.gen(function* () { + const collector = yield* adapter.streamEvents.pipe( + Stream.take(4), + Stream.runCollect, + Effect.forkScoped, + ); + const session = yield* adapter.startSession(startInput()); + yield* adapter.sendTurn({ threadId: session.threadId, input: "go" }); + yield* drainPoll; + const events = yield* Fiber.join(collector); + expect(events.map((event) => event.type)).toEqual([ + "turn.started", + "turn.completed", + "user-input.requested", + "session.state.changed", + ]); + expect(events[3]).toMatchObject({ payload: { state: "waiting" } }); + }), + ); + }), + ); + + it.effect( + "mid-turn send queues: turn.started(T2) deferred until pickup, after turn.completed(T1)", + () => + Effect.gen(function* () { + const respondRequests: Array<{ taskId: string; request: unknown }> = []; + const deltas = scriptedDeltas([ + // Tick 1: T1 (m2) still processing. + delta({ task: processingTask, activeMessageId: "m2", latestSequence: 3 }), + // Tick 2: remote picked up the queued m3 — T1 displaced. + delta({ task: processingTask, activeMessageId: "m3", latestSequence: 4 }), + // Tick 3: m3 settles into idle. + delta({ task: messageIdleTask, latestSequence: 5 }), + ]); + let respondCount = 0; + yield* withAdapter( + { + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + getTask: () => Effect.succeed(processingTask), + respondToTask: (taskId, request) => + Effect.sync(() => { + respondRequests.push({ taskId, request }); + respondCount++; + }).pipe(Effect.map(() => ({ message_id: respondCount === 1 ? "m2" : "m3" }))), + getConversationDelta: deltas.getConversationDelta, + }, + }, + (adapter) => + Effect.gen(function* () { + const collector = yield* adapter.streamEvents.pipe( + Stream.take(6), + Stream.runCollect, + Effect.forkScoped, + ); + const session = yield* adapter.startSession( + startInput({ + resumeCursor: { schemaVersion: 1, taskId: "task-1", latestSequence: 2 }, + }), + ); + const first = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "turn two", + }); + expect(first.turnId).toBe("aether-turn-m2"); + + // STEER while T1 runs: 202 + queue, activeTurnId flips to T2 NOW, + // but turn.started(T2) waits for remote pickup. + const second = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "steer it", + }); + expect(second.turnId).toBe("aether-turn-m3"); + expect((yield* adapter.listSessions())[0]!.activeTurnId).toBe("aether-turn-m3"); + // Both responds carried deterministic idempotency keys. + expect(respondRequests).toHaveLength(2); + for (const { request } of respondRequests) { + expect((request as { client_message_id?: string }).client_message_id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, + ); + } + + yield* drainPoll; + const events = yield* Fiber.join(collector); + expect(events.map((event) => `${event.type}:${String(event.turnId ?? "")}`)).toEqual([ + "turn.started:aether-turn-m2", + // The backstop poll projects processing → running. + "session.state.changed:", + // The queued/steering contract: completed(T1) strictly before + // started(T2), started(T2) only on observed pickup. + "turn.completed:aether-turn-m2", + "session.state.changed:", + "turn.started:aether-turn-m3", + "turn.completed:aether-turn-m3", + ]); + }), + ); + }), + ); + + it.effect("interrupt discards the queued follow-up and the thread stays idle", () => + Effect.gen(function* () { + const stops: Array<{ taskId: string; discard: boolean }> = []; + const deltas = scriptedDeltas([ + delta({ task: processingTask, activeMessageId: "m2", latestSequence: 3 }), + ]); + let respondCount = 0; + yield* withAdapter( + { + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + // Interrupt confirmation: the task has already left processing. + getTask: () => Effect.succeed(messageIdleTask), + respondToTask: () => + Effect.sync(() => { + respondCount++; + }).pipe(Effect.map(() => ({ message_id: respondCount === 1 ? "m2" : "m3" }))), + stopTask: (taskId, input) => + Effect.sync(() => { + stops.push({ taskId, discard: input.discardQueuedMessages }); + }), + getConversationDelta: deltas.getConversationDelta, + }, + }, + (adapter) => + Effect.gen(function* () { + const collector = yield* adapter.streamEvents.pipe( + Stream.take(3), + Stream.runCollect, + Effect.forkScoped, + ); + const session = yield* adapter.startSession( + startInput({ + resumeCursor: { schemaVersion: 1, taskId: "task-1", latestSequence: 2 }, + }), + ); + yield* adapter.sendTurn({ threadId: session.threadId, input: "turn two" }); + // Queue a follow-up, then stop: the follow-up is discarded and + // re-offered as text. + yield* adapter.sendTurn({ threadId: session.threadId, input: "queued follow-up" }); + yield* adapter.interruptTurn(session.threadId); + + expect(stops).toEqual([{ taskId: "task-1", discard: true }]); + + const events = yield* Fiber.join(collector); + expect(events.map((event) => event.type)).toEqual([ + "turn.started", + "runtime.warning", + "turn.completed", + ]); + expect(events[1]!.type === "runtime.warning" && events[1]!.payload.message).toContain( + "queued follow-up", + ); + expect(events[2]).toMatchObject({ + turnId: "aether-turn-m2", + payload: { state: "interrupted" }, + }); + + // The thread stays idle: no deferred turn.started(T2) fires later. + yield* drainPoll; + const after = (yield* adapter.listSessions())[0]!; + expect(after.status).toBe("ready"); + expect(after.activeTurnId).toBeUndefined(); + const extra = yield* adapter.streamEvents.pipe( + Stream.take(1), + Stream.runCollect, + Effect.forkScoped, + ); + yield* drainPoll; + yield* Fiber.interrupt(extra); + }), + ); + }), + ); + + it.effect( + "a failed first-turn harvest never double-sends: the retry re-enters the create path", + () => + Effect.gen(function* () { + // createTask succeeded but the turn-1 harvest failed — the task exists + // and carries the prompt. A retry must NOT take the respond path (that + // re-sends the prompt as a second message) and must NOT create again: + // it re-harvests. A retry with DIFFERENT text refuses loudly. + let createCalls = 0; + let deltaCalls = 0; + yield* withAdapter( + { + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + getTask: () => Effect.succeed(processingTask), + createTask: () => + Effect.suspend(() => { + createCalls++; + return Effect.succeed({ id: "task-9", name: "n" }); + }), + respondToTask: () => + Effect.die("respondToTask must not be called on a pending first turn"), + connectWorkspace: () => + Effect.succeed({ + state: "running", + transport: { + websocket_path: "/workspaces/ws-1/ws", + preview_token: "t".repeat(32), + }, + } as const), + getConversationDelta: () => + Effect.suspend(() => { + deltaCalls++; + // The first sendTurn's harvest fails (transport errors fail + // fast); the retry's harvest succeeds with the opening row. + if (deltaCalls === 1) { + return Effect.fail( + new AetherApiTransportError({ + endpoint: "/tasks/task-9/conversation/delta", + detail: "socket hangup", + }), + ); + } + return Effect.succeed( + delta({ + task: processingTask, + messages: [userRow("u1", 1)], + latestSequence: 1, + }), + ); + }), + }, + socket: { + apiBaseUrl: "https://api.runaether.dev", + apiKey: "aether_test_key", + timing: zeroSocketTiming, + webSocketFactory: () => diffAnsweringSocket(), + }, + }, + (adapter) => + Effect.gen(function* () { + const session = yield* adapter.startSession(startInput()); + const failure = yield* Effect.flip( + adapter.sendTurn({ threadId: session.threadId, input: "go" }), + ); + expect(failure._tag).toBe("ProviderAdapterRequestError"); + // Different text while pending → refused, nothing dispatched. + const mismatch = yield* Effect.flip( + adapter.sendTurn({ threadId: session.threadId, input: "something else" }), + ); + expect(mismatch._tag).toBe("ProviderAdapterValidationError"); + // Same text → re-enters the first-turn path: no second create, + // no respond, harvest retried and the turn comes up. + const result = yield* adapter.sendTurn({ threadId: session.threadId, input: "go" }); + expect(result.turnId).toBe("aether-turn-u1"); + expect(createCalls).toBe(1); + }), + ); + }), + ); + + it.effect("a failed respond does not burn the client_message_id — the retry can dedupe", () => + Effect.gen(function* () { + // The idempotency key exists for exactly one scenario: a respond whose + // 202 was lost in transit and is then re-sent. The ordinal must only + // advance on a CONFIRMED 202, so the retry reuses the same id and the + // server's ON CONFLICT dedupe can fire. + const seenIds: Array = []; + let failFirst = true; + yield* withAdapter( + { + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + getTask: () => Effect.succeed(messageIdleTask), + respondToTask: (_taskId, request) => + Effect.suspend(() => { + seenIds.push((request as { client_message_id: string }).client_message_id); + if (failFirst) { + failFirst = false; + return Effect.fail( + new AetherApiNotFoundError({ + endpoint: "/tasks/task-1/respond", + detail: "lost", + }), + ); + } + return Effect.succeed({ message_id: "m2" }); + }), + getConversationDelta: scriptedDeltas([ + delta({ task: messageIdleTask, latestSequence: 3 }), + ]).getConversationDelta, + }, + }, + (adapter) => + Effect.gen(function* () { + const session = yield* adapter.startSession( + startInput({ + resumeCursor: { schemaVersion: 1, taskId: "task-1", latestSequence: 2 }, + }), + ); + const failure = yield* Effect.flip( + adapter.sendTurn({ threadId: session.threadId, input: "send it" }), + ); + expect(failure._tag).toBe("ProviderAdapterRequestError"); + yield* adapter.sendTurn({ threadId: session.threadId, input: "send it" }); + expect(seenIds).toHaveLength(2); + expect(seenIds[0]).toBe(seenIds[1]); + }), + ); + }), + ); + + it.effect("interrupt with ONLY a queued follow-up settles it — the session never wedges", () => + Effect.gen(function* () { + const stops: Array<{ taskId: string; discard: boolean }> = []; + const deltas = scriptedDeltas([ + // Tick 1: T1 (m2) processing. + delta({ task: processingTask, activeMessageId: "m2", latestSequence: 3 }), + // Tick 2+: T1 settled WITHOUT the queued m3 being picked up. + delta({ task: messageIdleTask, latestSequence: 4 }), + ]); + let respondCount = 0; + yield* withAdapter( + { + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + getTask: () => Effect.succeed(messageIdleTask), + respondToTask: () => + Effect.sync(() => { + respondCount++; + }).pipe(Effect.map(() => ({ message_id: respondCount === 1 ? "m2" : "m3" }))), + stopTask: (taskId, input) => + Effect.sync(() => { + stops.push({ taskId, discard: input.discardQueuedMessages }); + }), + getConversationDelta: deltas.getConversationDelta, + }, + }, + (adapter) => + Effect.gen(function* () { + const collector = yield* adapter.streamEvents.pipe( + Stream.take(5), + Stream.runCollect, + Effect.forkScoped, + ); + const session = yield* adapter.startSession( + startInput({ + resumeCursor: { schemaVersion: 1, taskId: "task-1", latestSequence: 2 }, + }), + ); + yield* adapter.sendTurn({ threadId: session.threadId, input: "turn two" }); + yield* adapter.sendTurn({ threadId: session.threadId, input: "queued follow-up" }); + // T1 settles naturally via the backstop; m3 stays queued, so the + // session keeps running on the deferred turn. + yield* drainPoll; + const mid = (yield* adapter.listSessions())[0]!; + expect(mid.status).toBe("running"); + expect(mid.activeTurnId).toBe("aether-turn-m3"); + + // Stop with NO active turn — only the deferred steer exists. + yield* adapter.interruptTurn(session.threadId); + expect(stops).toEqual([{ taskId: "task-1", discard: true }]); + + const events = yield* Fiber.join(collector); + expect(events.map((event) => event.type)).toEqual([ + "turn.started", + "session.state.changed", + "turn.completed", + "runtime.warning", + // The discarded queued turn gets its OWN terminal settle — + // without it the session stays running on a turn that no + // longer exists and a second Stop has nothing to grab. + "turn.completed", + ]); + expect(events[2]).toMatchObject({ + turnId: "aether-turn-m2", + payload: { state: "completed" }, + }); + expect(events[4]).toMatchObject({ + turnId: "aether-turn-m3", + payload: { state: "interrupted" }, + }); + + const after = (yield* adapter.listSessions())[0]!; + expect(after.status).toBe("ready"); + expect(after.activeTurnId).toBeUndefined(); + // Nothing left to interrupt — the wedge would have kept this alive. + const second = yield* Effect.flip(adapter.interruptTurn(session.threadId)); + expect(second.message).toContain("No Aether turn is active"); + }), + ); + }), + ); + + it.effect("a failed stop does NOT falsify the turn's natural settle into 'interrupted'", () => + Effect.gen(function* () { + const deltas = scriptedDeltas([ + delta({ task: processingTask, activeMessageId: "m2", latestSequence: 3 }), + delta({ task: messageIdleTask, latestSequence: 4 }), + ]); + yield* withAdapter( + { + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + getTask: () => Effect.succeed(messageIdleTask), + respondToTask: () => Effect.succeed({ message_id: "m2" }), + stopTask: () => + Effect.fail( + new AetherApiNotFoundError({ endpoint: "/tasks/task-1/stop", detail: "gone" }), + ), + getConversationDelta: deltas.getConversationDelta, + }, + }, + (adapter) => + Effect.gen(function* () { + const collector = yield* adapter.streamEvents.pipe( + Stream.take(3), + Stream.runCollect, + Effect.forkScoped, + ); + const session = yield* adapter.startSession( + startInput({ + resumeCursor: { schemaVersion: 1, taskId: "task-1", latestSequence: 2 }, + }), + ); + yield* adapter.sendTurn({ threadId: session.threadId, input: "turn two" }); + const failure = yield* Effect.flip(adapter.interruptTurn(session.threadId)); + expect(failure._tag).toBe("ProviderAdapterRequestError"); + + // The remote turn kept running and settles NATURALLY — the + // aborted interrupt must not have pre-marked it, or this settle + // would lie 'interrupted' for a turn that ran to completion. + yield* drainPoll; + const events = yield* Fiber.join(collector); + expect(events.at(-1)).toMatchObject({ + turnId: "aether-turn-m2", + payload: { state: "completed" }, + }); + }), + ); + }), + ); + + it.effect("rejects unsupported and oversize attachments BEFORE any API call", () => + withAdapter( + { + // createTask/respondToTask stay defects: reaching them fails the test. + restClient: { ...unusedRestClient, listProjects: () => Effect.succeed([project()]) }, + }, + (adapter) => + Effect.gen(function* () { + const session = yield* adapter.startSession(startInput()); + const unsupported = yield* Effect.flip( + adapter.sendTurn({ + threadId: session.threadId, + input: "see image", + attachments: [ + { + type: "image", + id: "thread-1-00000000-0000-4000-8000-000000000000", + name: "scan.tiff", + mimeType: "image/tiff", + sizeBytes: 10, + }, + ], + }), + ); + expect(unsupported._tag).toBe("ProviderAdapterValidationError"); + expect(unsupported.message).toContain("image/tiff"); + + const oversize = yield* Effect.flip( + adapter.sendTurn({ + threadId: session.threadId, + input: "see image", + attachments: [ + { + type: "image", + id: "thread-1-00000000-0000-4000-8000-000000000001", + name: "big.png", + mimeType: "image/png", + sizeBytes: 6 * 1024 * 1024, + }, + ], + }), + ); + expect(oversize._tag).toBe("ProviderAdapterValidationError"); + expect(oversize.message).toContain("5 MiB"); + }), + ), + ); + + it.effect("rejects an empty prompt loudly", () => + withAdapter( + { restClient: { ...unusedRestClient, listProjects: () => Effect.succeed([project()]) } }, + (adapter) => + Effect.gen(function* () { + const session = yield* adapter.startSession(startInput()); + const error = yield* Effect.flip(adapter.sendTurn({ threadId: session.threadId })); + expect(error._tag).toBe("ProviderAdapterValidationError"); + expect(error.message).toContain("non-empty text prompt"); + }), + ), + ); + + it.effect("stopSession deregisters the mirror-guard claim", () => + Effect.gen(function* () { + const registrations: Array = []; + yield* withAdapter( + { + restClient: { ...unusedRestClient, listProjects: () => Effect.succeed([project()]) }, + mirrorRegistry: { + register: (cwd, key) => Effect.sync(() => void registrations.push(`+${cwd}:${key}`)), + deregister: (cwd, key) => Effect.sync(() => void registrations.push(`-${cwd}:${key}`)), + }, + }, + (adapter) => + Effect.gen(function* () { + const session = yield* adapter.startSession(startInput()); + yield* adapter.stopSession(session.threadId); + }), + ); + expect(registrations).toEqual(["+/repo:aether:thread-1", "-/repo:aether:thread-1"]); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/AetherAdapter.ts b/apps/server/src/provider/Layers/AetherAdapter.ts index b15003104a4b..e1d97dba1916 100644 --- a/apps/server/src/provider/Layers/AetherAdapter.ts +++ b/apps/server/src/provider/Layers/AetherAdapter.ts @@ -1,37 +1,46 @@ /** * AetherAdapter — session core for the Aether cloud-task driver. * - * T2–T5 slice: real startSession/listSessions/hasSession/readThread/ - * stopSession/stopAll over the REST client, plus the event pipeline (build - * items 5+6): a session resumed onto a live task attaches PASSIVELY to its - * workspace WS (never booting a VM to view), maps the 13-kind live event - * union through `eventMapper`, and reconciles the durable conversation delta - * on every (re)connect. The turn surface (sendTurn/interruptTurn/ - * respondToUserInput/rollbackThread) still fails loudly until build items - * 7, 9 and 10 land. + * T2–T6 slice: startSession/listSessions/hasSession/readThread/stopSession/ + * stopAll over the REST client, the event pipeline (build items 5+6: passive + * WS attach, 13-kind live union through `eventMapper`, durable delta + * reconciliation), and the turn surface (build items 7+8): + * - sendTurn creates the cloud task on the first turn (the ONE path that + * may pass `start=true` to the workspace connect), responds on later + * turns, and defers `turn.started` for a mid-turn steer until the remote + * queue picks the message up; + * - every turn settle flows through the mirror sync engine + * (`aether/mirrorSync.ts`): verify → fetch+reset+clean onto the diff's + * own baseRef → apply the full cumulative diff → `turn.diff.updated` → + * `turn.completed`; + * - interruptTurn stops with `discard_queued_messages: true`, surfaces any + * discarded driver-queued message text, and settles `interrupted` only + * after read-side confirmation. + * respondToUserInput/rollbackThread still fail loudly until items 9/10. * * Design invariants (docs/aether-driver-plumbing-spec.md §2.3): * - startSession NEVER creates a task — the task is created on the first * sendTurn. It preflights the local checkout (clean tree on a pushed, - * in-sync branch), resolves the cwd's origin remote to exactly one - * linked Aether project, and validates a resume cursor's task still - * exists remotely. + * in-sync branch for a FRESH thread; a resumed thread's mirror is dirty + * by design and is guarded by the sync fingerprint instead), resolves + * the cwd's origin remote to exactly one linked Aether project, and + * validates a resume cursor's task still exists remotely. * - stopSession / stopAll are PURE DISCONNECTS: the cloud task keeps - * running and the VM idles itself out. `/stop` is never called here. - * Closing the session scope tears the socket down (the reaper-safe idle - * path: dropping the WS and ceasing activity pings lets the VM suspend). - * - resumeCursor = `{schemaVersion: 1, taskId, latestSequence, turnLedger?}`; - * t3 persists it at startSession/sendTurn returns, so a fresh session - * (no task yet) carries none. latestSequence refreshes in memory as the - * mapper advances; replay safety comes from the mapper's deterministic - * event IDs, not cursor freshness. + * running and the VM idles itself out. `/stop` is never called there. + * - resumeCursor = `{schemaVersion: 1, taskId, latestSequence, + * mirrorFingerprint?, turnLedger?}`; replay safety comes from the + * mapper's deterministic event IDs, not cursor freshness. * * @module provider/Layers/AetherAdapter */ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeCrypto from "node:crypto"; + import { EventId, ProviderDriverKind, TurnId, + type ChatAttachment, type ProviderInstanceId, type ProviderRuntimeEvent, type ProviderSession, @@ -40,14 +49,21 @@ import { import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; import * as Queue from "effect/Queue"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import type { GitCommandError } from "@t3tools/contracts"; -import type { GitStatusDetails } from "../../vcs/GitVcsDriver.ts"; +import { resolveAttachmentPath } from "../../attachmentStore.ts"; +import type { + ExecuteGitInput, + ExecuteGitResult, + GitStatusDetails, +} from "../../vcs/GitVcsDriver.ts"; import { ProviderAdapterRequestError, ProviderAdapterSessionNotFoundError, @@ -61,12 +77,24 @@ import type { } from "../Services/ProviderAdapter.ts"; import { AETHER_API_KEY_ENV_VAR } from "./AetherProvider.ts"; import { makeAetherEventMapper, type AetherEventMapper } from "./aether/eventMapper.ts"; +import { makeAetherMirrorSync, type AetherMirrorSyncEngine } from "./aether/mirrorSync.ts"; import type { AetherRestClient } from "./aether/restClient.ts"; -import type { AetherProject, AetherTimelineMessage } from "./aether/restSchemas.ts"; +import type { + AetherPromptAttachment, + AetherProject, + AetherTask, + AetherTimelineMessage, +} from "./aether/restSchemas.ts"; import { toolLifecycleItemTypeFromAether } from "./aether/vendored/canonicalItemType.ts"; +import { + AETHER_AGENT_TYPES, + reasoningEffortsForModel, + type AetherAgentType, +} from "./aether/vendored/catalog.ts"; import { parseFileChanges } from "./aether/vendored/toolDisplay.ts"; import { runAetherAgentStream, + type AetherAgentConnection, type AetherStreamTiming, type AetherWebSocketFactory, } from "./aether/workspaceSocket.ts"; @@ -74,7 +102,7 @@ import { const PROVIDER = ProviderDriverKind.make("aether"); const NOT_IMPLEMENTED_DETAIL = - "Aether driver: not implemented until the turn-lifecycle slices (build items 5-10)"; + "Aether driver: not implemented until the questions/revert slices (build items 9-10)"; const notImplemented = (method: string): Effect.Effect => Effect.fail( @@ -96,6 +124,12 @@ export interface AetherResumeCursor { readonly schemaVersion: typeof AETHER_RESUME_VERSION; readonly taskId: string; readonly latestSequence: number; + /** + * The mirror sync engine's last-synced content fingerprint. On resume it + * is the expected state of the local checkout; a mismatch pauses sync + * loudly instead of resetting over unknown local work. + */ + readonly mirrorFingerprint?: string; /** * Turn → messageId ledger, carried opaquely until the revert slice (build * item 10) builds and consumes it. Preserved through parse so a newer @@ -128,6 +162,9 @@ export function parseAetherResume(raw: unknown): AetherResumeCursor | undefined schemaVersion: AETHER_RESUME_VERSION, taskId: record.taskId.trim(), latestSequence: record.latestSequence, + ...(typeof record.mirrorFingerprint === "string" && record.mirrorFingerprint.length > 0 + ? { mirrorFingerprint: record.mirrorFingerprint } + : {}), ...(record.turnLedger !== undefined ? { turnLedger: record.turnLedger } : {}), }; } @@ -143,6 +180,8 @@ export interface AetherSessionGit { cwd: string, key: string, ) => Effect.Effect; + /** Raw git executor — the mirror sync engine's only git surface. */ + readonly execute: (input: ExecuteGitInput) => Effect.Effect; } /** Transport coordinates for the workspace WS attach (build item 5). */ @@ -157,11 +196,45 @@ export interface AetherAdapterSocketOptions { readonly timing?: Partial; } +/** Server-side ownership registry hook (build item 8a's guard source of truth). */ +export interface AetherMirrorRegistration { + readonly register: (cwd: string, key: string) => Effect.Effect; + readonly deregister: (cwd: string, key: string) => Effect.Effect; +} + +/** Turn-engine pacing knobs (injectable so tests never sleep real time). */ +export interface AetherTurnTiming { + /** REST backstop poll cadence while a turn is active (spec ~3s). */ + readonly settlePollMs: number; + /** Cadence + budget for harvesting the first user row after create. */ + readonly harvestPollMs: number; + readonly harvestMaxAttempts: number; + /** Cadence + budget for read-side confirmation after /stop. */ + readonly interruptPollMs: number; + readonly interruptMaxAttempts: number; +} + +const DEFAULT_TURN_TIMING: AetherTurnTiming = { + settlePollMs: 3_000, + harvestPollMs: 250, + harvestMaxAttempts: 40, + interruptPollMs: 500, + interruptMaxAttempts: 60, +}; + export interface AetherAdapterOptions { readonly instanceId: ProviderInstanceId; /** Fallback session cwd when the start input carries none (ServerConfig.cwd). */ readonly defaultCwd: string; readonly git: AetherSessionGit; + /** Attachment blob store root (ServerConfig.attachmentsDir). */ + readonly attachmentsDir: string; + /** + * The fork-side guard registry: every session's cwd is registered while an + * Aether thread owns it, and deregistered on disconnect AND adapter + * teardown (build item 8a). + */ + readonly mirrorRegistry: AetherMirrorRegistration; /** * Undefined when the instance has no `AETHER_API_KEY` — startSession then * fails loudly with the remediation instead of the driver failing create(). @@ -172,36 +245,168 @@ export interface AetherAdapterOptions { * unit tests; the driver always passes it alongside a real client. */ readonly socket?: AetherAdapterSocketOptions | undefined; + readonly turnTiming?: Partial; +} + +interface AetherActiveTurn { + readonly wireTurnId: string; + readonly turnId: TurnId; +} + +interface AetherDeferredTurn extends AetherActiveTurn { + /** The queued message text — re-offered in a warning card if a Stop discards it. */ + readonly text: string; } interface AetherSessionContext { session: ProviderSession; readonly cwd: string; readonly projectId: string; - /** Undefined until the first sendTurn creates the cloud task (item 7). */ + /** The branch preflighted at startSession — the task's base_branch. */ + readonly baseBranch: string | undefined; + /** Undefined until the first sendTurn creates the cloud task. */ taskId: string | undefined; + /** + * True between a successful createTask and the completed first-turn + * bring-up (harvest + attach). A retry while pending re-enters the + * first-turn path — skipping the create AND the respond — so a transient + * harvest failure can never double-send the prompt as a second message. + */ + firstTurnPending: boolean; + /** + * Fingerprint of EVERY dispatch-relevant input of the pending created + * task (prompt, resolved slug, effort, interaction mode, attachments): + * a retry must match all of them — matching only the text would silently + * discard changed attachments or model selection. + */ + firstTurnFingerprint: string | undefined; latestSequence: number; /** Opaque turn ledger carried from the resume cursor (see AetherResumeCursor). */ turnLedger: unknown; - /** Owns the attach pump + socket; closed on stopSession/stopAll. */ + /** Owns the attach pump, socket and turn poll; closed on stopSession/stopAll. */ sessionScope: Scope.Closeable | undefined; /** The session's event mapper; its latestSequence() is the live cursor. */ mapper: AetherEventMapper | undefined; + /** The mirror sync engine — active from startSession for the thread's life. */ + mirror: AetherMirrorSyncEngine | undefined; + /** Live WS connection handle (undefined while detached). */ + connection: AetherAgentConnection | undefined; + /** The durable reconciliation — the settle backstop the turn poll drives. */ + reconcile: Effect.Effect | undefined; + /** True while an attach pump fiber runs for this session. */ + pumpRunning: boolean; + /** `session.started` is emitted once per session, across pump restarts. */ + sessionStartedEmitted: boolean; + /** The wire turn currently running remotely (driver-tracked). */ + activeTurn: AetherActiveTurn | undefined; + /** + * One-shot: the session resumed onto a task that was ALREADY processing, + * so the in-flight wire turn is unknown until a reconcile observes + * `activeProcessingTurn`. The first observation adopts it — activeTurnId, + * turn.started and the settle backstop poll — per spec §2.3 (reconstruct + * activeTurnId from status=processing). Cleared by the first adoption or + * by the user's own next sendTurn. + */ + adoptActiveTurn: boolean; + /** Steer messages queued remotely, their turn.starteds deferred until pickup (FIFO). */ + deferredTurns: Array; + /** Guards against stacking settle-poll fibers. */ + pollRunning: boolean; + /** Ordinal for deterministic client_message_ids (one per own send). */ + sentCount: number; } const nowIso = Effect.map(DateTime.now, DateTime.formatIso); function buildAetherResumeCursor(context: AetherSessionContext): AetherResumeCursor | undefined { + const mirrorFingerprint = context.mirror?.lastSyncedFingerprint(); return context.taskId === undefined ? undefined : { schemaVersion: AETHER_RESUME_VERSION, taskId: context.taskId, latestSequence: context.latestSequence, + ...(mirrorFingerprint !== undefined ? { mirrorFingerprint } : {}), ...(context.turnLedger !== undefined ? { turnLedger: context.turnLedger } : {}), }; } +// --------------------------------------------------------------------------- +// Turn helpers (pure) +// --------------------------------------------------------------------------- + +const AETHER_TURN_ID_PREFIX = "aether-turn-"; + +const turnIdForWire = (wireTurnId: string): TurnId => + TurnId.make(`${AETHER_TURN_ID_PREFIX}${wireTurnId}`); + +/** Recover the wire turn id from a mapper-stamped t3 TurnId. */ +function wireIdFromTurnId(turnId: TurnId): string | undefined { + const raw = String(turnId); + return raw.startsWith(AETHER_TURN_ID_PREFIX) + ? raw.slice(AETHER_TURN_ID_PREFIX.length) + : undefined; +} + +/** + * Deterministic, RFC-4122-shaped id for `client_message_id`: stable per + * (taskId, session epoch, send ordinal) — the driver-side stand-in for + * "(taskId, t3 turnId)", since the t3 TurnId for a respond derives from the + * very message_id the call returns. The session epoch keeps ordinals from a + * RESUMED session from colliding with an earlier session's sends (the server + * dedupes on client_message_id via ON CONFLICT — a collision would silently + * swallow the new message). + */ +export function deterministicClientMessageId(input: { + readonly taskId: string; + readonly sessionEpoch: string; + readonly sendOrdinal: number; +}): string { + const hash = NodeCrypto.createHash("sha256") + .update(`aether:${input.taskId}:${input.sessionEpoch}:send:${input.sendOrdinal}`) + .digest("hex"); + const variant = ((Number.parseInt(hash[16]!, 16) & 0x3) | 0x8).toString(16); + return `${hash.slice(0, 8)}-${hash.slice(8, 12)}-4${hash.slice(13, 16)}-${variant}${hash.slice(17, 20)}-${hash.slice(20, 32)}`; +} + +/** + * Resolve a composite `/` slug into the create/respond + * dispatch pair. Catalog agent types dispatch natively; anything else is an + * Aether free-typed custom model, dispatched as agent_type `opencode` with + * the FULL slug as the model string (spec §2.5 custom-models row). + */ +export function resolveAetherModelSlug(slug: string): { + readonly agentType: string; + readonly model: string; + readonly catalogAgentType: AetherAgentType | undefined; +} { + const separator = slug.indexOf("/"); + if (separator > 0) { + const prefix = slug.slice(0, separator); + const known = AETHER_AGENT_TYPES.find((agentType) => agentType === prefix); + if (known !== undefined) { + return { agentType: known, model: slug.slice(separator + 1), catalogAgentType: known }; + } + } + return { agentType: "opencode", model: slug, catalogAgentType: undefined }; +} + +/** The platform attachment allowlist (libs/go/promptattachment, kept in sync). */ +const AETHER_ATTACHMENT_MEDIA_TYPES: ReadonlySet = new Set([ + "image/png", + "image/jpeg", + "image/webp", + "image/gif", + "application/pdf", + "text/plain", + "text/markdown", + "text/csv", + "application/json", +]); + +/** 5 MiB decoded per attachment (promptattachment.MaxBytes). */ +const AETHER_ATTACHMENT_MAX_BYTES = 5 * 1024 * 1024; + /** * Verify the local checkout is a safe mirror base for a cloud thread: a git * repo, on a branch, with a clean tree, pushed, and in sync with its origin @@ -230,6 +435,21 @@ function preflightIssue(status: GitStatusDetails, cwd: string): string | undefin return undefined; } +/** + * Structural-only preflight for a RESUMED thread: its mirror is dirty by + * design (reset-to-base + applied cumulative diff), so the clean/pushed + * checks do not apply — the sync engine's fingerprint verify guards content. + */ +function resumePreflightIssue(status: GitStatusDetails, cwd: string): string | undefined { + if (!status.isRepo) { + return `'${cwd}' is not a git repository. Aether cloud tasks need a git checkout of the linked repository.`; + } + if (status.branch === null) { + return "The working tree is on a detached HEAD. Check out the thread's branch before resuming this Aether cloud task."; + } + return undefined; +} + /** Snapshot item for a timeline row — minimal, per t3's opaque snapshot type. */ function snapshotItemFromMessage(row: AetherTimelineMessage): unknown { if (row.role === "user") { @@ -286,9 +506,11 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( ): Effect.fn.Return< ProviderAdapterShape, never, - Crypto.Crypto | Scope.Scope + Crypto.Crypto | FileSystem.FileSystem | Scope.Scope > { const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const turnTiming = { ...DEFAULT_TURN_TIMING, ...options.turnTiming }; // Scope-owned so registry teardown shuts the stream down with the instance. const runtimeEvents = yield* Effect.acquireRelease( Queue.unbounded(), @@ -296,6 +518,8 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( ); const sessions = new Map(); + const registryKey = (threadId: ThreadId) => `${options.instanceId}:${threadId}`; + const emit = (event: ProviderRuntimeEvent) => Queue.offer(runtimeEvents, event).pipe(Effect.asVoid); @@ -352,79 +576,335 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( : Effect.ignore(Scope.close(context.sessionScope, Exit.void)); // Registry/instance teardown must also stop every attach pump (delete = - // full teardown). Registered AFTER the queue's acquireRelease so it runs - // FIRST on close: pumps stop emitting, then the queue shuts down. + // full teardown) AND release every mirror-guard registration — a torn-down + // adapter must never leave a cwd locked. Registered AFTER the queue's + // acquireRelease so it runs FIRST on close: pumps stop emitting, then the + // queue shuts down. yield* Effect.acquireRelease(Effect.void, () => Effect.gen(function* () { - for (const context of sessions.values()) { + for (const [threadId, context] of sessions.entries()) { yield* closeSessionScope(context); + yield* options.mirrorRegistry.deregister(context.cwd, registryKey(threadId)); } }), ); - const emitAll = (events: ReadonlyArray) => - Effect.forEach(events, emit, { discard: true }); + // Stream-pump callbacks must be infallible (a failing callback would kill + // the socket loop); crypto id generation dying is the only acceptable + // defect here. + const freshEventId = Effect.orDie(randomEventId); + + const baseEvent = (context: AetherSessionContext) => + Effect.gen(function* () { + return { + eventId: yield* freshEventId, + provider: PROVIDER, + providerInstanceId: options.instanceId, + threadId: context.session.threadId, + createdAt: yield* nowIso, + }; + }); /** - * The event pipeline (build items 5+6): attach passively to the task's - * workspace WS, feed live events through the mapper, and reconcile the - * durable delta on every (re)connect — the REST backstop is the ONLY - * recovery for live-only turn.* settles missed while detached. Forked into - * the session scope; failures surface as runtime.error events (the session - * itself stays readable via REST). + * Run the mirror sync for one settling turn and emit its surface events + * (spec build item 8: sync completes BEFORE turn.completed goes out). */ - const startStreamPump = Effect.fn("startAetherStreamPump")(function* ( - context: AetherSessionContext, - restClient: AetherRestClient, - socket: AetherAdapterSocketOptions, - taskId: string, - ) { - const sessionScope = yield* Scope.make(); - context.sessionScope = sessionScope; - const threadId = context.session.threadId; - const mapper = makeAetherEventMapper({ - provider: PROVIDER, - instanceId: options.instanceId, - threadId, - taskId, - initialSequence: context.latestSequence, - }); - context.mapper = mapper; - - // Stream-pump callbacks must be infallible (a failing callback would - // kill the socket loop); crypto id generation dying is the only - // acceptable defect here. - const freshEventId = Effect.orDie(randomEventId); - - // The durable reconciliation — also the poll hook the T6 turn engine - // will drive for turn-settle backstops. A transient REST failure warns - // loudly and leaves the cursor untouched, so the next (re)connect - // retries the exact same range instead of silently skipping it. - const reconcile = Effect.gen(function* () { - const delta = yield* restClient.getConversationDelta(taskId, mapper.latestSequence()); - const events = mapper.reconcileDelta(delta, yield* nowIso); - context.latestSequence = mapper.latestSequence(); - yield* emitAll(events); - }).pipe( - Effect.catch((error) => - Effect.gen(function* () { - yield* Effect.logWarning("aether.reconcile.failed", { taskId, error: String(error) }); + const syncMirrorForSettle = (context: AetherSessionContext, turnId: TurnId | undefined) => + Effect.gen(function* () { + const mirror = context.mirror; + if (mirror === undefined) { + return; + } + const outcome = yield* mirror.syncAtSettle(context.connection); + const wireId = turnId !== undefined ? wireIdFromTurnId(turnId) : undefined; + switch (outcome._tag) { + case "synced": + yield* Effect.logDebug("aether.mirror.synced", { + taskId: context.taskId, + fileCount: outcome.fileCount, + }); + if (outcome.modeOnlySkipped.length > 0) { + // The wire diff carries no file-mode information, so a + // chmod-only change cannot be mirrored — say so instead of + // silently dropping it (or worse, pausing the whole sync). + yield* emit({ + ...(yield* baseEvent(context)), + type: "runtime.warning", + payload: { + message: + `The cloud workspace changed only the file MODE of ${outcome.modeOnlySkipped.join(", ")}; ` + + "mode changes cannot be mirrored into the local checkout (the diff protocol carries no mode bits).", + }, + }); + } yield* emit({ - eventId: yield* freshEventId, - provider: PROVIDER, - providerInstanceId: options.instanceId, - threadId, - createdAt: yield* nowIso, + ...(yield* baseEvent(context)), + ...(context.taskId !== undefined && wireId !== undefined + ? { eventId: EventId.make(`aether:${context.taskId}:turn:${wireId}:diff`) } + : {}), + ...(turnId !== undefined ? { turnId } : {}), + type: "turn.diff.updated", + payload: { unifiedDiff: outcome.unifiedDiff }, + }); + return; + case "skipped-detached": + // Expected while detached: the turn settles with an empty + // checkpoint and the next successful sync captures the combined + // delta (lazy catch-up). No card. + yield* Effect.logInfo("aether.mirror.skipped-detached", { + taskId: context.taskId, + reason: outcome.reason, + }); + return; + case "skipped-transport": + yield* emit({ + ...(yield* baseEvent(context)), type: "runtime.warning", payload: { - message: `Could not reconcile the Aether conversation feed: ${error.message}`, + message: `The workspace diff sync did not complete for this turn; the next sync catches up. ${outcome.reason}`, }, }); - }), - ), - ); + return; + case "paused": + if (outcome.firstPause) { + yield* emit({ + ...(yield* baseEvent(context)), + type: "runtime.error", + payload: { + message: `Aether mirror sync is paused: ${outcome.reason}`, + class: "provider_error", + }, + }); + } else { + yield* emit({ + ...(yield* baseEvent(context)), + type: "runtime.warning", + payload: { + message: `Aether mirror sync remains paused; this turn settled without syncing. ${outcome.reason}`, + }, + }); + } + return; + } + }); + + /** Emit one deferred steer turn's `turn.started` and promote it to active. */ + const emitDeferredStarted = (context: AetherSessionContext, wireTurnId: string) => + Effect.gen(function* () { + const index = context.deferredTurns.findIndex( + (candidate) => candidate.wireTurnId === wireTurnId, + ); + if (index === -1) { + return; + } + const deferred = context.deferredTurns[index]!; + context.deferredTurns.splice(index, 1); + context.activeTurn = { wireTurnId: deferred.wireTurnId, turnId: deferred.turnId }; + context.session = { + ...context.session, + status: "running", + activeTurnId: deferred.turnId, + updatedAt: yield* nowIso, + }; + yield* emit({ + ...(yield* baseEvent(context)), + ...(context.taskId !== undefined + ? { + eventId: EventId.make(`aether:${context.taskId}:turn:${deferred.wireTurnId}:started`), + } + : {}), + turnId: deferred.turnId, + type: "turn.started", + payload: {}, + }); + }); + + /** Bookkeeping when a terminal settle for `turnId` has just been emitted. */ + const onTurnSettled = (context: AetherSessionContext, turnId: TurnId | undefined) => + Effect.gen(function* () { + if (turnId === undefined) { + return; + } + context.deferredTurns = context.deferredTurns.filter( + (candidate) => candidate.turnId !== turnId, + ); + if (context.activeTurn?.turnId === turnId) { + context.activeTurn = undefined; + } + const nextActive = + context.activeTurn?.turnId ?? + context.deferredTurns[context.deferredTurns.length - 1]?.turnId; + const session: ProviderSession = { + ...context.session, + status: nextActive !== undefined ? "running" : "ready", + updatedAt: yield* nowIso, + }; + // `activeTurnId` is optional — rebuild without the key when cleared. + if (nextActive !== undefined) { + context.session = { ...session, activeTurnId: nextActive }; + } else { + const { activeTurnId: _cleared, ...rest } = session; + context.session = rest; + } + const resumeCursor = buildAetherResumeCursor(context); + if (resumeCursor !== undefined) { + context.session = { ...context.session, resumeCursor }; + } + }); + + /** + * THE event funnel: every mapper output batch flows through here. The + * mapper stays pure — its `turn.completed` IS the pre-settle signal, and + * this funnel turns it into sync-then-settle when a mirror is active. It + * also owns the deferred steer `turn.started` ordering: strictly after the + * predecessor's `turn.completed`, strictly before the new turn's first + * event (spec §2.1 queued/steering row). + */ + const processMapperEvents = ( + context: AetherSessionContext, + events: ReadonlyArray, + ) => + Effect.gen(function* () { + // Resume-onto-processing adoption (spec §2.3): the session resumed + // while a turn was already in flight, so the first observation of the + // active wire turn reconstructs activeTurnId, emits its turn.started + // (BEFORE the batch's own events) and arms the settle backstop poll — + // Stop and the mandatory dual-path settle work immediately, not only + // after the next sendTurn. + const adoptWire = context.mapper?.activeWireTurnId(); + if ( + context.adoptActiveTurn && + adoptWire !== undefined && + context.activeTurn === undefined && + !context.deferredTurns.some((candidate) => candidate.wireTurnId === adoptWire) + ) { + context.adoptActiveTurn = false; + const adopted: AetherActiveTurn = { + wireTurnId: adoptWire, + turnId: turnIdForWire(adoptWire), + }; + context.activeTurn = adopted; + yield* emitTurnStarted(context, adopted, {}); + yield* startSettlePoll(context); + } + for (const event of events) { + const deferredMatch = + event.turnId !== undefined + ? context.deferredTurns.find((candidate) => candidate.turnId === event.turnId) + : undefined; + if (deferredMatch !== undefined) { + // Remote pickup observed (an event of the deferred turn — possibly + // its own settle): start the turn before forwarding it. The + // predecessor's turn.completed already flowed earlier in this + // batch (the mapper settles a displaced turn first). + yield* emitDeferredStarted(context, deferredMatch.wireTurnId); + } + if (event.type === "turn.completed") { + yield* syncMirrorForSettle(context, event.turnId); + yield* emit(event); + yield* onTurnSettled(context, event.turnId); + } else { + yield* emit(event); + } + } + // Pickup can also surface as a bare `activeProcessingTurn` flip in the + // delta (no rows for the new turn yet). + const activeWire = context.mapper?.activeWireTurnId(); + if ( + activeWire !== undefined && + context.deferredTurns.some((candidate) => candidate.wireTurnId === activeWire) + ) { + yield* emitDeferredStarted(context, activeWire); + } + }); + + /** + * The session scope, mapper and durable reconciliation — everything the + * turn engine needs BEFORE any transport exists. Split out of + * `ensureTaskPipeline` on purpose: sendTurn must be able to record its new + * turn (mapper + activeTurn + `turn.started`) while nothing can observe a + * settle yet, and the pump fork is exactly what starts observing. + */ + const ensureTaskMapper = Effect.fn("ensureAetherTaskMapper")(function* ( + context: AetherSessionContext, + restClient: AetherRestClient, + taskId: string, + ) { + if (context.sessionScope === undefined) { + context.sessionScope = yield* Scope.make(); + } + const sessionScope = context.sessionScope; + const threadId = context.session.threadId; + + if (context.mapper === undefined) { + const mapper = makeAetherEventMapper({ + provider: PROVIDER, + instanceId: options.instanceId, + threadId, + taskId, + initialSequence: context.latestSequence, + }); + context.mapper = mapper; + // The durable reconciliation — also the settle backstop the turn poll + // drives. A transient REST failure warns loudly and leaves the cursor + // untouched, so the next beat retries the exact same range. + context.reconcile = Effect.gen(function* () { + const delta = yield* restClient.getConversationDelta(taskId, mapper.latestSequence()); + const events = mapper.reconcileDelta(delta, yield* nowIso); + context.latestSequence = mapper.latestSequence(); + yield* processMapperEvents(context, events); + }).pipe( + Effect.catch((error) => + Effect.gen(function* () { + yield* Effect.logWarning("aether.reconcile.failed", { taskId, error: String(error) }); + yield* emit({ + ...(yield* baseEvent(context)), + type: "runtime.warning", + payload: { + message: `Could not reconcile the Aether conversation feed: ${error.message}`, + }, + }); + }), + ), + ); + } + return { mapper: context.mapper, sessionScope }; + }); - let sessionStartedEmitted = false; + /** + * Ensure the mapper exists and (when transport options exist) fork the WS + * attach pump into the session scope. Idempotent per (session, task): + * re-invoked by sendTurn to restart an ended pump with the one-shot + * `start=true` permission. + * + * CALL ORDER: every caller that is about to start a turn must record that + * turn FIRST — the attach's `onConnected` reconcile runs on the forked + * fiber and can settle it immediately. + */ + const ensureTaskPipeline = Effect.fn("ensureAetherTaskPipeline")(function* ( + context: AetherSessionContext, + restClient: AetherRestClient, + taskId: string, + input: { readonly allowStart: boolean }, + ) { + const { mapper, sessionScope } = yield* ensureTaskMapper(context, restClient, taskId); + const reconcile = context.reconcile ?? Effect.void; + + const socket = options.socket; + if (socket === undefined) { + // REST-only mode (no transport configured): the settle backstop poll + // is the only feed. Nothing to fork here. + return; + } + // One pump per session at a time. A running pump reconnects by itself; + // a pump that ENDED (durable-only mode, terminal failure) is restarted + // here on the next sendTurn — with the one-shot start permission. + if (context.pumpRunning) { + return; + } + context.pumpRunning = true; + + let sessionStartedEmitted = context.sessionStartedEmitted; let slashCommandsLogged = false; // Degradation warning: once per failure streak, reset on reconnect. let connectRetryWarned = false; @@ -438,23 +918,26 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( ? { webSocketFactory: socket.webSocketFactory } : {}), ...(socket.timing !== undefined ? { timing: socket.timing } : {}), - onConnected: () => + ...(input.allowStart ? { startOnFirstAttach: true } : {}), + onConnected: (connection) => Effect.gen(function* () { connectRetryWarned = false; + context.connection = connection; if (!sessionStartedEmitted) { sessionStartedEmitted = true; + context.sessionStartedEmitted = true; yield* emit({ - eventId: yield* freshEventId, - provider: PROVIDER, - providerInstanceId: options.instanceId, - threadId, - createdAt: yield* nowIso, + ...(yield* baseEvent(context)), type: "session.started", payload: { message: "Attached to the Aether workspace stream." }, }); } yield* reconcile; }), + onDisconnected: () => + Effect.sync(() => { + context.connection = undefined; + }), onEvent: (event) => Effect.gen(function* () { if (event.kind === "slash_commands.updated" && !slashCommandsLogged) { @@ -464,17 +947,13 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( } const events = mapper.mapWsEvent(event, yield* nowIso); context.latestSequence = mapper.latestSequence(); - yield* emitAll(events); + yield* processMapperEvents(context, events); }), onFrameDropped: (problem) => Effect.gen(function* () { yield* Effect.logWarning("aether.frame.dropped", { taskId, ...problem }); yield* emit({ - eventId: yield* freshEventId, - provider: PROVIDER, - providerInstanceId: options.instanceId, - threadId, - createdAt: yield* nowIso, + ...(yield* baseEvent(context)), type: "runtime.warning", payload: { message: @@ -494,11 +973,7 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( if (!connectRetryWarned) { connectRetryWarned = true; yield* emit({ - eventId: yield* freshEventId, - provider: PROVIDER, - providerInstanceId: options.instanceId, - threadId, - createdAt: yield* nowIso, + ...(yield* baseEvent(context)), type: "runtime.warning", payload: { message: @@ -525,11 +1000,8 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( // The task-errored surface shares the mapper's deterministic id // so a REST-side projection of the same failure collides // (idempotent) instead of duplicating. - eventId: isTaskErrored ? EventId.make(`aether:${taskId}:errored`) : yield* freshEventId, - provider: PROVIDER, - providerInstanceId: options.instanceId, - threadId, - createdAt: yield* nowIso, + ...(yield* baseEvent(context)), + ...(isTaskErrored ? { eventId: EventId.make(`aether:${taskId}:errored`) } : {}), type: "runtime.error", payload: { message: error.message, @@ -538,10 +1010,48 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( }); }), ), + Effect.ensuring( + Effect.sync(() => { + context.pumpRunning = false; + context.connection = undefined; + }), + ), Effect.forkIn(sessionScope), ); }); + /** + * The REST settle backstop (spec build item 7): while a turn is active, + * poll the durable feed every ~settlePollMs — turn.completed/failed are + * live-only, so this is the ONLY settle recovery while the socket is down. + * Also drives the user-activity keep-alive so the VM's interactive idle + * hold survives a long turn. + */ + const startSettlePoll = Effect.fn("startAetherSettlePoll")(function* ( + context: AetherSessionContext, + ) { + if (context.pollRunning || context.sessionScope === undefined) { + return; + } + context.pollRunning = true; + yield* Effect.gen(function* () { + while (context.activeTurn !== undefined || context.deferredTurns.length > 0) { + yield* Effect.sleep(Duration.millis(turnTiming.settlePollMs)); + if (context.connection !== undefined) { + yield* context.connection.sendUserActivity().pipe(Effect.ignore); + } + yield* context.reconcile ?? Effect.void; + } + }).pipe( + Effect.ensuring( + Effect.sync(() => { + context.pollRunning = false; + }), + ), + Effect.forkIn(context.sessionScope), + ); + }); + const startSession: ProviderAdapterShape["startSession"] = Effect.fn( "startSession", )(function* (input) { @@ -559,11 +1069,17 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( }); } - // (1) Mirror preflight: clean tree on a pushed, in-sync branch. + // (1) Mirror preflight. A FRESH thread requires a clean tree on a + // pushed, in-sync branch — the mirror's base state. A RESUMED thread's + // mirror is dirty BY DESIGN (it holds the applied cumulative diff), so + // only the structural checks apply; content integrity is enforced by the + // sync engine's fingerprint verify instead. + const resume = parseAetherResume(input.resumeCursor); + const isResume = resume !== undefined; const status = yield* options.git .statusDetails(cwd) .pipe(Effect.mapError(toGitRequestError("startSession"))); - const issue = preflightIssue(status, cwd); + const issue = isResume ? resumePreflightIssue(status, cwd) : preflightIssue(status, cwd); if (issue !== undefined) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, @@ -571,6 +1087,18 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( issue, }); } + // The mirror baseline: for a never-synced thread the expected pre-sync + // state is "clean tree at this HEAD". + const baselineHeadSha = yield* options.git + .execute({ + operation: "aether.startSession.baseline", + cwd, + args: ["rev-parse", "HEAD"], + }) + .pipe( + Effect.map((result) => result.stdout.trim()), + Effect.mapError(toGitRequestError("startSession")), + ); // (2) Repo → project resolution via the canonical owner/repo key. const originUrl = yield* options.git @@ -612,10 +1140,10 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( // belong to the project the cwd just resolved to — a persisted cursor is // untrusted input, and binding a foreign project's task here would later // mirror that repo's diffs onto this checkout. - const resume = parseAetherResume(input.resumeCursor); let taskId: string | undefined; let latestSequence = 0; let turnLedger: unknown; + let resumedTask: AetherTask | undefined; if (resume !== undefined) { const task = yield* restClient.getTask(resume.taskId).pipe( Effect.mapError((cause) => @@ -636,6 +1164,7 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( }); } taskId = resume.taskId; + resumedTask = task; // Keep the CURSOR's sequence, not the task row's: it is the safe // replay point — fast-forwarding here would skip never-ingested rows. latestSequence = resume.latestSequence; @@ -662,11 +1191,32 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( }, cwd, projectId: project.id, + baseBranch: status.branch ?? undefined, taskId, latestSequence, turnLedger, sessionScope: undefined, mapper: undefined, + mirror: makeAetherMirrorSync({ + cwd, + git: options.git, + baselineHeadSha, + persistedFingerprint: resume?.mirrorFingerprint, + // Lazily reads the surrounding context: the first sendTurn fills the + // task id in before any turn can settle. + getTaskId: () => context.taskId, + }), + connection: undefined, + reconcile: undefined, + pumpRunning: false, + sessionStartedEmitted: false, + activeTurn: undefined, + adoptActiveTurn: resumedTask?.status === "processing", + deferredTurns: [], + pollRunning: false, + sentCount: 0, + firstTurnPending: false, + firstTurnFingerprint: undefined, }; const resumeCursor = buildAetherResumeCursor(context); if (resumeCursor !== undefined) { @@ -677,14 +1227,17 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( const previous = sessions.get(input.threadId); if (previous !== undefined) { yield* closeSessionScope(previous); + yield* options.mirrorRegistry.deregister(previous.cwd, registryKey(input.threadId)); } sessions.set(input.threadId, context); + // The fork-side write guard owns this cwd for the thread's lifetime. + yield* options.mirrorRegistry.register(cwd, registryKey(input.threadId)); - // (5) Passive stream attach: a resumed live task starts streaming - // immediately. No task yet (fresh thread) → nothing to attach until the - // first sendTurn (T6) creates one. - if (taskId !== undefined && options.socket !== undefined) { - yield* startStreamPump(context, restClient, options.socket, taskId); + // (5) Stream attach: a resumed live task starts streaming immediately + // (PASSIVE — never boots a VM). No task yet (fresh thread) → nothing to + // attach until the first sendTurn creates one. + if (taskId !== undefined) { + yield* ensureTaskPipeline(context, restClient, taskId, { allowStart: false }); } return context.session; }); @@ -700,6 +1253,9 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( ) { yield* closeSessionScope(context); sessions.delete(threadId); + // Release the fork-side write guard: the cwd is an ordinary local + // checkout again the moment the Aether thread lets go of it. + yield* options.mirrorRegistry.deregister(context.cwd, registryKey(threadId)); yield* emit({ eventId: yield* randomEventId, provider: PROVIDER, @@ -777,6 +1333,456 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( } satisfies ProviderThreadSnapshot; }); + // -- turn lifecycle (build item 7) ---------------------------------------- + + /** Validate + encode t3 chat attachments into Aether prompt attachments. */ + const buildPromptAttachments = Effect.fn("buildAetherAttachments")(function* ( + attachments: ReadonlyArray, + ): Effect.fn.Return | undefined, ProviderAdapterError> { + if (attachments.length === 0) { + return undefined; + } + const built: Array = []; + for (const attachment of attachments) { + // Aether validates a lowercase, parameter-free media type against its + // platform allowlist — reject locally BEFORE any API call. + const mediaType = (attachment.mimeType.split(";")[0] ?? "").trim().toLowerCase(); + if (!AETHER_ATTACHMENT_MEDIA_TYPES.has(mediaType)) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `Attachment '${attachment.name}' has media type '${mediaType}', which Aether does not accept. Supported: ${[...AETHER_ATTACHMENT_MEDIA_TYPES].sort().join(", ")}.`, + }); + } + if (attachment.sizeBytes > AETHER_ATTACHMENT_MAX_BYTES) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `Attachment '${attachment.name}' is ${attachment.sizeBytes} bytes; Aether accepts at most ${AETHER_ATTACHMENT_MAX_BYTES} bytes (5 MiB) per attachment.`, + }); + } + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: options.attachmentsDir, + attachment, + }); + if (attachmentPath === null) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "sendTurn", + detail: `Invalid attachment id '${attachment.id}'.`, + }); + } + const bytes = yield* fileSystem.readFile(attachmentPath).pipe( + Effect.mapError( + (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "sendTurn", + detail: `Could not read attachment '${attachment.name}': ${cause.message}`, + cause, + }), + ), + ); + if (bytes.length > AETHER_ATTACHMENT_MAX_BYTES) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `Attachment '${attachment.name}' decodes to ${bytes.length} bytes; Aether accepts at most ${AETHER_ATTACHMENT_MAX_BYTES} bytes (5 MiB) per attachment.`, + }); + } + built.push({ + filename: attachment.name, + mediaType, + data: Buffer.from(bytes).toString("base64"), + }); + } + return built; + }); + + /** + * Reasoning effort from the model selection's option descriptor selection. + * Catalog models validate against their selectable set — Aether 422s + * non-selectable values, so an invalid selection fails HERE, loudly. + */ + const resolveEffortSelection = ( + selection: + | { + readonly options?: ReadonlyArray<{ + readonly id: string; + readonly value: string | boolean; + }>; + } + | undefined, + resolved: ReturnType, + ): Effect.Effect => + Effect.gen(function* () { + const raw = selection?.options?.find((option) => option.id === "reasoningEffort")?.value; + if (raw === undefined) { + return undefined; + } + if (typeof raw !== "string") { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `Reasoning effort selection must be a string, got ${typeof raw}.`, + }); + } + if (resolved.catalogAgentType !== undefined) { + const allowed = reasoningEffortsForModel(resolved.catalogAgentType, resolved.model); + if (!allowed.includes(raw)) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `Reasoning effort '${raw}' is not selectable for ${resolved.catalogAgentType}/${resolved.model}. Selectable: ${allowed.join(", ") || "(none)"}.`, + }); + } + } + return raw; + }); + + /** + * Turn 1's wire turn id is the first user row's id — `POST /tasks` returns + * `{id, name}` only, so it is harvested from the conversation delta + * (spec resolved note 7). + */ + const harvestFirstUserRowId = Effect.fn("harvestAetherFirstUserRow")(function* ( + restClient: AetherRestClient, + taskId: string, + ): Effect.fn.Return { + for (let attempt = 1; attempt <= turnTiming.harvestMaxAttempts; attempt++) { + const delta = yield* restClient + .getConversationDelta(taskId, 0) + .pipe(Effect.mapError(toRestRequestError("sendTurn"))); + const userRow = [...delta.messages] + .filter((row) => row.role === "user") + .sort((left, right) => left.sequence - right.sequence)[0]; + if (userRow !== undefined) { + return userRow.id; + } + yield* Effect.sleep(Duration.millis(turnTiming.harvestPollMs)); + } + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "sendTurn", + detail: `Created Aether task '${taskId}' but its first user message row never appeared in the conversation feed.`, + }); + }); + + const emitTurnStarted = ( + context: AetherSessionContext, + turn: AetherActiveTurn, + payload: { readonly model?: string; readonly effort?: string }, + ) => + Effect.gen(function* () { + context.session = { + ...context.session, + status: "running", + activeTurnId: turn.turnId, + updatedAt: yield* nowIso, + }; + const resumeCursor = buildAetherResumeCursor(context); + if (resumeCursor !== undefined) { + context.session = { ...context.session, resumeCursor }; + } + yield* emit({ + ...(yield* baseEvent(context)), + ...(context.taskId !== undefined + ? { eventId: EventId.make(`aether:${context.taskId}:turn:${turn.wireTurnId}:started`) } + : {}), + turnId: turn.turnId, + type: "turn.started", + payload: { + ...(payload.model !== undefined ? { model: payload.model } : {}), + ...(payload.effort !== undefined ? { effort: payload.effort } : {}), + }, + }); + }); + + const sendTurn: ProviderAdapterShape["sendTurn"] = Effect.fn("sendTurn")( + function* (input) { + const context = yield* ensureContext(input.threadId); + const restClient = yield* requireRestClient("sendTurn"); + if ( + input.modelSelection !== undefined && + input.modelSelection.instanceId !== options.instanceId + ) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `Aether model selection is bound to instance '${input.modelSelection.instanceId}', expected '${options.instanceId}'.`, + }); + } + const message = input.input?.trim(); + if (message === undefined || message.length === 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: "Aether requires a non-empty text prompt for every turn.", + }); + } + // Attachments validate + encode BEFORE any API call. + const attachments = yield* buildPromptAttachments(input.attachments ?? []); + const promptContext = attachments !== undefined ? { attachments } : undefined; + + // -- first turn: create the cloud task -------------------------------- + if (context.taskId === undefined || context.firstTurnPending) { + const slug = input.modelSelection?.model ?? context.session.model; + if (slug === undefined) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "sendTurn", + detail: "The session carries no model slug; cannot dispatch an Aether task.", + }); + } + const resolved = resolveAetherModelSlug(slug); + const effort = yield* resolveEffortSelection(input.modelSelection, resolved); + // Length-prefixed, control-char-delimited — deterministic without + // JSON (repo lint prefers Schema codecs for real serialization; this + // string is only ever compared, never parsed). + const dispatchFingerprint = [ + `${message.length}:${message}`, + slug, + effort ?? "", + input.interactionMode ?? "default", + ...(attachments ?? []).map( + (attachment) => + `${attachment.filename}\u0000${attachment.mediaType}\u0000${attachment.data.length}:${attachment.data}`, + ), + ].join("\u0001"); + if (context.taskId === undefined) { + const created = yield* restClient + .createTask({ + project_id: context.projectId, + prompt: message, + ...(context.baseBranch !== undefined ? { base_branch: context.baseBranch } : {}), + ...(promptContext !== undefined ? { context: promptContext } : {}), + agent_type: resolved.agentType, + model: resolved.model, + interaction_mode: input.interactionMode ?? "default", + ...(effort !== undefined ? { reasoning_effort: effort } : {}), + auto_fix_ci: false, + auto_fix_pr_comments: false, + auto_rebase: false, + }) + .pipe(Effect.mapError(toRestRequestError("sendTurn"))); + context.taskId = created.id; + context.sentCount = 1; + context.firstTurnPending = true; + context.firstTurnFingerprint = dispatchFingerprint; + } else if (context.firstTurnFingerprint !== dispatchFingerprint) { + // The created task already carries the first dispatch; a retry + // with different text, attachments, model or mode would silently + // discard one of the two. Refuse. + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: + "The first prompt was already dispatched to Aether but its turn is still being brought up. Retry with exactly the same input, or wait for the turn to appear and send the change as a follow-up.", + }); + } + const taskIdForFirstTurn = context.taskId; + // Turn 1's wire id = the first user row (nothing else names it). + // Harvest/attach failures leave firstTurnPending set: the retry + // re-enters HERE — never the respond path, never a second create. + const wireTurnId = yield* harvestFirstUserRowId(restClient, taskIdForFirstTurn); + const turn: AetherActiveTurn = { wireTurnId, turnId: turnIdForWire(wireTurnId) }; + // RECORD THE TURN FIRST. The mapper must learn the active wire turn + // so a settle observed only through the REST backstop still lands — + // and the attach below forks a fiber whose onConnected reconcile can + // settle this very turn on its first beat. Attaching before the turn + // exists lets `turn.completed` (and onTurnSettled's clean-up) run + // against a turn that was never started, stranding activeTurn state. + const { mapper } = yield* ensureTaskMapper(context, restClient, taskIdForFirstTurn); + yield* processMapperEvents(context, mapper.noteTurnStarted(wireTurnId, yield* nowIso)); + context.activeTurn = turn; + yield* emitTurnStarted(context, turn, { + model: slug, + ...(effort !== undefined ? { effort } : {}), + }); + // ACTIVE attach — the one path allowed to pass start=true. + yield* ensureTaskPipeline(context, restClient, taskIdForFirstTurn, { allowStart: true }); + yield* startSettlePoll(context); + context.firstTurnPending = false; + context.firstTurnFingerprint = undefined; + const resumeCursor = buildAetherResumeCursor(context); + return { + threadId: input.threadId, + turnId: turn.turnId, + ...(resumeCursor !== undefined ? { resumeCursor } : {}), + }; + } + + // -- later turns: respond --------------------------------------------- + const taskId = context.taskId; + if ( + input.modelSelection !== undefined && + input.modelSelection.model !== context.session.model + ) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `Mid-thread model switch to '${input.modelSelection.model}' is not supported yet (Aether driver build item 11).`, + }); + } + const clientMessageId = deterministicClientMessageId({ + taskId, + sessionEpoch: context.session.createdAt, + sendOrdinal: context.sentCount, + }); + const responded = yield* restClient + .respondToTask(taskId, { + message, + ...(promptContext !== undefined ? { context: promptContext } : {}), + ...(input.interactionMode !== undefined + ? { interaction_mode: input.interactionMode } + : {}), + client_message_id: clientMessageId, + }) + .pipe(Effect.mapError(toRestRequestError("sendTurn"))); + // Advance the ordinal only on a confirmed 202: a respond whose answer + // was lost in transit retries with the SAME client_message_id, so the + // server's ON CONFLICT dedupe can actually fire — incrementing before + // the call would burn the id and deliver the prompt twice. + context.sentCount++; + // The user is driving this thread now — a pending resume-adoption of a + // remotely running turn no longer applies. + context.adoptActiveTurn = false; + const wireTurnId = responded.message_id; + const turn: AetherActiveTurn = { wireTurnId, turnId: turnIdForWire(wireTurnId) }; + + if (context.activeTurn !== undefined || context.deferredTurns.length > 0) { + // STEER: Aether queues the message server-side; the running turn + // completes first. DEFER turn.started until remote pickup, but set + // the session's activeTurnId to the new turn NOW (spec §2.1 + // queued/steering row). + context.deferredTurns.push({ ...turn, text: message }); + context.session = { + ...context.session, + activeTurnId: turn.turnId, + updatedAt: yield* nowIso, + }; + yield* startSettlePoll(context); + const resumeCursor = buildAetherResumeCursor(context); + return { + threadId: input.threadId, + turnId: turn.turnId, + ...(resumeCursor !== undefined ? { resumeCursor } : {}), + }; + } + + // Idle task: the respond dispatches immediately. The turn is recorded + // BEFORE the attach for the same reason as the create path — a pump + // restarted here reconciles on its first beat and can settle it. + const { mapper } = yield* ensureTaskMapper(context, restClient, taskId); + context.activeTurn = turn; + yield* processMapperEvents(context, mapper.noteTurnStarted(wireTurnId, yield* nowIso)); + yield* emitTurnStarted(context, turn, {}); + // Re-attach if the pump ended (suspended VM) — active, may start. + yield* ensureTaskPipeline(context, restClient, taskId, { allowStart: true }); + yield* startSettlePoll(context); + const resumeCursor = buildAetherResumeCursor(context); + return { + threadId: input.threadId, + turnId: turn.turnId, + ...(resumeCursor !== undefined ? { resumeCursor } : {}), + }; + }, + ); + + const interruptTurn: ProviderAdapterShape["interruptTurn"] = Effect.fn( + "interruptTurn", + )(function* (threadId, _turnId) { + const context = yield* ensureContext(threadId); + const restClient = yield* requireRestClient("interruptTurn"); + const taskId = context.taskId; + if (taskId === undefined) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "interruptTurn", + detail: "The thread has no Aether task yet; there is nothing to interrupt.", + }); + } + const active = context.activeTurn; + const deferred = [...context.deferredTurns]; + if (active === undefined && deferred.length === 0) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "interruptTurn", + detail: "No Aether turn is active on this thread.", + }); + } + // Discarding queued messages is the explicit design choice: keeping them + // would let Aether's done-callback restart the agent and Stop would not + // stick (spec resolved note 6). + yield* restClient + .stopTask(taskId, { discardQueuedMessages: true }) + .pipe(Effect.mapError(toRestRequestError("interruptTurn"))); + // The settle — whichever transport observes it — must read `interrupted`. + // Marked only AFTER the stop 200: a failed stop leaves the turn running + // remotely, and the mapper flag is sticky — marking optimistically would + // falsify a later natural settle into 'interrupted'. + if (active !== undefined) { + context.mapper?.markInterrupted(active.wireTurnId); + } + // Re-offer any driver-queued (steer) message text the stop discarded, + // and cancel their deferred turn.starteds — the thread must stay idle. + context.deferredTurns = []; + for (const discarded of deferred) { + yield* emit({ + ...(yield* baseEvent(context)), + type: "runtime.warning", + payload: { + message: `Stopping discarded your queued message. You can send it again:\n\n${discarded.text}`, + }, + }); + } + // Read-side confirmation: settle ONLY once the task row has actually + // left processing — never optimistically on the 200. + let confirmed: AetherTask | undefined; + for (let attempt = 1; attempt <= turnTiming.interruptMaxAttempts; attempt++) { + const task = yield* restClient + .getTask(taskId) + .pipe(Effect.mapError(toRestRequestError("interruptTurn"))); + if (task.status !== "processing" && task.status !== "queued") { + confirmed = task; + break; + } + yield* Effect.sleep(Duration.millis(turnTiming.interruptPollMs)); + } + if (confirmed === undefined) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "interruptTurn", + detail: `Aether task '${taskId}' is still processing after the stop request; the interrupt could not be confirmed.`, + }); + } + // Settle through the standard pipeline: mirror sync runs, the mapper's + // interrupt flag turns the settle into state=interrupted, and if the + // live path already settled the turn this emits nothing extra. + if (context.mapper !== undefined) { + yield* processMapperEvents(context, context.mapper.reconcileTask(confirmed, yield* nowIso)); + } + // Every DISCARDED deferred turn needs its own terminal settle: it was + // announced through session.activeTurnId at queue time, but the remote + // never picked it up, so neither the mapper nor the reconcile above will + // ever settle it — without this a stop pressed while ONLY a queued steer + // exists leaves the session running on a turn that no longer exists. + for (const discarded of deferred) { + yield* emit({ + ...(yield* baseEvent(context)), + eventId: EventId.make(`aether:${taskId}:turn:${discarded.wireTurnId}:settled`), + turnId: discarded.turnId, + type: "turn.completed", + payload: { state: "interrupted" }, + }); + yield* onTurnSettled(context, discarded.turnId); + } + // The session must not stay wedged on a turn the remote no longer runs. + if (context.activeTurn !== undefined && context.activeTurn.turnId === active?.turnId) { + yield* onTurnSettled(context, active.turnId); + } + }); + return { provider: PROVIDER, capabilities: { @@ -784,8 +1790,8 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( sessionModelSwitch: "unsupported", }, startSession, - sendTurn: () => notImplemented("sendTurn"), - interruptTurn: () => notImplemented("interruptTurn"), + sendTurn, + interruptTurn, respondToRequest: () => notImplemented("respondToRequest"), respondToUserInput: () => notImplemented("respondToUserInput"), stopSession, diff --git a/apps/server/src/provider/Layers/AetherProvider.test.ts b/apps/server/src/provider/Layers/AetherProvider.test.ts index dadefb12228e..531f52229c49 100644 --- a/apps/server/src/provider/Layers/AetherProvider.test.ts +++ b/apps/server/src/provider/Layers/AetherProvider.test.ts @@ -66,24 +66,23 @@ describe("makePendingAetherProvider", () => { Effect.gen(function* () { const snapshot = yield* makePendingAetherProvider(decodeAetherSettings({ enabled: false })); expect(snapshot.enabled).toBe(false); - expect(snapshot.installed).toBe(false); + // Cloud API: no binary, installed is unconditionally true. + expect(snapshot.installed).toBe(true); expect(snapshot.message).toContain("disabled"); - expect(snapshot.availability).toBe("unavailable"); + expect(snapshot.availability).toBeUndefined(); }), ); it.effect("returns a pending snapshot carrying the vendored catalog by default", () => Effect.gen(function* () { const snapshot = yield* makePendingAetherProvider(enabledSettings); - // T6 flips enabled/installed back on: until the turn protocol exists, - // the pending snapshot must be unselectable on every client — the - // composer keys on enabled && isAvailable, mobile on - // enabled/installed/auth — so all gate flags hold at once. - expect(snapshot.enabled).toBe(false); - expect(snapshot.installed).toBe(false); - expect(snapshot.availability).toBe("unavailable"); - expect(snapshot.unavailableReason).toBeTruthy(); - expect(snapshot.status).toBe("disabled"); + // T6 flipped the preview gate: the turn protocol is real, so a healthy + // instance is selectable end to end (no availability stamp). + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); + expect(snapshot.availability).toBeUndefined(); + expect(snapshot.unavailableReason).toBeUndefined(); + expect(snapshot.status).toBe("warning"); expect(snapshot.version).toBeNull(); expect(snapshot.message).toContain("not been checked"); expect(snapshot.models.length).toBeGreaterThan(0); @@ -109,7 +108,7 @@ describe("checkAetherProviderStatus", () => { const snapshot = yield* checkAetherProviderStatus(enabledSettings, {}).pipe( Effect.provideService(HttpClient.HttpClient, failingClient()), ); - expect(snapshot.status).toBe("disabled"); + expect(snapshot.status).toBe("error"); expect(snapshot.auth.status).toBe("unauthenticated"); expect(snapshot.message).toContain(AETHER_API_KEY_ENV_VAR); }), @@ -120,7 +119,7 @@ describe("checkAetherProviderStatus", () => { const snapshot = yield* checkAetherProviderStatus(enabledSettings, { [AETHER_API_KEY_ENV_VAR]: " ", }).pipe(Effect.provideService(HttpClient.HttpClient, failingClient())); - expect(snapshot.status).toBe("disabled"); + expect(snapshot.status).toBe("error"); expect(snapshot.auth.status).toBe("unauthenticated"); }), ); @@ -133,7 +132,7 @@ describe("checkAetherProviderStatus", () => { respondingClient(() => new Response(null, { status: 401 })), ), ); - expect(snapshot.status).toBe("disabled"); + expect(snapshot.status).toBe("error"); expect(snapshot.auth.status).toBe("unauthenticated"); expect(snapshot.message).toContain("Invalid Aether API key"); }), @@ -147,7 +146,7 @@ describe("checkAetherProviderStatus", () => { respondingClient(() => new Response(null, { status: 503 })), ), ); - expect(snapshot.status).toBe("disabled"); + expect(snapshot.status).toBe("error"); expect(snapshot.auth.status).toBe("unknown"); expect(snapshot.message).toContain("HTTP 503"); }), @@ -158,7 +157,7 @@ describe("checkAetherProviderStatus", () => { const snapshot = yield* checkAetherProviderStatus(enabledSettings, keyedEnvironment).pipe( Effect.provideService(HttpClient.HttpClient, failingClient()), ); - expect(snapshot.status).toBe("disabled"); + expect(snapshot.status).toBe("error"); expect(snapshot.message).toContain("Couldn't reach the Aether API"); }), ); @@ -171,7 +170,7 @@ describe("checkAetherProviderStatus", () => { respondingClient(() => new Response("not json", { status: 200 })), ), ); - expect(snapshot.status).toBe("disabled"); + expect(snapshot.status).toBe("error"); expect(snapshot.message).toContain("unexpected /profile payload"); }), ); @@ -193,21 +192,19 @@ describe("checkAetherProviderStatus", () => { ); expect(seen?.url).toBe("https://api.example.test/profile"); expect(seen?.headers["authorization"]).toBe("Bearer test-key"); - expect(snapshot.status).toBe("disabled"); expect(snapshot.auth).toEqual({ status: "authenticated", type: "aether", email: "dev@example.test", }); expect(snapshot.message).toBe("Connected to Aether as dev@example.test."); - // T6 flips this: until the turn protocol exists, even a healthy, - // authenticated instance must stay unselectable on every client — - // availability for clients that honor it, enabled/installed for the - // ones (mobile) that do not. - expect(snapshot.availability).toBe("unavailable"); - expect(snapshot.unavailableReason).toBeTruthy(); - expect(snapshot.enabled).toBe(false); - expect(snapshot.installed).toBe(false); + // UN-gated shape (T6): a healthy probe is ready, authenticated and + // picker-eligible — no availability stamp, enabled+installed true. + expect(snapshot.status).toBe("ready"); + expect(snapshot.availability).toBeUndefined(); + expect(snapshot.unavailableReason).toBeUndefined(); + expect(snapshot.enabled).toBe(true); + expect(snapshot.installed).toBe(true); }), ); @@ -219,7 +216,7 @@ describe("checkAetherProviderStatus", () => { respondingClient(() => Response.json({})), ), ); - expect(snapshot.status).toBe("disabled"); + expect(snapshot.status).toBe("ready"); expect(snapshot.auth).toEqual({ status: "authenticated", type: "aether" }); expect(snapshot.message).toBe("Connected to Aether."); }), diff --git a/apps/server/src/provider/Layers/AetherProvider.ts b/apps/server/src/provider/Layers/AetherProvider.ts index 9a7fe1819817..f84571997312 100644 --- a/apps/server/src/provider/Layers/AetherProvider.ts +++ b/apps/server/src/provider/Layers/AetherProvider.ts @@ -134,22 +134,6 @@ export function readAetherApiKey(environment: NodeJS.ProcessEnv): string | undef const MISSING_KEY_MESSAGE = `No Aether API key configured. Add a sensitive ${AETHER_API_KEY_ENV_VAR} environment variable to this provider instance.`; -/** - * T6 removes this gate. Until the turn protocol exists (T3–T6), any - * selectable Aether snapshot routes turns into the not-implemented adapter. - * The composer resolves send availability from `enabled && isAvailable`, and - * mobile's model options check only enabled/installed/auth — so the gate must - * hold on ALL of them at once, per the ServerProvider contract that - * `availability: "unavailable"` snapshots set `enabled: false` and - * `installed: false`. Probes still run so key validation surfaces in - * settings via status/auth/message. - */ -const gateUntilTurnProtocol = (base: ServerProviderDraft): ServerProviderDraft => ({ - ...base, - availability: "unavailable", - unavailableReason: "Aether driver preview: sessions arrive in a later update.", -}); - /** Instant zero-I/O draft published while the first probe runs. */ export const makePendingAetherProvider = ( aetherSettings: AetherSettings, @@ -159,38 +143,35 @@ export const makePendingAetherProvider = ( const models = aetherModels(aetherSettings); if (!aetherSettings.enabled) { - return gateUntilTurnProtocol( - buildServerProvider({ - presentation: AETHER_PRESENTATION, - enabled: false, - checkedAt, - models, - probe: { - installed: false, - version: null, - status: "warning", - auth: { status: "unknown" }, - message: "Aether is disabled in T3 Code settings.", - }, - }), - ); - } - - return gateUntilTurnProtocol( - buildServerProvider({ + return buildServerProvider({ presentation: AETHER_PRESENTATION, enabled: false, checkedAt, models, probe: { - installed: false, + // Cloud API — there is no binary to install, ever. + installed: true, version: null, status: "warning", auth: { status: "unknown" }, - message: "Aether provider status has not been checked in this session yet.", + message: "Aether is disabled in T3 Code settings.", }, - }), - ); + }); + } + + return buildServerProvider({ + presentation: AETHER_PRESENTATION, + enabled: true, + checkedAt, + models, + probe: { + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + message: "Aether provider status has not been checked in this session yet.", + }, + }); }); /** @@ -211,21 +192,21 @@ export const checkAetherProviderStatus = Effect.fn("checkAetherProviderStatus")( readonly auth: ServerProviderDraft["auth"]; readonly message: string; }): ServerProviderDraft => - gateUntilTurnProtocol( - buildServerProvider({ - presentation: AETHER_PRESENTATION, - enabled: false, - checkedAt, - models, - probe: { - installed: false, - version: null, - status: probe.status, - auth: probe.auth, - message: probe.message, - }, - }), - ); + buildServerProvider({ + presentation: AETHER_PRESENTATION, + enabled: aetherSettings.enabled, + checkedAt, + models, + probe: { + // Cloud API — no local binary, so "installed" is unconditionally + // true and the status copy never says "Sign in via the CLI". + installed: true, + version: null, + status: probe.status, + auth: probe.auth, + message: probe.message, + }, + }); if (!aetherSettings.enabled) { return draft({ diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 5f593a9f0194..0bd43e6b7036 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -45,6 +45,7 @@ import { selectProvidersByKind, } from "./ProviderRegistry.ts"; import * as GitVcsDriverModule from "../../vcs/GitVcsDriver.ts"; +import * as AetherMirrorRegistryModule from "../AetherMirrorRegistry.ts"; import * as ServerConfig from "../../config.ts"; import * as ServerSettingsModule from "../../serverSettings.ts"; import { readProviderStatusCache, resolveProviderStatusCachePath } from "../providerStatusCache.ts"; @@ -1475,6 +1476,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te // preflight; its own inputs come from ServerConfig below plus // the outer NodeServices layer. Layer.provideMerge(GitVcsDriverModule.layer), + Layer.provideMerge(AetherMirrorRegistryModule.layer), Layer.provideMerge( Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), ), @@ -1572,6 +1574,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te // preflight; its own inputs come from ServerConfig below plus // the outer NodeServices layer. Layer.provideMerge(GitVcsDriverModule.layer), + Layer.provideMerge(AetherMirrorRegistryModule.layer), Layer.provideMerge( Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), ), @@ -1698,6 +1701,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te // preflight; its own inputs come from ServerConfig below plus // the outer NodeServices layer. Layer.provideMerge(GitVcsDriverModule.layer), + Layer.provideMerge(AetherMirrorRegistryModule.layer), Layer.provideMerge( Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), ), @@ -1764,6 +1768,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te // preflight; its own inputs come from ServerConfig below plus // the outer NodeServices layer. Layer.provideMerge(GitVcsDriverModule.layer), + Layer.provideMerge(AetherMirrorRegistryModule.layer), + Layer.provideMerge(AetherMirrorRegistryModule.layer), Layer.provideMerge( Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), ), diff --git a/apps/server/src/provider/Layers/aether/eventMapper.test.ts b/apps/server/src/provider/Layers/aether/eventMapper.test.ts index 93da0bd18c61..ea3958931252 100644 --- a/apps/server/src/provider/Layers/aether/eventMapper.test.ts +++ b/apps/server/src/provider/Layers/aether/eventMapper.test.ts @@ -777,3 +777,43 @@ describe("parseAetherQuestions", () => { expect(issues).toEqual(["ask_user input carries no questions array"]); }); }); + +describe("AetherEventMapper — interrupted turns (T6)", () => { + it("settles an interrupt-flagged turn as interrupted, whichever transport observes it", () => { + const mapper = makeMapper(); + mapper.noteTurnStarted("u1", NOW); + expect(mapper.activeWireTurnId()).toBe("u1"); + mapper.markInterrupted("u1"); + const events = mapper.mapWsEvent(parseFrame(wsTurnCompleted), NOW); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "turn.completed", + turnId: "aether-turn-u1", + payload: { state: "interrupted" }, + }); + expect(mapper.activeWireTurnId()).toBeUndefined(); + }); + + it("suppresses the error card when turn.failed lands after a user stop", () => { + const mapper = makeMapper(); + mapper.noteTurnStarted("u1", NOW); + mapper.markInterrupted("u1"); + const events = mapper.mapWsEvent(parseFrame(wsTurnFailed), NOW); + // A stop often surfaces remotely as a failed turn: the settle reads + // interrupted and NO runtime.error follows — the user asked for it. + expect(events.map((event) => event.type)).toEqual(["turn.completed"]); + expect(events[0]).toMatchObject({ payload: { state: "interrupted" } }); + }); + + it("noteTurnStarted settles a displaced predecessor exactly like an observed transition", () => { + const mapper = makeMapper(); + mapper.noteTurnStarted("u1", NOW); + const events = mapper.noteTurnStarted("u2", NOW); + expect(events.map((event) => event.type)).toEqual(["turn.completed"]); + expect(events[0]).toMatchObject({ + turnId: "aether-turn-u1", + payload: { state: "completed" }, + }); + expect(mapper.activeWireTurnId()).toBe("u2"); + }); +}); diff --git a/apps/server/src/provider/Layers/aether/eventMapper.ts b/apps/server/src/provider/Layers/aether/eventMapper.ts index 429690503ee1..615969269937 100644 --- a/apps/server/src/provider/Layers/aether/eventMapper.ts +++ b/apps/server/src/provider/Layers/aether/eventMapper.ts @@ -91,6 +91,26 @@ export interface AetherEventMapper { readonly reconcileTask: (task: AetherTask, nowIso: string) => ReadonlyArray; /** The highest durable sequence applied so far (in-memory cursor). */ readonly latestSequence: () => number; + /** The wire turn id currently tracked as in flight, if any. */ + readonly activeWireTurnId: () => string | undefined; + /** + * Register a driver-initiated turn (sendTurn minted it from the 202 / + * harvested user row) as the active wire turn, so a settle observed ONLY + * through the REST backstop (durable rows carry no turn ids) still finds + * the turn to settle. Returns the displaced predecessor's settle events, + * exactly like an observed turn transition. + */ + readonly noteTurnStarted: ( + wireTurnId: string, + nowIso: string, + ) => ReadonlyArray; + /** + * Flag a wire turn as user-interrupted (T6 interruptTurn): its single + * terminal settle — whichever transport observes it — emits + * `turn.completed state=interrupted`, and a `turn.failed` twin arriving + * after the stop skips its error card (a stop is not a provider failure). + */ + readonly markInterrupted: (wireTurnId: string) => void; } // --------------------------------------------------------------------------- @@ -298,6 +318,8 @@ export function makeAetherEventMapper(options: AetherEventMapperOptions): Aether const warnedOnce = new Set(); /** The wire turn id currently in flight, for settles observed via REST. */ let activeWireTurnId: string | undefined; + /** Wire turns the user interrupted — their settle state is `interrupted`. */ + const interruptedTurns = new Set(); /** * The session state ingestion currently believes, mirrored so the status * projection (spec §2.1 working-indicator row) emits only on transitions. @@ -671,9 +693,14 @@ export function makeAetherEventMapper(options: AetherEventMapperOptions): Aether if (activeWireTurnId === input.wireTurnId) { activeWireTurnId = undefined; } + // A user-interrupted turn settles as `interrupted` no matter which + // transport observes the settle (spec §2.3 interrupt row). + const state: "completed" | "failed" | "interrupted" = interruptedTurns.has(input.wireTurnId) + ? "interrupted" + : input.state; // Ingestion flips the session to error/ready on a turn settle; mirror it // so the status projection re-emits `running` for the NEXT turn. - lastProjectedState = input.state === "failed" ? "error" : "ready"; + lastProjectedState = state === "failed" ? "error" : "ready"; const errorMessage = trimmedOrUndefined(input.errorMessage); return [ { @@ -684,7 +711,7 @@ export function makeAetherEventMapper(options: AetherEventMapperOptions): Aether }), type: "turn.completed", payload: { - state: input.state, + state, ...(errorMessage !== undefined ? { errorMessage } : {}), }, }, @@ -877,7 +904,15 @@ export function makeAetherEventMapper(options: AetherEventMapperOptions): Aether ...settleTurn({ wireTurnId: event.turnId, state: "completed", createdAt }), ]; - case "turn.failed": + case "turn.failed": { + if (interruptedTurns.has(event.turnId)) { + // A stop often surfaces remotely as a failed turn; the user asked + // for it, so no error card — just the interrupted settle. + return [ + ...trackTurn(event.turnId, createdAt), + ...settleTurn({ wireTurnId: event.turnId, state: "failed", createdAt }), + ]; + } return [ ...trackTurn(event.turnId, createdAt), ...settleTurn({ @@ -900,6 +935,7 @@ export function makeAetherEventMapper(options: AetherEventMapperOptions): Aether payload: { message: event.payload.errorMessage, class: "provider_error" }, }, ]; + } case "turn.awaiting_input": { // An awaiting_input IS a settle: the remote turn ended and parked on @@ -1212,5 +1248,10 @@ export function makeAetherEventMapper(options: AetherEventMapperOptions): Aether reconcileDelta, reconcileTask, latestSequence: () => lastSequence, + activeWireTurnId: () => activeWireTurnId, + noteTurnStarted: (wireTurnId, nowIso) => trackTurn(wireTurnId, stamp(undefined, nowIso)), + markInterrupted: (wireTurnId) => { + interruptedTurns.add(wireTurnId); + }, }; } diff --git a/apps/server/src/provider/Layers/aether/mirrorSync.test.ts b/apps/server/src/provider/Layers/aether/mirrorSync.test.ts new file mode 100644 index 000000000000..17de252ebcd8 --- /dev/null +++ b/apps/server/src/provider/Layers/aether/mirrorSync.test.ts @@ -0,0 +1,842 @@ +/** + * Mirror sync engine acceptance tests (spec build item 8) over REAL temp git + * repositories: an "upstream" repo standing in for origin and a cloned + * "mirror" standing in for the thread's local checkout. The VM side is + * simulated by hand-built structured GitDiffResult fixtures — exactly the + * cumulative merge-base→tree shape the workspace WS serves. + */ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import * as ServerConfig from "../../../config.ts"; +import * as GitVcsDriverModule from "../../../vcs/GitVcsDriver.ts"; +import * as VcsProcess from "../../../vcs/VcsProcess.ts"; +import { + makeAetherMirrorSync, + rebuildUnifiedDiff, + type AetherMirrorConnection, +} from "./mirrorSync.ts"; +import type { AetherWsGitDiffFile, AetherWsGitDiffResult } from "./wireEvents.ts"; +import { + AetherWorkspaceRequestTimeoutError, + type AetherWorkspaceRequestError, +} from "./workspaceSocket.ts"; + +const TestLayer = GitVcsDriverModule.layer.pipe( + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-aether-mirror-" })), + Layer.provideMerge(VcsProcess.layer), + Layer.provideMerge(NodeServices.layer), +); + +// --------------------------------------------------------------------------- +// Structured-diff fixture builders (the wire shape, built from file contents) +// --------------------------------------------------------------------------- + +interface FixtureLine { + readonly kind: "add" | "del" | "context"; + readonly text: string; + readonly noTrailingNewline?: boolean; +} + +function toLines(content: string, kind: "add" | "del"): Array { + if (content.length === 0) { + return []; + } + const hasTrailingNewline = content.endsWith("\n"); + const raw = (hasTrailingNewline ? content.slice(0, -1) : content).split("\n"); + return raw.map((text, index) => ({ + kind, + text, + ...(index === raw.length - 1 && !hasTrailingNewline ? { noTrailingNewline: true } : {}), + })); +} + +const countOf = (content: string): number => toLines(content, "add").length; + +function addedFile(path: string, content: string): AetherWsGitDiffFile { + return { + // The real wire sends "/dev/null" for added entries (aether + // packages/diff/src/index.ts) — and [] hunks for an EMPTY added file. + oldPath: "/dev/null", + newPath: path, + displayPath: path, + status: "added", + isBinary: false, + hunks: + content.length === 0 + ? [] + : [ + { + header: `@@ -0,0 +1,${countOf(content)} @@`, + oldStart: 0, + oldCount: 0, + newStart: 1, + newCount: countOf(content), + lines: toLines(content, "add"), + }, + ], + }; +} + +function deletedFile(path: string, oldContent: string): AetherWsGitDiffFile { + return { + oldPath: path, + // The real wire sends "/dev/null" for deleted entries. + newPath: "/dev/null", + displayPath: path, + status: "deleted", + isBinary: false, + hunks: + oldContent.length === 0 + ? [] + : [ + { + header: `@@ -1,${countOf(oldContent)} +0,0 @@`, + oldStart: 1, + oldCount: countOf(oldContent), + newStart: 0, + newCount: 0, + lines: toLines(oldContent, "del"), + }, + ], + }; +} + +function renamedFile( + oldPath: string, + newPath: string, + oldContent: string, + newContent: string, +): AetherWsGitDiffFile { + return { + oldPath, + newPath, + displayPath: newPath, + status: "renamed", + isBinary: false, + // A PURE rename carries zero hunks on the wire. + hunks: + oldContent === newContent + ? [] + : [ + { + header: "@@", + oldStart: 1, + oldCount: countOf(oldContent), + newStart: 1, + newCount: countOf(newContent), + lines: [...toLines(oldContent, "del"), ...toLines(newContent, "add")], + }, + ], + }; +} + +/** A chmod-only change: raw `M` record with no content hunks. */ +function modeOnlyFile(path: string): AetherWsGitDiffFile { + return { + oldPath: path, + newPath: path, + displayPath: path, + status: "modified", + isBinary: false, + hunks: [], + }; +} + +function modifiedFile(path: string, oldContent: string, newContent: string): AetherWsGitDiffFile { + return { + oldPath: path, + newPath: path, + displayPath: path, + status: "modified", + isBinary: false, + hunks: [ + { + header: "@@", + oldStart: 1, + oldCount: countOf(oldContent), + newStart: 1, + newCount: countOf(newContent), + lines: [...toLines(oldContent, "del"), ...toLines(newContent, "add")], + }, + ], + }; +} + +const diffResult = ( + baseRef: string, + files: ReadonlyArray, +): AetherWsGitDiffResult => ({ baseRef, files }); + +/** A connection whose diff answers are scripted per call (last repeats). */ +function scriptedConnection( + answers: ReadonlyArray, +): AetherMirrorConnection & { readonly diffCalls: () => number } { + let calls = 0; + return { + requestGitDiff: () => { + const answer = answers[Math.min(calls, answers.length - 1)]!; + calls++; + return "baseRef" in answer ? Effect.succeed(answer) : Effect.fail(answer); + }, + readWorkspaceFile: (path) => + Effect.succeed({ + success: true as const, + content: Buffer.from(`binary:${path}`).toString("base64"), + encoding: "base64" as const, + isBinary: true, + }), + diffCalls: () => calls, + }; +} + +// --------------------------------------------------------------------------- +// Fixture repos +// --------------------------------------------------------------------------- + +const INITIAL_APP = "one\ntwo\n"; + +const setupRepos = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const driver = yield* GitVcsDriverModule.GitVcsDriver; + const base = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-aether-mirror-" }); + + const git = (cwd: string, args: ReadonlyArray) => + driver + .execute({ operation: "mirrorSync.test", cwd, args, timeoutMs: 15_000 }) + .pipe(Effect.map((result) => result.stdout.trim())); + + const upstream = path.join(base, "upstream"); + yield* fileSystem.makeDirectory(upstream); + yield* git(upstream, ["init", "--initial-branch", "main"]); + yield* git(upstream, ["config", "user.email", "test@test.test"]); + yield* git(upstream, ["config", "user.name", "Test"]); + yield* fileSystem.writeFileString(path.join(upstream, "app.txt"), INITIAL_APP); + yield* fileSystem.writeFileString(path.join(upstream, "lib.txt"), "lib\n"); + yield* fileSystem.writeFileString(path.join(upstream, ".gitignore"), "build/\n"); + yield* git(upstream, ["add", "-A"]); + yield* git(upstream, ["commit", "-m", "init"]); + const baseSha = yield* git(upstream, ["rev-parse", "HEAD"]); + + yield* git(base, ["clone", upstream, "mirror"]); + const mirror = path.join(base, "mirror"); + yield* git(mirror, ["config", "user.email", "test@test.test"]); + yield* git(mirror, ["config", "user.name", "Test"]); + + const readMirrorFile = (relative: string) => + fileSystem.readFileString(path.join(mirror, relative)); + const mirrorFileExists = (relative: string) => fileSystem.exists(path.join(mirror, relative)); + const writeMirrorFile = (relative: string, content: string) => + fileSystem.writeFileString(path.join(mirror, relative), content); + const makeMirrorDirectory = (relative: string) => + fileSystem.makeDirectory(path.join(mirror, relative), { recursive: true }); + + /** + * The EXACT mirror tree (tracked ∪ untracked, gitignored artifacts and + * files deleted from disk excluded), as path → content — spec item 8: + * "turn N's tree must be exact", which per-file spot checks cannot prove. + */ + const mirrorTree = Effect.gen(function* () { + const listing = yield* git(mirror, ["ls-files", "-co", "--exclude-standard"]); + const paths = [...new Set(listing.split("\n").filter((line) => line.length > 0))].sort(); + const entries: Record = {}; + for (const relative of paths) { + // `ls-files -c` lists INDEX entries even when apply deleted the file + // from disk — the tree we assert on is the working tree. + if (yield* mirrorFileExists(relative)) { + entries[relative] = yield* readMirrorFile(relative); + } + } + return entries; + }); + + return { + upstream, + mirror, + git, + baseSha, + driver, + readMirrorFile, + mirrorFileExists, + writeMirrorFile, + makeMirrorDirectory, + mirrorTree, + }; +}); + +const makeEngine = ( + repos: { + readonly mirror: string; + readonly baseSha: string; + readonly driver: GitVcsDriverModule.GitVcsDriver["Service"]; + }, + overrides?: { + readonly taskId?: string; + readonly persistedFingerprint?: string; + }, +) => + makeAetherMirrorSync({ + cwd: repos.mirror, + git: repos.driver, + baselineHeadSha: repos.baseSha, + getTaskId: () => overrides?.taskId ?? "task-1", + ...(overrides?.persistedFingerprint !== undefined + ? { persistedFingerprint: overrides.persistedFingerprint } + : {}), + writeLockRetry: { attempts: 1, delayMs: 0 }, + }); + +describe("rebuildUnifiedDiff", () => { + it("renders added, modified and no-trailing-newline entries", () => { + const rebuilt = rebuildUnifiedDiff( + diffResult("base", [ + addedFile("notes.md", "hello"), + modifiedFile("app.txt", "one\ntwo\n", "one\ntwo\nthree\n"), + ]), + ); + expect(rebuilt.binaries).toHaveLength(0); + expect(rebuilt.patch).toContain("diff --git a/notes.md b/notes.md"); + expect(rebuilt.patch).toContain("new file mode 100644"); + expect(rebuilt.patch).toContain("--- /dev/null"); + expect(rebuilt.patch).toContain("+hello\n\\ No newline at end of file"); + expect(rebuilt.patch).toContain("@@ -1,2 +1,3 @@"); + }); + + it("excludes mode-only entries (zero-hunk modified) instead of emitting a bare header", () => { + // A bare `diff --git` line makes git apply reject the WHOLE patch + // ("No valid patches in input" / "inconsistent old filename") — verified + // against real git; the entry must be excluded and reported. + const rebuilt = rebuildUnifiedDiff( + diffResult("base", [modeOnlyFile("tools/run.sh"), addedFile("notes.md", "hello\n")]), + ); + expect(rebuilt.modeOnly).toEqual(["tools/run.sh"]); + expect(rebuilt.patch).not.toContain("tools/run.sh"); + expect(rebuilt.patch).toContain("diff --git a/notes.md b/notes.md"); + }); + + it("renders deleted, renamed and pure-rename entries", () => { + const rebuilt = rebuildUnifiedDiff( + diffResult("base", [ + deletedFile("gone.txt", "bye\n"), + renamedFile("lib.txt", "moved.txt", "lib\n", "lib\n"), + ]), + ); + expect(rebuilt.modeOnly).toEqual([]); + expect(rebuilt.patch).toContain("diff --git a/gone.txt b/gone.txt"); + expect(rebuilt.patch).toContain("deleted file mode 100644"); + expect(rebuilt.patch).toContain("+++ /dev/null"); + // Pure rename: header lines only, no hunks. + expect(rebuilt.patch).toContain("rename from lib.txt"); + expect(rebuilt.patch).toContain("rename to moved.txt"); + }); + + it("throws on a diff line kind it cannot express", () => { + expect(() => + rebuildUnifiedDiff( + diffResult("base", [ + { + ...addedFile("x.txt", "x\n"), + hunks: [ + { + header: "@@", + oldStart: 0, + oldCount: 0, + newStart: 1, + newCount: 1, + lines: [{ kind: "sideband", text: "x" }], + }, + ], + }, + ]), + ), + ).toThrowError(/Unknown diff line kind/); + }); +}); + +it.layer(TestLayer)("mirror sync engine (real git fixtures)", (it) => { + it.effect( + "3+ turns: re-touched files, untracked add/re-touch, moved merge-base, detached catch-up", + () => + Effect.gen(function* () { + const repos = yield* setupRepos; + const engine = makeEngine(repos); + + // A gitignored artifact must survive every sync (clean -fd, never -x). + yield* repos.makeMirrorDirectory("build"); + yield* repos.writeMirrorFile("build/cache.txt", "artifact\n"); + + // -- turn 1: modify a tracked file, add an untracked one ----------- + const turn1 = scriptedConnection([ + diffResult(repos.baseSha, [ + modifiedFile("app.txt", INITIAL_APP, "one\ntwo\nthree\n"), + addedFile("notes.md", "hello\n"), + ]), + ]); + const outcome1 = yield* engine.syncAtSettle(turn1); + expect(outcome1._tag).toBe("synced"); + expect(yield* repos.readMirrorFile("app.txt")).toBe("one\ntwo\nthree\n"); + expect(yield* repos.readMirrorFile("notes.md")).toBe("hello\n"); + expect(yield* repos.readMirrorFile("build/cache.txt")).toBe("artifact\n"); + + // -- turn 2: BOTH files re-touched; the diff is CUMULATIVE ---------- + // Without reset + clean the re-apply would fail: app.txt's old sides + // no longer match and notes.md "already exists". + const turn2 = scriptedConnection([ + diffResult(repos.baseSha, [ + modifiedFile("app.txt", INITIAL_APP, "zero\none\ntwo\nthree\n"), + addedFile("notes.md", "hello\nworld\n"), + ]), + ]); + const outcome2 = yield* engine.syncAtSettle(turn2); + expect(outcome2._tag).toBe("synced"); + expect(yield* repos.readMirrorFile("app.txt")).toBe("zero\none\ntwo\nthree\n"); + expect(yield* repos.readMirrorFile("notes.md")).toBe("hello\nworld\n"); + expect(yield* repos.readMirrorFile("build/cache.txt")).toBe("artifact\n"); + + // -- turn 3: the merge-base MOVED (Aether-side rebase) -------------- + // The new base exists only upstream until the engine fetches origin. + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fileSystem.writeFileString( + path.join(repos.upstream, "upstream.txt"), + "from upstream\n", + ); + yield* fileSystem.writeFileString(path.join(repos.upstream, "app.txt"), "rebased\n"); + yield* repos.git(repos.upstream, ["add", "-A"]); + yield* repos.git(repos.upstream, ["commit", "-m", "base moved"]); + const movedBase = yield* repos.git(repos.upstream, ["rev-parse", "HEAD"]); + + const turn3 = scriptedConnection([ + diffResult(movedBase, [ + modifiedFile("app.txt", "rebased\n", "rebased\nplus agent work\n"), + addedFile("notes.md", "hello\nworld\nagain\n"), + ]), + ]); + const outcome3 = yield* engine.syncAtSettle(turn3); + expect(outcome3._tag).toBe("synced"); + // The tree is EXACTLY base(moved) + cumulative diff. + expect(yield* repos.readMirrorFile("app.txt")).toBe("rebased\nplus agent work\n"); + expect(yield* repos.readMirrorFile("upstream.txt")).toBe("from upstream\n"); + expect(yield* repos.readMirrorFile("notes.md")).toBe("hello\nworld\nagain\n"); + expect(yield* repos.readMirrorFile("build/cache.txt")).toBe("artifact\n"); + + // -- turn 4: settled while DETACHED — empty checkpoint, no touch ---- + const outcome4 = yield* engine.syncAtSettle(undefined); + expect(outcome4._tag).toBe("skipped-detached"); + expect(yield* repos.readMirrorFile("app.txt")).toBe("rebased\nplus agent work\n"); + + // -- turn 5: catch-up — the next sync captures the combined delta --- + const turn5 = scriptedConnection([ + diffResult(movedBase, [ + modifiedFile("app.txt", "rebased\n", "rebased\nplus agent work\nand turn five\n"), + addedFile("notes.md", "hello\nworld\nagain\nand again\n"), + addedFile("fresh.txt", "new in turn five\n"), + ]), + ]); + const outcome5 = yield* engine.syncAtSettle(turn5); + expect(outcome5._tag).toBe("synced"); + expect(yield* repos.readMirrorFile("app.txt")).toBe( + "rebased\nplus agent work\nand turn five\n", + ); + expect(yield* repos.readMirrorFile("fresh.txt")).toBe("new in turn five\n"); + expect(engine.lastSyncedFingerprint()).toBeDefined(); + expect(engine.pausedReason()).toBeUndefined(); + }).pipe(Effect.scoped), + { timeout: 60_000 }, + ); + + it.effect( + "pauses loudly on local divergence (user edit between turns) and never re-applies", + () => + Effect.gen(function* () { + const repos = yield* setupRepos; + const engine = makeEngine(repos); + const turn1 = scriptedConnection([ + diffResult(repos.baseSha, [modifiedFile("app.txt", INITIAL_APP, "one\ntwo\nthree\n")]), + ]); + expect((yield* engine.syncAtSettle(turn1))._tag).toBe("synced"); + + // The user edits the mirror between turns. + yield* repos.writeMirrorFile("app.txt", "my local edit\n"); + + const turn2 = scriptedConnection([ + diffResult(repos.baseSha, [modifiedFile("app.txt", INITIAL_APP, "one\ntwo\nfour\n")]), + ]); + const outcome = yield* engine.syncAtSettle(turn2); + expect(outcome).toMatchObject({ _tag: "paused", firstPause: true }); + if (outcome._tag === "paused") { + expect(outcome.reason).toContain("diverged"); + } + // NEVER applied over the diverged tree: the local edit survives. + expect(yield* repos.readMirrorFile("app.txt")).toBe("my local edit\n"); + // The diff was never requested — verify runs first. + expect(turn2.diffCalls()).toBe(0); + // The pause is sticky (subsequent settles are sync-skipped, quieter). + const again = yield* engine.syncAtSettle(turn2); + expect(again).toMatchObject({ _tag: "paused", firstPause: false }); + }).pipe(Effect.scoped), + { timeout: 60_000 }, + ); + + it.effect( + "the fingerprint catches a local COMMIT too (base diverged, clean tree)", + () => + Effect.gen(function* () { + const repos = yield* setupRepos; + const engine = makeEngine(repos); + const turn1 = scriptedConnection([ + diffResult(repos.baseSha, [addedFile("notes.md", "hello\n")]), + ]); + expect((yield* engine.syncAtSettle(turn1))._tag).toBe("synced"); + + // Commit the applied state: content identical, HEAD moved, tree clean. + yield* repos.git(repos.mirror, ["add", "-A"]); + yield* repos.git(repos.mirror, ["commit", "-m", "local commit"]); + + const outcome = yield* engine.syncAtSettle(turn1); + expect(outcome).toMatchObject({ _tag: "paused", firstPause: true }); + }).pipe(Effect.scoped), + { timeout: 60_000 }, + ); + + it.effect( + "pauses loudly when git apply rejects the rebuilt diff", + () => + Effect.gen(function* () { + const repos = yield* setupRepos; + const engine = makeEngine(repos); + // Old sides that do not exist at the declared base: apply must fail. + const badDiff = scriptedConnection([ + diffResult(repos.baseSha, [ + modifiedFile("app.txt", "not\nwhat\nis\nthere\n", "something\n"), + ]), + ]); + const outcome = yield* engine.syncAtSettle(badDiff); + expect(outcome).toMatchObject({ _tag: "paused", firstPause: true }); + if (outcome._tag === "paused") { + expect(outcome.reason).toContain("git apply"); + } + }).pipe(Effect.scoped), + { timeout: 60_000 }, + ); + + it.effect( + "pauses loudly when the declared baseRef does not resolve even after fetch", + () => + Effect.gen(function* () { + const repos = yield* setupRepos; + const engine = makeEngine(repos); + const unknownBase = scriptedConnection([ + diffResult("0123456789abcdef0123456789abcdef01234567", [ + addedFile("notes.md", "hello\n"), + ]), + ]); + const outcome = yield* engine.syncAtSettle(unknownBase); + expect(outcome).toMatchObject({ _tag: "paused", firstPause: true }); + if (outcome._tag === "paused") { + expect(outcome.reason).toContain("does not resolve"); + } + }).pipe(Effect.scoped), + { timeout: 60_000 }, + ); + + it.effect( + "a transport failure mid-request degrades to a warning-level skip, not a pause", + () => + Effect.gen(function* () { + const repos = yield* setupRepos; + const engine = makeEngine(repos); + const timedOut = scriptedConnection([ + new AetherWorkspaceRequestTimeoutError({ + channel: "git", + requestType: "diff", + requestId: "t3-git-1", + timeoutMs: 1, + }), + // The retry succeeds — self-healing. + diffResult(repos.baseSha, [addedFile("notes.md", "hello\n")]), + ]); + const first = yield* engine.syncAtSettle(timedOut); + expect(first._tag).toBe("skipped-transport"); + expect(engine.pausedReason()).toBeUndefined(); + const second = yield* engine.syncAtSettle(timedOut); + expect(second._tag).toBe("synced"); + expect(yield* repos.readMirrorFile("notes.md")).toBe("hello\n"); + }).pipe(Effect.scoped), + { timeout: 60_000 }, + ); + + it.effect( + "binary entries are written from the files channel after the text apply", + () => + Effect.gen(function* () { + const repos = yield* setupRepos; + const engine = makeEngine(repos); + const withBinary = scriptedConnection([ + diffResult(repos.baseSha, [ + addedFile("notes.md", "hello\n"), + { + oldPath: "assets/logo.bin", + newPath: "assets/logo.bin", + displayPath: "assets/logo.bin", + status: "added", + isBinary: true, + hunks: [], + }, + ]), + ]); + const outcome = yield* engine.syncAtSettle(withBinary); + expect(outcome._tag).toBe("synced"); + expect(yield* repos.readMirrorFile("assets/logo.bin")).toBe("binary:assets/logo.bin"); + }).pipe(Effect.scoped), + { timeout: 60_000 }, + ); + + it.effect( + "refuses binary entries whose diff-supplied path escapes the mirror checkout", + () => + Effect.gen(function* () { + // Binary entries never pass through `git apply`, so nothing else + // validates their paths: an absolute newPath makes join(cwd, …) + // return the path itself, and '..' walks straight out of the + // checkout. Both must pause loudly with NOTHING touched outside. + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const binaryEntry = (entry: { + readonly oldPath: string; + readonly newPath: string; + readonly status: "added" | "deleted"; + }): AetherWsGitDiffFile => ({ + ...entry, + displayPath: entry.status === "deleted" ? entry.oldPath : entry.newPath, + isBinary: true, + hunks: [], + }); + + const escapes: ReadonlyArray<{ + readonly name: string; + readonly entry: (sentinel: string) => AetherWsGitDiffFile; + }> = [ + { + name: "absolute newPath", + entry: (sentinel) => + binaryEntry({ status: "added", oldPath: "/dev/null", newPath: sentinel }), + }, + { + name: "'..' newPath", + entry: () => + binaryEntry({ status: "added", oldPath: "/dev/null", newPath: "../escape.bin" }), + }, + { + name: "'..' oldPath removal", + entry: () => + binaryEntry({ + status: "deleted", + oldPath: "../outside-sentinel.txt", + newPath: "/dev/null", + }), + }, + { + // Repo-relative but git-metadata-targeting: a direct write to + // .git/hooks/* is code execution on the next git invocation, and + // git never tracks paths under .git, so no legitimate diff names + // them. + name: ".git hooks newPath", + entry: () => + binaryEntry({ + status: "added", + oldPath: "/dev/null", + newPath: ".git/hooks/post-checkout", + }), + }, + { + name: ".git config removal", + entry: () => + binaryEntry({ + status: "deleted", + oldPath: ".git/config", + newPath: "/dev/null", + }), + }, + ]; + + for (const escape of escapes) { + // A fresh repo per case: a pause is sticky, and the previous case + // left the tree mid-sync. + const repos = yield* setupRepos; + const outside = path.dirname(repos.mirror); + const sentinel = path.join(outside, "outside-sentinel.txt"); + yield* fileSystem.writeFileString(sentinel, "sentinel\n"); + + const engine = makeEngine(repos); + const connection = scriptedConnection([ + diffResult(repos.baseSha, [addedFile("notes.md", "hello\n"), escape.entry(sentinel)]), + ]); + const outcome = yield* engine.syncAtSettle(connection); + expect(outcome, escape.name).toMatchObject({ _tag: "paused", firstPause: true }); + if (outcome._tag === "paused") { + expect(outcome.reason, escape.name).toContain("escapes the mirror checkout"); + } + // Nothing outside the checkout was written or deleted. + expect(yield* fileSystem.readFileString(sentinel)).toBe("sentinel\n"); + expect(yield* fileSystem.exists(path.join(outside, "escape.bin"))).toBe(false); + } + }).pipe(Effect.scoped), + { timeout: 60_000 }, + ); + + it.effect( + "validates every binary path BEFORE any mutation: a rename with a safe oldPath and an escaping newPath removes nothing", + () => + Effect.gen(function* () { + // The failure mode is partial mutation: oldPath removed, then the + // newPath validation pauses — a failed sync must never mutate the + // mirror through its own validation error. + const repos = yield* setupRepos; + const engine = makeEngine(repos); + const connection = scriptedConnection([ + diffResult(repos.baseSha, [ + { + status: "renamed", + oldPath: "lib.txt", + newPath: "../escaped-rename.bin", + displayPath: "../escaped-rename.bin", + isBinary: true, + hunks: [], + }, + ]), + ]); + const outcome = yield* engine.syncAtSettle(connection); + expect(outcome).toMatchObject({ _tag: "paused", firstPause: true }); + if (outcome._tag === "paused") { + expect(outcome.reason).toContain("escapes the mirror checkout"); + } + // The safe oldPath was NOT removed: validation ran before mutation. + expect(yield* repos.readMirrorFile("lib.txt")).toBe("lib\n"); + }).pipe(Effect.scoped), + { timeout: 60_000 }, + ); + + it.effect( + "deleted, renamed and dropped entries settle to the EXACT tree each turn", + () => + Effect.gen(function* () { + const repos = yield* setupRepos; + const engine = makeEngine(repos); + yield* repos.makeMirrorDirectory("build"); + yield* repos.writeMirrorFile("build/cache.txt", "artifact\n"); + + // -- turn 1: modify app.txt, add notes.md and an EMPTY untracked file. + const turn1 = scriptedConnection([ + diffResult(repos.baseSha, [ + modifiedFile("app.txt", INITIAL_APP, "one\ntwo\nthree\n"), + addedFile("notes.md", "hello\n"), + addedFile("empty.txt", ""), + ]), + ]); + expect((yield* engine.syncAtSettle(turn1))._tag).toBe("synced"); + expect(yield* repos.mirrorTree).toEqual({ + ".gitignore": "build/\n", + "app.txt": "one\ntwo\nthree\n", + "lib.txt": "lib\n", + "notes.md": "hello\n", + "empty.txt": "", + }); + + // -- turn 2: the agent DELETED app.txt, renamed lib.txt with an + // edit, and reverted its own notes.md/empty.txt — the cumulative + // diff simply no longer contains them, so reset+clean must erase + // them (the whole reason the engine re-baselines every turn). + const turn2 = scriptedConnection([ + diffResult(repos.baseSha, [ + deletedFile("app.txt", INITIAL_APP), + renamedFile("lib.txt", "lib/renamed.txt", "lib\n", "lib\nmore\n"), + ]), + ]); + expect((yield* engine.syncAtSettle(turn2))._tag).toBe("synced"); + expect(yield* repos.mirrorTree).toEqual({ + ".gitignore": "build/\n", + "lib/renamed.txt": "lib\nmore\n", + }); + + // -- turn 3: app.txt restored (dropped from the diff again) and a + // PURE rename (zero hunks on the wire). + const turn3 = scriptedConnection([ + diffResult(repos.baseSha, [renamedFile("lib.txt", "moved.txt", "lib\n", "lib\n")]), + ]); + expect((yield* engine.syncAtSettle(turn3))._tag).toBe("synced"); + expect(yield* repos.mirrorTree).toEqual({ + ".gitignore": "build/\n", + "app.txt": INITIAL_APP, + "moved.txt": "lib\n", + }); + // The gitignored artifact survived every reset+clean (-fd, never -x). + expect(yield* repos.readMirrorFile("build/cache.txt")).toBe("artifact\n"); + expect(engine.pausedReason()).toBeUndefined(); + }).pipe(Effect.scoped), + { timeout: 60_000 }, + ); + + it.effect( + "a mode-only change is skipped with a report — never a pause, never a broken apply", + () => + Effect.gen(function* () { + const repos = yield* setupRepos; + const engine = makeEngine(repos); + const turn = scriptedConnection([ + diffResult(repos.baseSha, [modeOnlyFile("app.txt"), addedFile("notes.md", "hello\n")]), + ]); + const outcome = yield* engine.syncAtSettle(turn); + expect(outcome).toMatchObject({ _tag: "synced", modeOnlySkipped: ["app.txt"] }); + expect(yield* repos.readMirrorFile("app.txt")).toBe(INITIAL_APP); + expect(yield* repos.readMirrorFile("notes.md")).toBe("hello\n"); + expect(engine.pausedReason()).toBeUndefined(); + // The tree the sync left behind verifies clean on the next settle. + expect((yield* engine.syncAtSettle(turn))._tag).toBe("synced"); + }).pipe(Effect.scoped), + { timeout: 60_000 }, + ); + + it.effect( + "crash-resume: the mirror-local fingerprint record recovers a stale or lost cursor", + () => + Effect.gen(function* () { + const repos = yield* setupRepos; + const engine1 = makeEngine(repos); + const turn1 = scriptedConnection([ + diffResult(repos.baseSha, [modifiedFile("app.txt", INITIAL_APP, "one\ntwo\nthree\n")]), + ]); + expect((yield* engine1.syncAtSettle(turn1))._tag).toBe("synced"); + + // Non-graceful shutdown: the resume cursor never captured turn 1's + // fingerprint. A fresh engine must NOT read the driver's own applied + // diff as user divergence — the record written in the same breath as + // the sync recovers the expected state. + const engine2 = makeEngine(repos); + const turn2 = scriptedConnection([ + diffResult(repos.baseSha, [modifiedFile("app.txt", INITIAL_APP, "one\ntwo\nfour\n")]), + ]); + expect((yield* engine2.syncAtSettle(turn2))._tag).toBe("synced"); + expect(yield* repos.readMirrorFile("app.txt")).toBe("one\ntwo\nfour\n"); + + // A STALE cursor fingerprint loses to the mirror-local record too. + const engine3 = makeEngine(repos, { persistedFingerprint: "stale:stale" }); + expect((yield* engine3.syncAtSettle(turn2))._tag).toBe("synced"); + + // A record from ANOTHER task never vouches for this tree. + const engine4 = makeEngine(repos, { taskId: "task-other" }); + const outcome = yield* engine4.syncAtSettle(turn2); + expect(outcome).toMatchObject({ _tag: "paused", firstPause: true }); + if (outcome._tag === "paused") { + expect(outcome.reason).toContain("diverged"); + } + }).pipe(Effect.scoped), + { timeout: 60_000 }, + ); +}); diff --git a/apps/server/src/provider/Layers/aether/mirrorSync.ts b/apps/server/src/provider/Layers/aether/mirrorSync.ts new file mode 100644 index 000000000000..52f58a166545 --- /dev/null +++ b/apps/server/src/provider/Layers/aether/mirrorSync.ts @@ -0,0 +1,741 @@ +/** + * Aether mirror sync engine (build item 8). + * + * The local checkout is a driver-owned ONE-WAY MIRROR of the cloud VM for the + * life of an Aether thread. At every turn settle this engine, in order: + * (a) verifies the mirror still matches the last synced state (content + * fingerprint recorded after each successful sync) — any divergence + * PAUSES sync loudly; the engine never applies over a diverged tree; + * (b) requests the cumulative WS `git diff` (mode main), fetches origin, + * and resolves the diff's own declared `baseRef` locally — pausing + * loudly if it does not resolve (an Aether-side rebase moves the + * merge-base; guessing would corrupt the mirror); + * (c) re-baselines with `git reset --hard ` AND `git clean -fd` + * (never `-x`: gitignored artifacts survive; the diff's synthetic + * `added` entries are exactly what clean removes); + * (d) rebuilds a unified diff from the structured hunks and `git apply`s + * it; binary files arrive via the WS files channel (base64) and are + * written directly — bypassing `git apply`'s path validation, so their + * diff-supplied paths are checked against the checkout root here; + * (e) hands the outcome back so the caller emits `turn.diff.updated` and + * ONLY THEN `turn.completed`. + * + * Self-healing by construction: every successful sync reconstructs the full + * state from the base, so a turn settled while detached settles immediately + * with an empty checkpoint and the next successful sync captures the + * combined delta (lazy catch-up). + * + * Every git invocation goes through the injected executor (the repo's + * `GitVcsDriver.execute`) with the session's PROJECT cwd and is logged at + * debug with its argv. + * + * @module provider/Layers/aether/mirrorSync + */ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import type { GitCommandError } from "@t3tools/contracts"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Result from "effect/Result"; + +import type { ExecuteGitInput, ExecuteGitResult } from "../../../vcs/GitVcsDriver.ts"; +import type { AetherAgentConnection } from "./workspaceSocket.ts"; +import type { AetherWsGitDiffFile, AetherWsGitDiffResult } from "./wireEvents.ts"; + +// --------------------------------------------------------------------------- +// Seams +// --------------------------------------------------------------------------- + +/** The one git surface the engine uses — structurally `GitVcsDriver.execute`. */ +export interface AetherMirrorGit { + readonly execute: (input: ExecuteGitInput) => Effect.Effect; +} + +/** The live-socket surface the engine drives (subset of the connection). */ +export type AetherMirrorConnection = Pick< + AetherAgentConnection, + "requestGitDiff" | "readWorkspaceFile" +>; + +/** + * Narrow filesystem seam for the binary-file path. Defaults to node:fs — + * injected only so failures can be simulated in tests. + */ +export interface AetherMirrorFs { + readonly writeFile: (path: string, bytes: Uint8Array) => Promise; + readonly mkdir: (dir: string) => Promise; + readonly remove: (path: string) => Promise; +} + +const defaultMirrorFs: AetherMirrorFs = { + writeFile: (path, bytes) => NodeFSP.writeFile(path, bytes), + mkdir: async (dir) => { + await NodeFSP.mkdir(dir, { recursive: true }); + }, + remove: (path) => NodeFSP.rm(path, { force: true }), +}; + +// --------------------------------------------------------------------------- +// Outcomes +// --------------------------------------------------------------------------- + +export type AetherMirrorSyncOutcome = + /** Full reset-and-apply completed; the checkpoint will capture a real delta. */ + | { + readonly _tag: "synced"; + readonly unifiedDiff: string; + readonly fileCount: number; + /** + * Paths whose only change was file MODE (chmod): the wire diff carries + * no mode fields, so the change cannot be mirrored — surfaced as a + * warning by the caller, never silently dropped and never a pause. + */ + readonly modeOnlySkipped: ReadonlyArray; + } + /** + * No live workspace connection at settle time (detached / suspended VM). + * The turn settles immediately with an empty checkpoint; the next + * successful sync captures the combined delta. The tree was not touched. + */ + | { readonly _tag: "skipped-detached"; readonly reason: string } + /** + * A transport-class failure interrupted the sync (request timeout, socket + * drop). The tree is in a consistent state and the fingerprint was + * re-recorded, so the next sync self-heals — surfaced as a warning, not a + * pause. + */ + | { readonly _tag: "skipped-transport"; readonly reason: string } + /** + * Sync is paused: local divergence, unresolvable base, apply failure, or a + * contract break. `firstPause` distinguishes the loud error card from the + * per-settle reminder warning. + */ + | { readonly _tag: "paused"; readonly reason: string; readonly firstPause: boolean }; + +export interface AetherMirrorSyncEngine { + /** Sync at one turn settle. Never fails — every failure mode is an outcome. */ + readonly syncAtSettle: ( + connection: AetherMirrorConnection | undefined, + ) => Effect.Effect; + /** The fingerprint of the last synced state (persisted via the resume cursor). */ + readonly lastSyncedFingerprint: () => string | undefined; + /** Pause state, if any (sticky until the session restarts). */ + readonly pausedReason: () => string | undefined; +} + +export interface AetherMirrorSyncOptions { + /** The session's PROJECT cwd — the mirror checkout. Never the VM path. */ + readonly cwd: string; + readonly git: AetherMirrorGit; + readonly fs?: AetherMirrorFs; + /** + * The session's Aether task id (defined by the time any turn settles — a + * sync without one fails loudly). Keys the mirror-local fingerprint record + * so a stale record from another thread's task is never trusted. + */ + readonly getTaskId: () => string | undefined; + /** + * HEAD sha recorded at session start. For a thread that has never synced, + * the expected pre-sync state is exactly "clean tree at this HEAD". + */ + readonly baselineHeadSha: string; + /** + * Fingerprint carried in the resume cursor for a thread with earlier + * synced turns. Second in precedence: the mirror-local record (written in + * the same breath as each sync) wins when present, because t3 snapshots + * the cursor only at its own persistence beats — after a non-graceful + * shutdown the cursor can lag the tree by whole turns, and trusting it + * would misread the driver's own applied diff as user divergence. + */ + readonly persistedFingerprint?: string | undefined; + /** Retry policy for the workspace's "git write operation in progress" answer. */ + readonly writeLockRetry?: { readonly attempts: number; readonly delayMs: number }; +} + +// --------------------------------------------------------------------------- +// Unified-diff rebuild (pure; exported for tests) +// --------------------------------------------------------------------------- + +/** A structured entry the rebuild cannot express — the sync must pause. */ +export class AetherDiffRebuildError extends Error { + readonly detail: string; + constructor(detail: string) { + super(detail); + this.name = "AetherDiffRebuildError"; + this.detail = detail; + } +} + +const LINE_PREFIX: Record = { + add: "+", + del: "-", + context: " ", +}; + +function renderTextFilePatch(file: AetherWsGitDiffFile): string { + const parts: Array = []; + const oldPath = file.status === "added" ? file.newPath : file.oldPath; + const newPath = file.status === "deleted" ? file.oldPath : file.newPath; + parts.push(`diff --git a/${oldPath} b/${newPath}`); + switch (file.status) { + case "added": + parts.push("new file mode 100644"); + break; + case "deleted": + parts.push("deleted file mode 100644"); + break; + case "renamed": + parts.push(`rename from ${file.oldPath}`); + parts.push(`rename to ${file.newPath}`); + break; + case "modified": + break; + default: + throw new AetherDiffRebuildError( + `Unknown git diff file status '${file.status}' for '${file.displayPath}'.`, + ); + } + if (file.hunks.length > 0) { + parts.push(file.status === "added" ? "--- /dev/null" : `--- a/${oldPath}`); + parts.push(file.status === "deleted" ? "+++ /dev/null" : `+++ b/${newPath}`); + for (const hunk of file.hunks) { + // Regenerate the @@ line from the numeric fields (authoritative); + // the stored header text is display-oriented. + parts.push(`@@ -${hunk.oldStart},${hunk.oldCount} +${hunk.newStart},${hunk.newCount} @@`); + for (const line of hunk.lines) { + const prefix = LINE_PREFIX[line.kind]; + if (prefix === undefined) { + throw new AetherDiffRebuildError( + `Unknown diff line kind '${line.kind}' in '${file.displayPath}'.`, + ); + } + parts.push(`${prefix}${line.text}`); + if (line.noTrailingNewline === true) { + parts.push("\\ No newline at end of file"); + } + } + } + } + return `${parts.join("\n")}\n`; +} + +export interface RebuiltDiff { + /** Unified text patch covering every non-binary entry ("" when none). */ + readonly patch: string; + /** Binary entries, applied via the WS files channel + direct writes. */ + readonly binaries: ReadonlyArray; + /** + * Non-binary `modified` entries with ZERO hunks: a mode-only change + * (chmod on an otherwise-untouched file). The wire schema carries no mode + * fields, so the change is inexpressible locally — and rendering a bare + * `diff --git` header makes `git apply` reject the WHOLE patch (verified: + * "No valid patches in input" alone, "inconsistent old filename" when + * concatenated). Excluded from the patch and reported so the caller warns. + */ + readonly modeOnly: ReadonlyArray; +} + +/** + * Rebuild a `git apply`-able unified diff from the structured GitDiffResult. + * Throws `AetherDiffRebuildError` on any entry it cannot express — the + * caller pauses loudly rather than half-applying. + */ +export function rebuildUnifiedDiff(diff: AetherWsGitDiffResult): RebuiltDiff { + const textParts: Array = []; + const binaries: Array = []; + const modeOnly: Array = []; + for (const file of diff.files) { + if (file.isBinary) { + binaries.push(file); + continue; + } + if (file.status === "modified" && file.hunks.length === 0) { + modeOnly.push(file.displayPath); + continue; + } + textParts.push(renderTextFilePatch(file)); + } + return { patch: textParts.join(""), binaries, modeOnly }; +} + +// --------------------------------------------------------------------------- +// Engine +// --------------------------------------------------------------------------- + +const WRITE_LOCK_PATTERN = /write operation is in progress/i; + +/** Monotonic id source for per-engine temp index files. */ +let mirrorEngineCounter = 0; + +/** Internal control-flow error: pause the sync with this reason. */ +class PauseSync { + readonly reason: string; + constructor(reason: string) { + this.reason = reason; + } +} +/** Internal control-flow error: transport died mid-sync. */ +class TransportSkip { + readonly reason: string; + readonly treeMutated: boolean; + constructor(reason: string, treeMutated: boolean) { + this.reason = reason; + this.treeMutated = treeMutated; + } +} + +export function makeAetherMirrorSync(options: AetherMirrorSyncOptions): AetherMirrorSyncEngine { + const fs = options.fs ?? defaultMirrorFs; + const writeLockRetry = options.writeLockRetry ?? { attempts: 5, delayMs: 500 }; + const cwd = options.cwd; + + let lastSynced: string | undefined; + /** One-shot: the first sync resolves the expected state from the durable records. */ + let fingerprintLoaded = false; + let paused: string | undefined; + // Stable per-engine temp index for content fingerprints: `git add -A` + // against this side index captures tracked AND untracked content without + // touching the real index; `.gitignore`d artifacts stay excluded, matching + // `clean -fd` semantics. The name only needs uniqueness across engines in + // this process — a monotonic counter suffices (no randomness). + mirrorEngineCounter++; + const tempIndexPath = NodePath.join( + NodeOS.tmpdir(), + `t3-aether-mirror-index-${process.pid}-${mirrorEngineCounter}`, + ); + + const git = ( + operation: string, + args: ReadonlyArray, + extra?: Partial, + ): Effect.Effect => + Effect.logDebug("aether.mirror.git", { cwd, argv: ["git", ...args] }).pipe( + Effect.andThen(options.git.execute({ operation, cwd, args, ...extra })), + ); + + const gitStdout = (operation: string, args: ReadonlyArray) => + git(operation, args).pipe(Effect.map((result) => result.stdout.trim())); + + /** `:` — catches edits, untracked files AND local commits. */ + const captureFingerprint: Effect.Effect = Effect.gen(function* () { + const headSha = yield* gitStdout("aether.mirror.fingerprint", ["rev-parse", "HEAD"]); + const env = { GIT_INDEX_FILE: tempIndexPath }; + yield* git("aether.mirror.fingerprint", ["read-tree", "HEAD"], { env }); + yield* git("aether.mirror.fingerprint", ["add", "-A", "."], { env }); + const treeSha = yield* git("aether.mirror.fingerprint", ["write-tree"], { env }).pipe( + Effect.map((result) => result.stdout.trim()), + ); + return `${headSha}:${treeSha}`; + }); + + /** + * The mirror-local fingerprint record: `git config --local`, written in + * the SAME breath as every successful sync and keyed by task id. The + * resume cursor is snapshotted only at t3's own persistence beats + * (startSession return / sendTurn return / stopAll), so after a crash it + * can lag the tree by whole turns — a fingerprint that travels with the + * tree it describes is the only record that is never stale. + */ + const FINGERPRINT_CONFIG_KEY = "t3.aetherMirrorFingerprint"; + + const requireTaskId: Effect.Effect = Effect.suspend(() => { + const taskId = options.getTaskId(); + return taskId === undefined + ? Effect.fail( + new PauseSync( + "The session has no Aether task id at settle time; refusing to sync the mirror without one.", + ), + ) + : Effect.succeed(taskId); + }); + + const persistFingerprint = (taskId: string, fingerprint: string) => + git("aether.mirror.fingerprint-store", [ + "config", + "--local", + FINGERPRINT_CONFIG_KEY, + `${taskId} ${fingerprint}`, + ]).pipe( + Effect.mapError( + (error) => + new PauseSync(`Could not persist the mirror fingerprint record: ${error.message}`), + ), + Effect.asVoid, + ); + + const readPersistedFingerprint = (taskId: string): Effect.Effect => + Effect.gen(function* () { + const result = yield* git( + "aether.mirror.fingerprint-store", + ["config", "--local", "--get", FINGERPRINT_CONFIG_KEY], + { allowNonZeroExit: true }, + ).pipe( + Effect.mapError( + (error) => + new PauseSync(`Could not read the mirror fingerprint record: ${error.message}`), + ), + ); + if (result.exitCode !== 0) { + // `git config --get` exits 1 when the key is unset — the only + // non-zero exit that means "no record" rather than a failure. + if (result.exitCode === 1) { + return undefined; + } + return yield* Effect.fail( + new PauseSync( + `Could not read the mirror fingerprint record (git config exit ${String(result.exitCode)}): ${result.stderr.trim()}`, + ), + ); + } + const value = result.stdout.trim(); + if (value.length === 0) { + return undefined; + } + const separator = value.indexOf(" "); + if (separator === -1) { + return yield* Effect.fail( + new PauseSync( + `The mirror fingerprint record '${value}' is corrupt (expected ' '). ` + + `Remove it (git config --local --unset ${FINGERPRINT_CONFIG_KEY}) to recover.`, + ), + ); + } + // A record from ANOTHER task (an earlier thread in this cwd) is not + // ours — the keyed lookup misses and the cursor/baseline decide. + return value.slice(0, separator) === taskId ? value.slice(separator + 1) : undefined; + }); + + const expectedFingerprint = ( + taskId: string, + ): Effect.Effect => + Effect.gen(function* () { + if (!fingerprintLoaded) { + fingerprintLoaded = true; + // Precedence: mirror-local record (never stale) > resume cursor. + const stored = yield* readPersistedFingerprint(taskId); + lastSynced = stored ?? options.persistedFingerprint; + } + if (lastSynced !== undefined) { + return lastSynced; + } + // Never-synced thread: the expected state is a clean tree at the + // baseline HEAD recorded when the session started. + const baselineTree = yield* gitStdout("aether.mirror.fingerprint", [ + "rev-parse", + `${options.baselineHeadSha}^{tree}`, + ]); + return `${options.baselineHeadSha}:${baselineTree}`; + }); + + const requestDiffWithLockRetry = (connection: AetherMirrorConnection) => + Effect.gen(function* () { + for (let attempt = 1; ; attempt++) { + const outcome = yield* connection.requestGitDiff({ mode: "main" }).pipe(Effect.result); + if (Result.isSuccess(outcome)) { + return outcome.success; + } + const error = outcome.failure; + if ( + error._tag === "AetherWorkspaceRequestFailedError" && + WRITE_LOCK_PATTERN.test(error.detail) && + attempt < writeLockRetry.attempts + ) { + yield* Effect.logDebug("aether.mirror.diff.write-lock-retry", { attempt }); + yield* Effect.sleep(Duration.millis(writeLockRetry.delayMs)); + continue; + } + return yield* error; + } + }); + + /** + * Resolve one diff-supplied path inside the mirror checkout. Binary + * entries are written and deleted DIRECTLY — they never pass through + * `git apply`, whose own path validation is what protects every hunk + * path — so this is the only thing standing between a malformed (or + * hostile) workspace diff and a write outside the checkout: an absolute + * path makes `join(cwd, …)` return the path itself, and a `..` segment + * walks straight out. + */ + const resolveInMirror = (relative: string): Effect.Effect => + Effect.suspend(() => { + const refuse = (why: string) => + Effect.fail( + new PauseSync( + `The workspace diff names a binary path that escapes the mirror checkout (${why}): '${relative}'.`, + ), + ); + if (relative.length === 0) { + return refuse("empty path"); + } + if (NodePath.isAbsolute(relative)) { + return refuse("absolute path"); + } + // Both separators: a Windows-style '..\\x' is a traversal too, and a + // backslash in a POSIX name is not worth the ambiguity. + const segments = relative.split(/[/\\]/); + if (segments.includes("..")) { + return refuse("'..' segment"); + } + // Direct writes bypass git's own refusal to track files under .git — + // a write to .git/config or .git/hooks/* corrupts the mirror's + // metadata (hooks = code execution on the next git invocation). Git + // never tracks such paths, so a legitimate diff cannot name them. + if (segments.some((segment) => segment.toLowerCase() === ".git")) { + return refuse("'.git' segment"); + } + const root = NodePath.resolve(cwd); + const resolved = NodePath.resolve(root, relative); + if (!resolved.startsWith(root + NodePath.sep)) { + return refuse("resolves outside the checkout"); + } + return Effect.succeed(resolved); + }); + + const applyBinaries = ( + connection: AetherMirrorConnection, + binaries: ReadonlyArray, + ) => + Effect.gen(function* () { + // TWO-PHASE: resolve/validate EVERY path in the batch before touching + // the filesystem. A rename with a safe oldPath and an escaping newPath + // must refuse before the removal — a failed sync may never leave the + // mirror partially mutated by its own validation error. + const resolvedTargets = new Map(); + for (const file of binaries) { + if (file.status === "deleted" || file.status === "renamed") { + resolvedTargets.set(`old:${file.oldPath}`, yield* resolveInMirror(file.oldPath)); + } + if (file.status !== "deleted") { + resolvedTargets.set(`new:${file.newPath}`, yield* resolveInMirror(file.newPath)); + } + } + for (const file of binaries) { + if (file.status === "deleted" || file.status === "renamed") { + const stale = resolvedTargets.get(`old:${file.oldPath}`)!; + yield* Effect.tryPromise({ + try: () => fs.remove(stale), + catch: (cause) => + new PauseSync(`Failed to remove binary file '${file.oldPath}': ${String(cause)}`), + }); + if (file.status === "deleted") { + continue; + } + } + const target = resolvedTargets.get(`new:${file.newPath}`)!; + const read = yield* connection.readWorkspaceFile(file.newPath).pipe( + Effect.mapError((error) => { + switch (error._tag) { + case "AetherWorkspaceRequestTimeoutError": + case "AetherWorkspaceDetachedError": + return new TransportSkip( + `Binary file '${file.newPath}' could not be read before the connection dropped: ${error.message}`, + true, + ); + default: + return new PauseSync( + `Failed to read binary file '${file.newPath}' from the workspace: ${error.message}`, + ); + } + }), + ); + const bytes = + read.encoding === "base64" + ? Uint8Array.from(Buffer.from(read.content, "base64")) + : new TextEncoder().encode(read.content); + yield* Effect.tryPromise({ + try: async () => { + await fs.mkdir(NodePath.dirname(target)); + await fs.writeFile(target, bytes); + }, + catch: (cause) => + new PauseSync(`Failed to write binary file '${file.newPath}': ${String(cause)}`), + }); + } + }); + + const runSync = (connection: AetherMirrorConnection) => + Effect.gen(function* () { + const taskId = yield* requireTaskId; + // (a) Verify the mirror still matches the last synced state. + const current = yield* captureFingerprint.pipe( + Effect.mapError( + (error) => new PauseSync(`Could not fingerprint the local checkout: ${error.message}`), + ), + ); + const expected = yield* expectedFingerprint(taskId).pipe( + Effect.mapError((error) => + error instanceof PauseSync + ? error + : new PauseSync(`Could not compute the expected mirror state: ${error.message}`), + ), + ); + if (current !== expected) { + return yield* Effect.fail( + new PauseSync( + `The local checkout diverged from the last synced state (expected ${expected}, found ${current}). ` + + "The checkout is a one-way mirror of the Aether workspace — local edits, commits and branch " + + "operations are unsupported during a cloud session. Restore the checkout (or start a fresh " + + "thread from a clean checkout) to resume syncing.", + ), + ); + } + + // (b) The cumulative diff, then re-baseline onto ITS declared base. + const diff = yield* requestDiffWithLockRetry(connection).pipe( + Effect.mapError((error) => { + if (error instanceof PauseSync || error instanceof TransportSkip) { + return error; + } + switch (error._tag) { + case "AetherWorkspaceRequestTimeoutError": + case "AetherWorkspaceDetachedError": + return new TransportSkip( + `The workspace diff request did not complete: ${error.message}`, + false, + ); + default: + return new PauseSync(`The workspace diff request failed: ${error.message}`); + } + }), + ); + + // Fetch is freshness, resolution is the check: pause only when the + // declared base cannot be resolved locally. + yield* git("aether.mirror.fetch", ["fetch", "origin"]).pipe( + Effect.catch((error) => + Effect.logWarning("aether.mirror.fetch.failed", { detail: error.message }), + ), + ); + const resolvedBase = yield* git("aether.mirror.resolve-base", [ + "rev-parse", + "--verify", + `${diff.baseRef}^{commit}`, + ]).pipe( + Effect.map((result) => result.stdout.trim()), + Effect.mapError( + () => + new PauseSync( + `The diff's declared base '${diff.baseRef}' does not resolve in the local checkout ` + + "even after fetching origin. The mirror cannot be re-baselined safely.", + ), + ), + ); + + // (c) reset --hard AND clean -fd (never -x). + const mutate = (operation: string, args: ReadonlyArray) => + git(operation, args).pipe( + Effect.mapError( + (error) => + new PauseSync(`Mirror re-baseline failed (${args.join(" ")}): ${error.message}`), + ), + ); + yield* mutate("aether.mirror.reset", ["reset", "--hard", resolvedBase]); + yield* mutate("aether.mirror.clean", ["clean", "-fd"]); + + // (d) Rebuild + apply the FULL cumulative diff. + const rebuilt = yield* Effect.try({ + try: () => rebuildUnifiedDiff(diff), + catch: (cause) => + cause instanceof AetherDiffRebuildError + ? new PauseSync(`The workspace diff cannot be rebuilt locally: ${cause.detail}`) + : new PauseSync(`The workspace diff cannot be rebuilt locally: ${String(cause)}`), + }); + if (rebuilt.patch.length > 0) { + const applied = yield* git("aether.mirror.apply", ["apply", "--whitespace=nowarn", "-"], { + stdin: rebuilt.patch, + allowNonZeroExit: true, + }).pipe( + Effect.mapError((error) => new PauseSync(`git apply could not run: ${error.message}`)), + ); + if (applied.exitCode !== 0) { + return yield* Effect.fail( + new PauseSync( + `git apply rejected the cumulative diff (exit ${String(applied.exitCode)}): ${applied.stderr.trim()}`, + ), + ); + } + } + yield* applyBinaries(connection, rebuilt.binaries); + + // Record the new synced state — in memory AND in the mirror-local + // record, so a crash between now and t3's next cursor snapshot cannot + // make the next resume misread this very sync as user divergence. + const fingerprint = yield* captureFingerprint.pipe( + Effect.mapError( + (error) => + new PauseSync(`Could not fingerprint the checkout after syncing: ${error.message}`), + ), + ); + yield* persistFingerprint(taskId, fingerprint); + lastSynced = fingerprint; + return { + _tag: "synced", + unifiedDiff: rebuilt.patch, + fileCount: diff.files.length, + modeOnlySkipped: rebuilt.modeOnly, + } as const; + }); + + const syncAtSettle: AetherMirrorSyncEngine["syncAtSettle"] = (connection) => + Effect.gen(function* () { + if (paused !== undefined) { + return { _tag: "paused", reason: paused, firstPause: false } as const; + } + if (connection === undefined) { + return { + _tag: "skipped-detached", + reason: + "no live workspace connection; the next successful sync captures the combined delta", + } as const; + } + const outcome = yield* runSync(connection).pipe(Effect.result); + if (Result.isSuccess(outcome)) { + return outcome.success; + } + const error = outcome.failure; + if (error instanceof TransportSkip) { + if (error.treeMutated) { + // The tree changed under a partially completed sync; re-record + // (memory + mirror-local record) so the next sync verifies against + // reality and rebuilds from base. + const recaptured = yield* Effect.result( + Effect.gen(function* () { + const taskId = yield* requireTaskId; + const fingerprint = yield* captureFingerprint.pipe( + Effect.mapError( + (cause) => + new PauseSync( + `Could not re-fingerprint the checkout after an interrupted sync: ${cause.message}`, + ), + ), + ); + yield* persistFingerprint(taskId, fingerprint); + return fingerprint; + }), + ); + if (Result.isSuccess(recaptured)) { + lastSynced = recaptured.success; + } else { + paused = recaptured.failure.reason; + return { _tag: "paused", reason: paused, firstPause: true } as const; + } + } + return { _tag: "skipped-transport", reason: error.reason } as const; + } + paused = error.reason; + return { _tag: "paused", reason: paused, firstPause: true } as const; + }); + + return { + syncAtSettle, + // Before the first post-(re)start sync the engine has not resolved the + // durable records yet — echo the cursor's fingerprint so a resume that + // never syncs does not silently drop it from the next cursor. + lastSyncedFingerprint: () => lastSynced ?? options.persistedFingerprint, + pausedReason: () => paused, + }; +} diff --git a/apps/server/src/provider/Layers/aether/wireEvents.ts b/apps/server/src/provider/Layers/aether/wireEvents.ts index a55216b733c4..f0b6d28c0a45 100644 --- a/apps/server/src/provider/Layers/aether/wireEvents.ts +++ b/apps/server/src/provider/Layers/aether/wireEvents.ts @@ -159,6 +159,124 @@ const AetherWsSlashCommandsUpdatedEvent = Schema.Struct({ }); export type AetherWsSlashCommandsUpdatedEvent = typeof AetherWsSlashCommandsUpdatedEvent.Type; +// --------------------------------------------------------------------------- +// Git / files channel request-response payloads (T6 mirror sync) +// --------------------------------------------------------------------------- + +/** + * Loose twin of the workspace-protocol `DiffLine` (messages.ts:813-838). + * `kind` stays an open string at this boundary — the mirror engine dispatches + * on add|del|context and fails loudly on anything else (a diff it cannot + * rebuild must never be half-applied). The per-side line numbers are not + * consumed (hunk headers carry the positions), so they are tolerated and + * dropped by the loose struct. + */ +const AetherWsDiffLine = Schema.Struct({ + kind: Schema.String, + text: Schema.String, + noTrailingNewline: Schema.optional(Schema.Boolean), +}); +export type AetherWsDiffLine = typeof AetherWsDiffLine.Type; + +const AetherWsGitDiffHunk = Schema.Struct({ + header: Schema.String, + oldStart: Schema.Number, + oldCount: Schema.Number, + newStart: Schema.Number, + newCount: Schema.Number, + lines: Schema.Array(AetherWsDiffLine), +}); +export type AetherWsGitDiffHunk = typeof AetherWsGitDiffHunk.Type; + +const AetherWsGitDiffFile = Schema.Struct({ + oldPath: Schema.String, + newPath: Schema.String, + displayPath: Schema.String, + // GitFileStatus is added|modified|deleted|renamed today; open string so a + // new status degrades into a typed rebuild error, not a parse crash. + status: Schema.String, + isBinary: Schema.Boolean, + hunks: Schema.Array(AetherWsGitDiffHunk), +}); +export type AetherWsGitDiffFile = typeof AetherWsGitDiffFile.Type; + +/** `GitDiffResult` (messages.ts:862-866): the cumulative merge-base→tree diff. */ +const AetherWsGitDiffResult = Schema.Struct({ + baseRef: Schema.String, + files: Schema.Array(AetherWsGitDiffFile), +}); +export type AetherWsGitDiffResult = typeof AetherWsGitDiffResult.Type; + +const AetherWsGitDiffSuccessResponse = Schema.Struct({ + success: Schema.Literal(true), + diff: AetherWsGitDiffResult, +}); +const AetherWsRequestFailureResponse = Schema.Struct({ + success: Schema.Literal(false), + error: Schema.String, +}); + +/** WS files `read` success (messages.ts FileReadResponse). */ +const AetherWsFileReadSuccessResponse = Schema.Struct({ + success: Schema.Literal(true), + content: Schema.String, + encoding: Schema.Literals(["utf8", "base64"]), + isBinary: Schema.Boolean, +}); +export type AetherWsFileReadSuccessResponse = typeof AetherWsFileReadSuccessResponse.Type; + +export type AetherWsRequestOutcome = + | { readonly _tag: "success"; readonly value: A } + /** The workspace reported the request failed (e.g. git write lock held). */ + | { readonly _tag: "failure"; readonly error: string } + /** A correlated response this build cannot parse — a contract break. */ + | { readonly _tag: "malformed"; readonly detail: string }; + +const decodeGitDiffSuccess = Schema.decodeUnknownResult(AetherWsGitDiffSuccessResponse); +const decodeRequestFailure = Schema.decodeUnknownResult(AetherWsRequestFailureResponse); +const decodeFileReadSuccess = Schema.decodeUnknownResult(AetherWsFileReadSuccessResponse); +const decodeSuccessProbe = Schema.decodeUnknownResult(Schema.Struct({ success: Schema.Boolean })); + +function parseRequestOutcome( + frame: unknown, + decodeSuccess: (frame: unknown) => Result.Result, +): AetherWsRequestOutcome { + const probe = decodeSuccessProbe(frame); + if (Result.isFailure(probe)) { + return { _tag: "malformed", detail: "Response carries no boolean `success` field." }; + } + if (!probe.success.success) { + const failure = decodeRequestFailure(frame); + return { + _tag: "failure", + error: Result.isSuccess(failure) + ? failure.success.error + : "workspace reported a failure without an error message", + }; + } + const decoded = decodeSuccess(frame); + if (Result.isFailure(decoded)) { + return { _tag: "malformed", detail: String(decoded.failure) }; + } + return { _tag: "success", value: decoded.success }; +} + +/** Parse a correlated git `diff` response frame. */ +export function parseAetherGitDiffResponse( + frame: unknown, +): AetherWsRequestOutcome { + return parseRequestOutcome(frame, (input) => + Result.map(decodeGitDiffSuccess(input), (response) => response.diff), + ); +} + +/** Parse a correlated files `read` response frame. */ +export function parseAetherFileReadResponse( + frame: unknown, +): AetherWsRequestOutcome { + return parseRequestOutcome(frame, decodeFileReadSuccess); +} + /** The full parsed agent event union — all 13 wire kinds. */ export type AetherAgentEvent = | AetherWsToolCallEvent @@ -190,6 +308,18 @@ export type AetherFrameParseResult = * zero diagnostics under protocol skew. The caller must surface it. */ | { readonly _tag: "server-error"; readonly detail: string } + /** + * A requestId-correlated response on the `git` or `files` channel — the + * answer to a driver-issued request (mirror sync's diff / binary read). + * The frame stays opaque here; the request issuer decodes it with the + * response parser matching what it asked for. + */ + | { + readonly _tag: "request-response"; + readonly channel: "git" | "files"; + readonly requestId: string; + readonly frame: unknown; + } /** An agent task event whose kind this build does not know. Log once, drop. */ | { readonly _tag: "unknown-kind"; readonly kind: string } /** Not JSON, no envelope, or a KNOWN kind whose payload failed to parse. */ @@ -203,6 +333,9 @@ const decodeKindProbe = Schema.decodeUnknownResult(Schema.Struct({ kind: Schema. // The server error frame is `{channel:"error", type:"error", error: string}` // (workspace-service server.ts); probed loosely like everything else. const decodeErrorProbe = Schema.decodeUnknownResult(Schema.Struct({ error: Schema.String })); +const decodeRequestIdProbe = Schema.decodeUnknownResult( + Schema.Struct({ requestId: Schema.String }), +); const decodeToolCall = Schema.decodeUnknownResult(AetherWsToolCallEvent); const decodeAssistantDelta = Schema.decodeUnknownResult(AetherWsAssistantDeltaEvent); @@ -280,6 +413,21 @@ export function parseAetherAgentFrame(raw: string): AetherFrameParseResult { : "server sent an error frame carrying no string `error` field", }; } + if (envelope.success.channel === "git" || envelope.success.channel === "files") { + // Correlated request-response traffic for the mirror sync engine. Frames + // WITHOUT a requestId (files change broadcasts, git checkpoint + // notifications) are ordinary multiplexed traffic — ignored. + const requestIdProbe = decodeRequestIdProbe(frame); + if (Result.isSuccess(requestIdProbe)) { + return { + _tag: "request-response", + channel: envelope.success.channel, + requestId: requestIdProbe.success.requestId, + frame, + }; + } + return { _tag: "ignored", channel: envelope.success.channel, type: envelope.success.type }; + } if (envelope.success.channel !== "agent" || envelope.success.type !== "task_event") { return { _tag: "ignored", channel: envelope.success.channel, type: envelope.success.type }; } diff --git a/apps/server/src/provider/Layers/aether/workspaceSocket.test.ts b/apps/server/src/provider/Layers/aether/workspaceSocket.test.ts index bc421df75119..d93059b034df 100644 --- a/apps/server/src/provider/Layers/aether/workspaceSocket.test.ts +++ b/apps/server/src/provider/Layers/aether/workspaceSocket.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; import { AetherApiTransportError } from "./restClient.ts"; @@ -11,6 +12,7 @@ import { resolveTaskWorkspace, runAetherAgentStream, aetherWorkspaceSocketUrl, + type AetherAgentConnection, type AetherAgentStreamOptions, type AetherWebSocketLike, } from "./workspaceSocket.ts"; @@ -604,3 +606,279 @@ describe("runAetherAgentStream", () => { }), ); }); + +// --------------------------------------------------------------------------- +// Git / files channel request-response correlation (T6 mirror sync transport) +// --------------------------------------------------------------------------- + +const decodeSentRequestFrame = Schema.decodeSync( + Schema.fromJsonString( + Schema.Struct({ + channel: Schema.String, + type: Schema.String, + requestId: Schema.String, + mode: Schema.optional(Schema.String), + path: Schema.optional(Schema.String), + }), + ), +); + +describe("workspace request-response channel", () => { + const connectedHarness = () => { + const connections: Array = []; + const harness = makeHarness({ + getTask: scriptedGetTask([taskProcessing]).getTask, + connectWorkspace: scriptedConnect([runningOutcome]).connectWorkspace, + }); + const options: AetherAgentStreamOptions = { + ...harness.options, + onConnected: (connection) => Effect.sync(() => void connections.push(connection)), + }; + return { harness, options, connections }; + }; + + it.effect("correlates a git diff response by requestId", () => + Effect.gen(function* () { + const { harness, options, connections } = connectedHarness(); + const fiber = yield* Effect.forkChild(runAetherAgentStream(options)); + yield* settlePump; + const connection = connections[0]!; + const socket = harness.sockets[0]!; + + const request = yield* Effect.forkChild(connection.requestGitDiff({ mode: "main" })); + yield* settlePump; + const sentFrame = socket.sent.find((frame) => frame.includes('"channel":"git"')); + expect(sentFrame).toBeDefined(); + const parsed = decodeSentRequestFrame(sentFrame!); + expect(parsed.type).toBe("diff"); + expect(parsed.mode).toBe("main"); + + // An unmatched response is dropped, the matched one resolves. + socket.message({ + channel: "git", + type: "diff", + requestId: "someone-else", + success: true, + diff: { baseRef: "bogus", files: [] }, + }); + socket.message({ + channel: "git", + type: "diff", + requestId: parsed.requestId, + success: true, + diff: { baseRef: "abc123", files: [] }, + }); + yield* settlePump; + const diff = yield* Fiber.join(request); + expect(diff).toEqual({ baseRef: "abc123", files: [] }); + + yield* Fiber.interrupt(fiber); + }), + ); + + it.effect("maps success:false, timeout, and socket-drop to typed errors", () => + Effect.gen(function* () { + const { harness, options, connections } = connectedHarness(); + const fiber = yield* Effect.forkChild(runAetherAgentStream(options)); + yield* settlePump; + const connection = connections[0]!; + const socket = harness.sockets[0]!; + + // success:false → request-failed (the write-lock answer takes this shape). + const failing = yield* Effect.forkChild( + Effect.flip(connection.requestGitDiff({ mode: "main" })), + ); + yield* settlePump; + const failingId = decodeSentRequestFrame(socket.sent.at(-1)!).requestId; + socket.message({ + channel: "git", + type: "diff", + requestId: failingId, + success: false, + error: "A git write operation is in progress, cannot read diff", + }); + yield* settlePump; + const failure = yield* Fiber.join(failing); + expect(failure._tag).toBe("AetherWorkspaceRequestFailedError"); + expect(failure.message).toContain("write operation is in progress"); + + // No answer → timeout after the request budget. + const timing = yield* Effect.forkChild( + Effect.flip(connection.requestGitDiff({ mode: "main" })), + ); + yield* settlePump; + yield* TestClock.adjust("31 seconds"); + const timeout = yield* Fiber.join(timing); + expect(timeout._tag).toBe("AetherWorkspaceRequestTimeoutError"); + + // Socket drop with a request in flight → typed detached failure. + const dropped = yield* Effect.forkChild(Effect.flip(connection.readWorkspaceFile("a/b.bin"))); + yield* settlePump; + socket.serverClose(1006, "gone"); + yield* settlePump; + const detached = yield* Fiber.join(dropped); + expect(detached._tag).toBe("AetherWorkspaceDetachedError"); + + yield* Fiber.interrupt(fiber); + }), + ); + + it.effect("a request issued from INSIDE onEvent resolves — no pump self-deadlock", () => + Effect.gen(function* () { + // Regression: the mirror sync engine requests the git diff from inside + // the turn-settle event handler (sync-then-settle). If frame routing + // and event handling shared one fiber, the response frame would sit in + // the signal queue behind the very handler awaiting it and EVERY + // live-observed settle would stall for the full request timeout. + const connections: Array = []; + const resolvedDiffs: Array = []; + const harness = makeHarness( + { + getTask: scriptedGetTask([taskProcessing]).getTask, + connectWorkspace: scriptedConnect([runningOutcome]).connectWorkspace, + }, + (socket) => { + // The workspace side: answer every git diff request as soon as it + // is sent (synchronously — the harshest ordering). + const originalSend = socket.send.bind(socket); + socket.send = (data: string) => { + originalSend(data); + const frame = JSON.parse(data) as { channel?: string; requestId?: string }; + if (frame.channel === "git" && typeof frame.requestId === "string") { + socket.message({ + channel: "git", + type: "diff", + requestId: frame.requestId, + success: true, + diff: { baseRef: "abc123", files: [] }, + }); + } + }; + }, + ); + const options: AetherAgentStreamOptions = { + ...harness.options, + onConnected: (connection) => Effect.sync(() => void connections.push(connection)), + onEvent: () => + Effect.gen(function* () { + // orDie: a timeout HERE is exactly the deadlock this test guards + // against — it must crash the test, never be swallowed. + const diff = yield* Effect.orDie(connections[0]!.requestGitDiff({ mode: "main" })); + resolvedDiffs.push(diff.baseRef); + }), + }; + const fiber = yield* Effect.forkChild(runAetherAgentStream(options)); + yield* settlePump; + + // Two settles in a row: each handler's request must resolve without + // ANY clock advancement (the request timeout never fires) and the pump + // must keep flowing to the next event. + harness.sockets[0]!.message(wsAssistantDelta); + yield* settlePump; + harness.sockets[0]!.message(wsAssistantDelta); + yield* settlePump; + expect(resolvedDiffs).toEqual(["abc123", "abc123"]); + + yield* Fiber.interrupt(fiber); + }), + ); + + it.effect( + "a request issued from INSIDE onConnected resolves — the router drains first", + () => + Effect.gen(function* () { + // Regression: onConnected drives the reconcile, which can settle a + // turn and (through the mirror sync) request the git diff. With the + // router forked only AFTER onConnected returned, that response sat + // unconsumed in the signal queue while onConnected awaited it — + // every reconnect-with-a-pending-settle deadlocked for the full + // request timeout. + const resolvedDiffs: Array = []; + const harness = makeHarness( + { + getTask: scriptedGetTask([taskProcessing]).getTask, + connectWorkspace: scriptedConnect([runningOutcome]).connectWorkspace, + }, + (socket) => { + // The workspace side answers every git diff request synchronously + // — the harshest ordering for the drain loop. + const originalSend = socket.send.bind(socket); + socket.send = (data: string) => { + originalSend(data); + const frame = JSON.parse(data) as { channel?: string; requestId?: string }; + if (frame.channel === "git" && typeof frame.requestId === "string") { + socket.message({ + channel: "git", + type: "diff", + requestId: frame.requestId, + success: true, + diff: { baseRef: "abc123", files: [] }, + }); + } + }; + }, + ); + const options: AetherAgentStreamOptions = { + ...harness.options, + onConnected: (connection) => + Effect.gen(function* () { + // orDie: a timeout HERE is exactly the deadlock under test. + const diff = yield* Effect.orDie(connection.requestGitDiff({ mode: "main" })); + resolvedDiffs.push(diff.baseRef); + }), + }; + const fiber = yield* Effect.forkChild(runAetherAgentStream(options)); + yield* settlePump; + // Resolved without ANY clock advancement (the request timeout never + // fired), and the pump went on to handle live frames normally. + expect(resolvedDiffs).toEqual(["abc123"]); + + harness.sockets[0]!.message(wsAssistantDelta); + yield* settlePump; + expect(harness.events).toHaveLength(1); + + // The same on RECONNECT: the second attach's onConnected must not + // hang either. + harness.sockets[0]!.serverClose(1006, "vm went away"); + yield* settlePump; + expect(resolvedDiffs).toEqual(["abc123", "abc123"]); + + yield* Fiber.interrupt(fiber); + }), + // A regression deadlocks instead of failing an assertion: cap it so the + // suite fails fast rather than hanging. + { timeout: 15_000 }, + ); + + it.effect("reads a workspace file over the files channel", () => + Effect.gen(function* () { + const { harness, options, connections } = connectedHarness(); + const fiber = yield* Effect.forkChild(runAetherAgentStream(options)); + yield* settlePump; + const connection = connections[0]!; + const socket = harness.sockets[0]!; + + const request = yield* Effect.forkChild(connection.readWorkspaceFile("assets/logo.png")); + yield* settlePump; + const frame = decodeSentRequestFrame(socket.sent.at(-1)!); + expect(frame).toMatchObject({ channel: "files", type: "read", path: "assets/logo.png" }); + socket.message({ + channel: "files", + type: "read", + requestId: frame.requestId, + success: true, + path: "assets/logo.png", + content: "aGVsbG8=", + encoding: "base64", + size: 5, + modified: "2026-08-08T10:00:00Z", + isBinary: true, + }); + yield* settlePump; + const read = yield* Fiber.join(request); + expect(read).toMatchObject({ content: "aGVsbG8=", encoding: "base64", isBinary: true }); + + yield* Fiber.interrupt(fiber); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/aether/workspaceSocket.ts b/apps/server/src/provider/Layers/aether/workspaceSocket.ts index 0d9b37bdc1ea..014cb6181b18 100644 --- a/apps/server/src/provider/Layers/aether/workspaceSocket.ts +++ b/apps/server/src/provider/Layers/aether/workspaceSocket.ts @@ -37,6 +37,7 @@ * * @module provider/Layers/aether/workspaceSocket */ +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Queue from "effect/Queue"; @@ -44,7 +45,16 @@ import * as Schema from "effect/Schema"; import type { AetherRestClient, AetherRestError } from "./restClient.ts"; import type { AetherTask } from "./restSchemas.ts"; -import { parseAetherAgentFrame, type AetherAgentEvent } from "./wireEvents.ts"; +import { + parseAetherAgentFrame, + parseAetherFileReadResponse, + parseAetherGitDiffResponse, + type AetherAgentEvent, + type AetherFrameParseResult, + type AetherWsFileReadSuccessResponse, + type AetherWsGitDiffResult, + type AetherWsRequestOutcome, +} from "./wireEvents.ts"; // --------------------------------------------------------------------------- // Errors @@ -109,6 +119,71 @@ export type AetherAttachError = | AetherWorkspaceConnectTimeoutError | AetherRestError; +// --------------------------------------------------------------------------- +// Request-response errors (git diff / files read over the live socket) +// --------------------------------------------------------------------------- + +/** The workspace never answered a correlated request within the budget. */ +export class AetherWorkspaceRequestTimeoutError extends Schema.TaggedErrorClass()( + "AetherWorkspaceRequestTimeoutError", + { + channel: Schema.String, + requestType: Schema.String, + requestId: Schema.String, + timeoutMs: Schema.Number, + }, +) { + override get message(): string { + return `Aether workspace did not answer the ${this.channel} '${this.requestType}' request within ${this.timeoutMs}ms.`; + } +} + +/** The workspace answered a correlated request with success:false. */ +export class AetherWorkspaceRequestFailedError extends Schema.TaggedErrorClass()( + "AetherWorkspaceRequestFailedError", + { + channel: Schema.String, + requestType: Schema.String, + detail: Schema.String, + }, +) { + override get message(): string { + return `Aether workspace ${this.channel} '${this.requestType}' request failed: ${this.detail}`; + } +} + +/** The socket dropped (or was never open) while a request needed it. */ +export class AetherWorkspaceDetachedError extends Schema.TaggedErrorClass()( + "AetherWorkspaceDetachedError", + { + detail: Schema.String, + }, +) { + override get message(): string { + return `Aether workspace socket is not attached: ${this.detail}`; + } +} + +/** A correlated response that this build cannot parse — a contract break. */ +export class AetherWorkspaceResponseMalformedError extends Schema.TaggedErrorClass()( + "AetherWorkspaceResponseMalformedError", + { + channel: Schema.String, + requestType: Schema.String, + detail: Schema.String, + }, +) { + override get message(): string { + return `Aether workspace ${this.channel} '${this.requestType}' response did not parse: ${this.detail}`; + } +} + +export type AetherWorkspaceRequestError = + | AetherWorkspaceRequestTimeoutError + | AetherWorkspaceRequestFailedError + | AetherWorkspaceDetachedError + | AetherWorkspaceResponseMalformedError; + // --------------------------------------------------------------------------- // WebSocket seam (injectable for tests; defaults to the Node global) // --------------------------------------------------------------------------- @@ -191,6 +266,8 @@ export interface AetherStreamTiming { readonly connectMaxAttempts: number; /** Fallback wait when the server sends no retry_after_ms. */ readonly connectDefaultRetryMs: number; + /** Budget for one correlated git/files request over the live socket. */ + readonly requestTimeoutMs: number; } const DEFAULT_TIMING: AetherStreamTiming = { @@ -201,6 +278,7 @@ const DEFAULT_TIMING: AetherStreamTiming = { openTimeoutMs: 15_000, connectMaxAttempts: 60, connectDefaultRetryMs: 1_000, + requestTimeoutMs: 30_000, }; const backoffMs = (initialMs: number, maxMs: number, attempt: number): number => @@ -336,10 +414,25 @@ export const connectForTransport = Effect.fn("connectForTransport")(function* (o export interface AetherAgentConnection { /** * Send one `user_activity` keep-alive ping. The T6 turn engine drives this - * (throttled to ACTIVITY_PING_THROTTLE_MS) while a turn is active so the - * VM's interactive idle hold stays alive; nothing calls it yet. + * from its settle-poll beat while a turn is active so the VM's interactive + * idle hold stays alive. */ readonly sendUserActivity: () => Effect.Effect; + /** + * Request the cumulative git diff over the git channel: + * `{channel:"git", type:"diff", requestId, mode}` → `GitDiffResult`. + * requestId-correlated with a timeout; every failure is typed. + */ + readonly requestGitDiff: (input: { + readonly mode: "main" | "lastCommit"; + }) => Effect.Effect; + /** + * Read one workspace file over the files channel (the binary-file path of + * the mirror sync — base64 content for isBinary diff entries). + */ + readonly readWorkspaceFile: ( + path: string, + ) => Effect.Effect; } export interface AetherAgentStreamOptions { @@ -349,11 +442,21 @@ export interface AetherAgentStreamOptions { readonly taskId: string; readonly webSocketFactory?: AetherWebSocketFactory; readonly timing?: Partial; + /** + * Pass `start=true` to the FIRST connect attempt of this stream — the one + * path allowed to boot a VM, reserved for a user-initiated turn (the T6 + * sendTurn attach). Consumed after one connect; every re-attach after a + * drop is passive again (`start=false`), preserving the + * viewing-never-starts-a-VM invariant. + */ + readonly startOnFirstAttach?: boolean; /** * Fires after every successful attach+subscribe, BEFORE live frames are * handled — drive the conversation/delta reconciliation from the resume * cursor here (the ONLY recovery for live-only turn.* events missed while - * detached). + * detached). The reconcile may settle a turn and issue correlated + * git/files requests on the handed connection: those resolve normally, + * because the frame router is already draining when this runs. */ readonly onConnected: (connection: AetherAgentConnection) => Effect.Effect; /** One parsed agent event. */ @@ -386,6 +489,12 @@ export interface AetherAgentStreamOptions { * workspace). Terminal for this attach: the next sendTurn re-attaches. */ readonly onDurableOnly: (reason: string) => Effect.Effect; + /** + * The live socket dropped (after having connected). The connection handle + * handed to `onConnected` is dead from this moment — callers must stop + * issuing requests on it until the next `onConnected`. + */ + readonly onDisconnected?: () => Effect.Effect; } type SocketSignal = @@ -501,6 +610,10 @@ export const runAetherAgentStream = Effect.fn("runAetherAgentStream")(function* let reconnectAttempt = 0; let consecutiveFailures = 0; let everConnected = false; + // One-shot start permission (user-initiated turn). Consumed by the first + // connect attempt whether or not it succeeds — a failed active attach must + // not leave a VM-boot permission armed for a later passive reconnect. + let startPermission = options.startOnFirstAttach === true; while (true) { // Re-resolve the FULL attach every iteration: task status and workspace @@ -515,10 +628,12 @@ export const runAetherAgentStream = Effect.fn("runAetherAgentStream")(function* if (resolution._tag === "parked") { return { _tag: "parked" } as const; } + const start = startPermission; + startPermission = false; const transport = yield* connectForTransport({ connectWorkspace: options.restClient.connectWorkspace, workspaceId: resolution.workspaceId, - start: false, + start, timing, }); if (transport._tag === "unavailable") { @@ -577,6 +692,124 @@ export const runAetherAgentStream = Effect.fn("runAetherAgentStream")(function* reconnectAttempt = 0; consecutiveFailures = 0; everConnected = true; + + // Correlated request-response state for this connection. + // Every pending request fails with a typed detached error when + // the connection scope closes (socket drop or session stop). + const pending = new Map< + string, + Deferred.Deferred + >(); + let requestCounter = 0; + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + for (const deferred of pending.values()) { + yield* Deferred.fail( + deferred, + new AetherWorkspaceDetachedError({ + detail: "the workspace socket closed with the request in flight", + }), + ).pipe(Effect.ignore); + } + pending.clear(); + }), + ); + + const sendRequest = (input: { + readonly channel: "git" | "files"; + readonly requestType: string; + readonly message: (requestId: string) => string; + readonly parse: (frame: unknown) => AetherWsRequestOutcome; + }): Effect.Effect => + Effect.gen(function* () { + requestCounter++; + const requestId = `t3-${input.channel}-${requestCounter}`; + const deferred = yield* Deferred.make(); + pending.set(requestId, deferred); + yield* Effect.try({ + try: () => opened.socket.send(input.message(requestId)), + catch: (cause) => + new AetherWorkspaceDetachedError({ + detail: `failed to send the ${input.channel} '${input.requestType}' request: ${String(cause)}`, + }), + }); + const frame = yield* Deferred.await(deferred).pipe( + Effect.timeout(Duration.millis(timing.requestTimeoutMs)), + Effect.catchTag( + "TimeoutError", + () => + new AetherWorkspaceRequestTimeoutError({ + channel: input.channel, + requestType: input.requestType, + requestId, + timeoutMs: timing.requestTimeoutMs, + }), + ), + Effect.ensuring(Effect.sync(() => pending.delete(requestId))), + ); + const outcome = input.parse(frame); + switch (outcome._tag) { + case "success": + return outcome.value; + case "failure": + return yield* new AetherWorkspaceRequestFailedError({ + channel: input.channel, + requestType: input.requestType, + detail: outcome.error, + }); + case "malformed": + return yield* new AetherWorkspaceResponseMalformedError({ + channel: input.channel, + requestType: input.requestType, + detail: outcome.detail, + }); + } + }); + + // Frame ROUTING runs on its own fiber so request-response + // resolution never waits behind event handling: the mirror + // sync engine issues correlated git/files requests from INSIDE + // `onEvent` (sync-then-settle) AND from inside `onConnected` + // (the reconcile can settle a turn), and a single fiber doing + // both would deadlock — the response frame would sit in the + // signal queue behind the very handler awaiting it, + // guaranteeing the request timeout. The router is therefore + // forked and DRAINING before `onConnected` runs. Event ORDER is + // preserved regardless: the router only forwards non-response + // frames FIFO into `routed`, and nothing takes from `routed` + // until the consumer loop below, which starts strictly after + // `onConnected` has returned. + const routed = yield* Queue.unbounded< + | { readonly _tag: "closed"; readonly code: number; readonly reason: string } + | { + readonly _tag: "frame"; + readonly parsed: Exclude< + AetherFrameParseResult, + { readonly _tag: "request-response" } + >; + } + >(); + yield* Effect.gen(function* () { + while (true) { + const signal = yield* Queue.take(opened.signals); + if (signal._tag === "closed") { + yield* Queue.offer(routed, signal); + return; + } + const parsed = parseAetherAgentFrame(signal.data); + if (parsed._tag === "request-response") { + const waiter = pending.get(parsed.requestId); + if (waiter !== undefined) { + pending.delete(parsed.requestId); + yield* Deferred.succeed(waiter, parsed.frame).pipe(Effect.ignore); + } + // No waiter: the request already timed out — drop. + continue; + } + yield* Queue.offer(routed, { _tag: "frame", parsed }); + } + }).pipe(Effect.forkScoped); + yield* options.onConnected({ sendUserActivity: () => Effect.sync(() => @@ -584,14 +817,30 @@ export const runAetherAgentStream = Effect.fn("runAetherAgentStream")(function* encodeUserActivityMessage({ channel: "activity", type: "user_activity" }), ), ), + requestGitDiff: ({ mode }) => + sendRequest({ + channel: "git", + requestType: "diff", + message: (requestId) => + JSON.stringify({ channel: "git", type: "diff", requestId, mode }), + parse: parseAetherGitDiffResponse, + }), + readWorkspaceFile: (path) => + sendRequest({ + channel: "files", + requestType: "read", + message: (requestId) => + JSON.stringify({ channel: "files", type: "read", requestId, path }), + parse: parseAetherFileReadResponse, + }), }); while (true) { - const signal = yield* Queue.take(opened.signals); - if (signal._tag === "closed") { - return signal; + const item = yield* Queue.take(routed); + if (item._tag === "closed") { + return item; } - const parsed = parseAetherAgentFrame(signal.data); + const parsed = item.parsed; switch (parsed._tag) { case "event": { // The socket is workspace-scoped and frames carry their @@ -679,6 +928,9 @@ export const runAetherAgentStream = Effect.fn("runAetherAgentStream")(function* code: pumped.code, reason: pumped.reason, }); + if (options.onDisconnected !== undefined) { + yield* options.onDisconnected(); + } } reconnectAttempt++; yield* Effect.sleep( diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 4ddb01e09dd7..ad82fc37eadb 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -103,6 +103,7 @@ import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as ServerConfig from "./config.ts"; import { makeRoutesLayer } from "./server.ts"; import { isThreadDetailEvent, resolveAvailableEditorsForConfig } from "./ws.ts"; +import * as AetherMirrorRegistryModule from "./provider/AetherMirrorRegistry.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as GitManager from "./git/GitManager.ts"; import * as Keybindings from "./keybindings.ts"; @@ -614,15 +615,21 @@ const buildAppUnderTest = (options?: { disableLogger: true, }, ).pipe( + // The ws layer's Aether cloud-session write guard reads this registry; + // the real (empty) one is exactly the no-cloud-session case. Merged + // with the keybindings mock to stay under the pipe arity cap. Layer.provide( - Layer.mock(Keybindings.Keybindings)({ - loadConfigState: Effect.succeed({ - keybindings: [], - issues: [], + Layer.mergeAll( + AetherMirrorRegistryModule.layer, + Layer.mock(Keybindings.Keybindings)({ + loadConfigState: Effect.succeed({ + keybindings: [], + issues: [], + }), + streamChanges: Stream.empty, + ...options?.layers?.keybindings, }), - streamChanges: Stream.empty, - ...options?.layers?.keybindings, - }), + ), ), Layer.provide( Layer.mock(ProviderRegistry.ProviderRegistry)({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 5fafd74137e0..46568c5b9fd9 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -39,6 +39,7 @@ import * as GitHubCli from "./sourceControl/GitHubCli.ts"; import * as GitLabCli from "./sourceControl/GitLabCli.ts"; import * as TextGeneration from "./textGeneration/TextGeneration.ts"; import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; +import * as AetherMirrorRegistry from "./provider/AetherMirrorRegistry.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; @@ -386,8 +387,12 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // drivers (native stream, written from inside each `Adapter`) and // `ProviderService` (canonical stream, written after event normalization). // Provided once at the runtime level so every consumer sees the same - // logger instances. - Layer.provideMerge(ProviderEventLoggers.layer), + // logger instances. Merged with the ONE mirror registry for the whole + // runtime: `AetherDriver.create()` registers cloud-session cwds into it, + // and the ws.ts dispatch-site guard (build item 8a) refuses local writes + // against those cwds — merged into one pipe argument to stay under the + // pipe overload arity cap. + Layer.provideMerge(Layer.mergeAll(ProviderEventLoggers.layer, AetherMirrorRegistry.layer)), // `OpenCodeDriver.create()` yields `OpenCodeRuntime`; previously the old // `ProviderRegistryLive` pulled `OpenCodeRuntimeLive` in for itself, but // the rewritten registry reads snapshots off the instance registry and diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a6b155c296f7..a040affd69d5 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -20,6 +20,8 @@ import { EventId, type OrchestrationCommand, type GitActionProgressEvent, + GitCommandError, + GitManagerError, type GitManagerServiceError, OrchestrationDispatchCommandError, type OrchestrationEvent, @@ -79,6 +81,13 @@ import { observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import { + AETHER_MIRROR_REFUSAL, + aetherMirrorWriteFileError, + guardAetherRemoveWorktree, + guardAetherVcsMutation, +} from "./provider/AetherMirrorGuards.ts"; +import { AetherMirrorRegistry } from "./provider/AetherMirrorRegistry.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; @@ -360,6 +369,27 @@ const makeWsRpcLayer = ( const keybindings = yield* Keybindings.Keybindings; const externalLauncher = yield* ExternalLauncher.ExternalLauncher; const gitWorkflow = yield* GitWorkflowService.GitWorkflowService; + const aetherMirrorRegistry = yield* AetherMirrorRegistry; + + // -- Aether cloud-session write guard (build item 8a) ----------------- + // While an Aether thread owns a cwd, that checkout is a one-way mirror + // of the cloud VM: local writes never reach the VM and silently break + // the next turn's reset-and-apply sync. These RPCs dispatch straight + // into workspaceFileSystem/gitWorkflow (they never cross + // ProviderAdapter), so the refusal lives HERE, at the dispatch sites — + // the guard logic itself is in provider/AetherMirrorGuards.ts (tested). + const guardVcsMutation = ( + operation: string, + cwd: string, + effect: Effect.Effect, + ): Effect.Effect => + guardAetherVcsMutation(aetherMirrorRegistry, operation, cwd, effect); + + const guardRemoveWorktree = ( + input: { readonly cwd: string; readonly path: string }, + effect: Effect.Effect, + ): Effect.Effect => + guardAetherRemoveWorktree(aetherMirrorRegistry, input, effect); const review = yield* ReviewService.ReviewService; const vcsProvisioning = yield* VcsProvisioningService.VcsProvisioningService; const vcsStatusBroadcaster = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; @@ -1691,16 +1721,25 @@ const makeWsRpcLayer = ( [WS_METHODS.projectsWriteFile]: (input) => observeRpcEffect( WS_METHODS.projectsWriteFile, - workspaceFileSystem.writeFile(input).pipe( - Effect.mapError( - (cause) => - new ProjectWriteFileError({ - cwd: input.cwd, - relativePath: input.relativePath, - ...projectFileFailureContext(cause), - cause, - }), - ), + // ownsPathWithin, not ownsCwd: relativePath resolves under cwd, + // so a PARENT project cwd can descend into an active mirror + // (cwd=/repo, relativePath=.worktrees/mirror/app.ts). + Effect.flatMap( + aetherMirrorRegistry.ownsPathWithin(input.cwd, input.relativePath), + (owned) => + owned + ? Effect.fail(aetherMirrorWriteFileError(input)) + : workspaceFileSystem.writeFile(input).pipe( + Effect.mapError( + (cause) => + new ProjectWriteFileError({ + cwd: input.cwd, + relativePath: input.relativePath, + ...projectFileFailureContext(cause), + cause, + }), + ), + ), ), { "rpc.aggregate": "workspace" }, ), @@ -1790,35 +1829,54 @@ const makeWsRpcLayer = ( [WS_METHODS.vcsPull]: (input) => observeRpcEffect( WS_METHODS.vcsPull, - gitWorkflow.pullCurrentBranch(input.cwd).pipe( - Effect.matchCauseEffect({ - onFailure: (cause) => Effect.failCause(cause), - onSuccess: (result) => - refreshGitStatus(input.cwd).pipe(Effect.ignore({ log: true }), Effect.as(result)), - }), + guardVcsMutation( + "vcs.pull", + input.cwd, + gitWorkflow.pullCurrentBranch(input.cwd).pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => Effect.failCause(cause), + onSuccess: (result) => + refreshGitStatus(input.cwd).pipe( + Effect.ignore({ log: true }), + Effect.as(result), + ), + }), + ), ), { "rpc.aggregate": "git" }, ), [WS_METHODS.gitRunStackedAction]: (input) => observeRpcStream( WS_METHODS.gitRunStackedAction, - Stream.callback((queue) => - gitWorkflow - .runStackedAction(input, { - actionId: input.actionId, - progressReporter: { - publish: (event) => Queue.offer(queue, event).pipe(Effect.asVoid), - }, - }) - .pipe( - Effect.matchCauseEffect({ - onFailure: (cause) => Queue.failCause(queue, cause), - onSuccess: () => - refreshGitStatus(input.cwd).pipe( - Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), - ), - }), - ), + Stream.unwrap( + Effect.map(aetherMirrorRegistry.ownsCwd(input.cwd), (owned) => + owned + ? Stream.fail( + new GitManagerError({ + operation: "git.runStackedAction", + cwd: input.cwd, + detail: AETHER_MIRROR_REFUSAL, + }), + ) + : Stream.callback((queue) => + gitWorkflow + .runStackedAction(input, { + actionId: input.actionId, + progressReporter: { + publish: (event) => Queue.offer(queue, event).pipe(Effect.asVoid), + }, + }) + .pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => Queue.failCause(queue, cause), + onSuccess: () => + refreshGitStatus(input.cwd).pipe( + Effect.andThen(Queue.end(queue).pipe(Effect.asVoid)), + ), + }), + ), + ), + ), ), { "rpc.aggregate": "vcs" }, ), @@ -1845,25 +1903,40 @@ const makeWsRpcLayer = ( [WS_METHODS.vcsCreateWorktree]: (input) => observeRpcEffect( WS_METHODS.vcsCreateWorktree, - gitWorkflow.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + guardVcsMutation( + "vcs.createWorktree", + input.cwd, + gitWorkflow.createWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + ), { "rpc.aggregate": "vcs" }, ), [WS_METHODS.vcsRemoveWorktree]: (input) => observeRpcEffect( WS_METHODS.vcsRemoveWorktree, - gitWorkflow.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + guardRemoveWorktree( + input, + gitWorkflow.removeWorktree(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + ), { "rpc.aggregate": "vcs" }, ), [WS_METHODS.vcsCreateRef]: (input) => observeRpcEffect( WS_METHODS.vcsCreateRef, - gitWorkflow.createRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + guardVcsMutation( + "vcs.createRef", + input.cwd, + gitWorkflow.createRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + ), { "rpc.aggregate": "vcs" }, ), [WS_METHODS.vcsSwitchRef]: (input) => observeRpcEffect( WS_METHODS.vcsSwitchRef, - gitWorkflow.switchRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + guardVcsMutation( + "vcs.switchRef", + input.cwd, + gitWorkflow.switchRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + ), { "rpc.aggregate": "vcs" }, ), [WS_METHODS.vcsInit]: (input) => diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index d13d27104634..b8611d6575c6 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -52,13 +52,13 @@ export const PROVIDER_OPTIONS: Array<{ available: true, pickerSidebarBadge: "new", }, - // T1 skeleton: the Aether adapter fails every session/turn operation until - // T3/T6 land, so the picker entry stays unavailable ("soon"), not live. + // T6 landed the turn protocol + mirror sync: Aether is selectable end to + // end. { value: ProviderDriverKind.make("aether"), label: "Aether", - available: false, - pickerSidebarBadge: "soon", + available: true, + pickerSidebarBadge: "new", }, ]; diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index a1b11df73b21..2947b689cf77 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -233,6 +233,8 @@ type ProjectFileFailureContext = { readonly resolvedWorkspaceRoot?: string; readonly operation?: ProjectFileOperation; readonly operationPath?: string; + /** Overrides the derived message (the constructors honor it via decodedProjectErrorMessage). */ + readonly message?: string; readonly cause?: unknown; }; From 3d5217bbcfeb1a78a3e47cc83f71f9ea4e212d35 Mon Sep 17 00:00:00 2001 From: Pranav Sharan Date: Sat, 8 Aug 2026 14:50:17 -0700 Subject: [PATCH 07/44] feat(aether): question/plan responses, revert ledger, model switch, polish (#6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(aether): question/plan responses, revert ledger, model switch, polish T7: respondToUserInput maps t3 answers to aether's exact ask_user wire shape (index-keyed answers, -1 custom sentinel + customAnswers); plan accept/reject rides the fresh-turn route t3 actually uses, as propose_plan {approved, feedback}; stale requests render t3's stale-request affordance; 409 bodies surface their decoded message after a delta re-sync; typed turn→message ledger rides the resume cursor; rollback is a typed one-way-mirror refusal. T8: in-session model switch via read-modify-write full-replace PUT (auto_fix flags read live first); explicit interaction_mode on plan-mode sends; remote-originated turns surface as warning cards with the injected text; cancelled steers settle interrupted and re-offer their text; out-of-band question resolution clears the pending panel (with a corrective ready when observed into message-idle); mobile ProviderIcon + server badge parity; load-bearing stopAll copy annotated. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * fix(aether): option-only model switch, mobile empty-option questions, reconcile ordering Review round 1 on T7/T8. (1) The between-turns switch guard now fires on a resolved reasoning-effort change with the same model slug, not only a slug change — an option-only switch reaches the task instead of silently keeping the old effort. (2) apps/mobile threadActivity keeps an answerable question with zero parsed options (custom-answer-only Aether question) instead of dropping it, matching the mapper/web contract. (3) The idle-session eager reconcile no longer lets a stale pre-reconcile event emit after the reconcile catches the same completion — ordering made deterministic. Each pinned by a test. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h --------- Co-authored-by: Claude Fable 5 --- apps/mobile/src/components/ProviderIcon.tsx | 9 + apps/mobile/src/lib/threadActivity.test.ts | 46 + apps/mobile/src/lib/threadActivity.ts | 8 +- .../src/provider/Layers/AetherAdapter.test.ts | 1065 ++++++++++++++++- .../src/provider/Layers/AetherAdapter.ts | 749 ++++++++++-- .../src/provider/Layers/AetherProvider.ts | 3 + .../Layers/aether/eventMapper.fixtures.ts | 3 + .../Layers/aether/eventMapper.test.ts | 199 +++ .../src/provider/Layers/aether/eventMapper.ts | 228 +++- .../provider/Layers/aether/restClient.test.ts | 3 + .../src/provider/Layers/aether/restSchemas.ts | 6 + 11 files changed, 2171 insertions(+), 148 deletions(-) diff --git a/apps/mobile/src/components/ProviderIcon.tsx b/apps/mobile/src/components/ProviderIcon.tsx index bdddf2c45951..e70a09c43534 100644 --- a/apps/mobile/src/components/ProviderIcon.tsx +++ b/apps/mobile/src/components/ProviderIcon.tsx @@ -49,6 +49,15 @@ export function ProviderIcon(props: ProviderIconProps) { ); } + if (props.provider === "aether") { + return ( + + + + + ); + } + if (props.provider === "opencode") { return ( diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index ae9a93e9fc36..68f44a0a942d 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -12,7 +12,9 @@ import { } from "@t3tools/contracts"; import { + buildPendingUserInputAnswers, buildThreadFeed, + derivePendingUserInputs, deriveThreadFeedPresentation, type ThreadFeedActivity, type ThreadFeedEntry, @@ -55,6 +57,50 @@ function makeThread( }; } +describe("derivePendingUserInputs", () => { + it("keeps a custom-answer-only question so the submission is never partial", () => { + const pending = derivePendingUserInputs([ + makeActivity({ + id: EventId.make("activity-ask"), + kind: "user-input.requested", + summary: "User input needed", + createdAt: "2026-08-08T10:00:00.000Z", + payload: { + requestId: "request-1", + questions: [ + { + id: "q1", + header: "Scope", + question: "Which files should I touch?", + options: [{ label: "All", description: "Everything in the repo" }], + }, + // The provider's custom-answer-only question: an EMPTY options + // array is legal and the card answers it with free text. + { id: "q2", header: "Anything else", question: "Notes?", options: [] }, + // Options were sent but none parse — a choice-less card would + // misrepresent this one, so it stays dropped. + { id: "q3", header: "Broken", question: "Pick one", options: [{ label: 7 }] }, + ], + }, + }), + ]); + + expect(pending).toHaveLength(1); + expect(pending[0]!.questions.map((question) => question.id)).toEqual(["q1", "q2"]); + expect(pending[0]!.questions[1]!.options).toEqual([]); + // Submit stays disabled until the custom-only question is answered too. + expect( + buildPendingUserInputAnswers(pending[0]!.questions, { q1: { selectedOptionLabel: "All" } }), + ).toBeNull(); + expect( + buildPendingUserInputAnswers(pending[0]!.questions, { + q1: { selectedOptionLabel: "All" }, + q2: { customAnswer: "ship it" }, + }), + ).toEqual({ q1: "All", q2: "ship it" }); + }); +}); + describe("buildThreadFeed", () => { it("keeps historic work entries attributed to their turns", () => { const thread = makeThread({ diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 886644bf83eb..fc2e8753f3f6 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -203,7 +203,13 @@ function parseUserInputQuestions( }; }) .filter((option): option is UserInputQuestion["options"][number] => option !== null); - if (options.length === 0) { + // A question the provider sent with NO options is answerable by free + // text alone (the card always renders the custom-answer field), so it + // must survive — dropping it hides the question and submits a partial + // answer set the provider rejects. Options that WERE sent but all + // failed to parse still drop the question: a choice-less card would + // misrepresent a multiple-choice question. + if (options.length === 0 && question.options.length > 0) { return null; } return { diff --git a/apps/server/src/provider/Layers/AetherAdapter.test.ts b/apps/server/src/provider/Layers/AetherAdapter.test.ts index e095eb10da27..9e203dbafca7 100644 --- a/apps/server/src/provider/Layers/AetherAdapter.test.ts +++ b/apps/server/src/provider/Layers/AetherAdapter.test.ts @@ -1,7 +1,13 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { describe, expect, it } from "@effect/vitest"; -import { ProviderInstanceId, ThreadId, type ProviderRuntimeEvent } from "@t3tools/contracts"; +import { + ApprovalRequestId, + ProviderInstanceId, + ThreadId, + type ProviderRuntimeEvent, +} from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import type * as Scope from "effect/Scope"; @@ -13,6 +19,7 @@ import type { ExecuteGitResult, GitStatusDetails } from "../../vcs/GitVcsDriver. import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; import type { ProviderAdapterError } from "../Errors.ts"; import { + deterministicClientMessageId, makeAetherAdapter, parseAetherResume, type AetherAdapterSocketOptions, @@ -21,6 +28,7 @@ import { type AetherTurnTiming, } from "./AetherAdapter.ts"; import { + AetherApiConflictError, AetherApiNotFoundError, AetherApiTransportError, type AetherRestClient, @@ -139,11 +147,30 @@ const processingTask: AetherTask = { agent_type: "codex", model: "gpt-5.6-sol", interaction_mode: "default", + auto_fix_ci: false, + auto_fix_pr_comments: false, + auto_rebase: false, latest_sequence: 12, status: "processing", run_context: { workspace_id: "ws-1", started_at: "2026-08-08T10:01:00Z" }, }; +/** + * Conversation page for the startSession ledger rebuild (spec item 10): a + * resumed session re-derives the turn ledger from these rows, so most tests + * hand it an empty history. + */ +const messagesPage = (task: AetherTask, messages: ReadonlyArray = []) => ({ + task, + messages, + activity: [], + activeProcessingTurn: null, + latestSequence: task.latest_sequence, + oldestSequenceLoaded: messages.length > 0 ? messages[0]!.sequence : null, + oldestSortTimestampLoaded: messages.length > 0 ? messages[0]!.timestamp : null, + hasMoreOlder: false, +}); + const startInput = (overrides?: { readonly resumeCursor?: unknown; readonly modelSelection?: { readonly instanceId: ProviderInstanceId; readonly model: string }; @@ -202,19 +229,36 @@ const expectStartFailure = (options: { ); describe("parseAetherResume", () => { - it("parses a current-version cursor and preserves the opaque turn ledger", () => { + it("parses a current-version cursor including the typed turn ledger", () => { + expect( + parseAetherResume({ + schemaVersion: 1, + taskId: "task-1", + latestSequence: 12, + turnLedger: [{ turnId: "aether-turn-u1", messageId: "u1" }], + }), + ).toEqual({ + schemaVersion: 1, + taskId: "task-1", + latestSequence: 12, + turnLedger: [{ turnId: "aether-turn-u1", messageId: "u1" }], + }); + }); + + it("drops a malformed turn ledger wholesale but keeps the resume", () => { + // A partial ledger would misclassify the dropped turns as + // remote-originated, so one bad entry voids the whole ledger. expect( parseAetherResume({ schemaVersion: 1, taskId: "task-1", latestSequence: 12, - turnLedger: [{ turn: 1 }], + turnLedger: [{ turnId: "aether-turn-u1", messageId: "u1" }, { turn: 1 }], }), ).toEqual({ schemaVersion: 1, taskId: "task-1", latestSequence: 12, - turnLedger: [{ turn: 1 }], }); }); @@ -409,6 +453,7 @@ describe("AetherAdapter startSession", () => { Effect.sync(() => { requestedTaskIds.push(taskId); }).pipe(Effect.as(processingTask)), + getConversationMessages: () => Effect.succeed(messagesPage(processingTask)), }, }, (adapter) => @@ -464,13 +509,42 @@ describe("AetherAdapter startSession", () => { }), ); - it.effect("round-trips an opaque turn ledger through the rebuilt resume cursor", () => + it.effect("rebuilds the turn ledger from the conversation page, never the cursor snapshot", () => withAdapter( { restClient: { ...unusedRestClient, listProjects: () => Effect.succeed([project()]), getTask: () => Effect.succeed(processingTask), + getConversationMessages: () => + Effect.succeed( + messagesPage(processingTask, [ + { + id: "u1", + role: "user", + content: "first turn", + deliveryStatus: "processed", + timestamp: "t1", + sequence: 1, + }, + { + id: "a1", + role: "assistant", + variant: "text", + content: "done", + timestamp: "t2", + sequence: 2, + }, + { + id: "m2", + role: "user", + content: "answered after the last cursor snapshot", + deliveryStatus: "processed", + timestamp: "t3", + sequence: 3, + }, + ]), + ), }, }, (adapter) => @@ -481,17 +555,22 @@ describe("AetherAdapter startSession", () => { schemaVersion: 1, taskId: "task-1", latestSequence: 7, - turnLedger: [{ turn: 1, messageId: "m-1" }], + // Stale by a turn (a crash before the next cursor snapshot): + // m2 is missing here but present on the page — trusting this + // ledger would misclassify the driver's own m2 as a + // remote-originated turn (spec resolved note 7). + turnLedger: [{ turnId: "aether-turn-u1", messageId: "u1" }], }, }), ); - // A ledger written by a newer build (item 10) must survive a - // startSession round-trip through this one. expect(session.resumeCursor).toEqual({ schemaVersion: 1, taskId: "task-1", latestSequence: 7, - turnLedger: [{ turn: 1, messageId: "m-1" }], + turnLedger: [ + { turnId: "aether-turn-u1", messageId: "u1" }, + { turnId: "aether-turn-m2", messageId: "m2" }, + ], }); }), ), @@ -522,6 +601,7 @@ describe("AetherAdapter session lifecycle", () => { ...unusedRestClient, listProjects: () => Effect.succeed([project()]), getTask: () => Effect.succeed(processingTask), + getConversationMessages: () => Effect.succeed(messagesPage(processingTask)), }, }, (adapter) => @@ -599,7 +679,7 @@ describe("AetherAdapter session lifecycle", () => { ), ); - it.effect("turn methods fail session-not-found for unknown threads; T7+ stubs stay loud", () => + it.effect("turn methods fail session-not-found for unknown threads; refusals stay loud", () => withAdapter({}, (adapter) => Effect.gen(function* () { const threadId = ThreadId.make("thread-1"); @@ -607,10 +687,19 @@ describe("AetherAdapter session lifecycle", () => { expect(sendTurn._tag).toBe("ProviderAdapterSessionNotFoundError"); const interrupt = yield* Effect.flip(adapter.interruptTurn(threadId)); expect(interrupt._tag).toBe("ProviderAdapterSessionNotFoundError"); - // Questions/revert land with build items 9/10 — still typed stubs. + // Revert is a deliberate v1 refusal: the mirror is one-way, so the + // message names the actionable alternative instead of a stub. const rollback = yield* Effect.flip(adapter.rollbackThread(threadId, 1)); expect(rollback._tag).toBe("ProviderAdapterRequestError"); - expect(rollback.message).toContain("not implemented"); + expect(rollback.message).toContain("one-way mirror"); + expect(rollback.message).toContain("Revert the task from the Aether app"); + // Approvals never exist for Aether — the refusal says what actually + // happens (auto-approved remotely), not "not implemented". + const approval = yield* Effect.flip( + adapter.respondToRequest(threadId, ApprovalRequestId.make("req-1"), "accept"), + ); + expect(approval._tag).toBe("ProviderAdapterRequestError"); + expect(approval.message).toContain("auto-approve"); }), ), ); @@ -815,40 +904,58 @@ describe("AetherAdapter readThread", () => { expect(snapshot.turns[1]?.id).toBe("aether-turn-u2"); }), ); - expect(cursors).toEqual([undefined, { sequence: 5, sortTimestamp: "t5" }]); + // TWO full walks: the startSession ledger rebuild and the readThread + // snapshot each page back to the first turn. + expect(cursors).toEqual([ + undefined, + { sequence: 5, sortTimestamp: "t5" }, + undefined, + { sequence: 5, sortTimestamp: "t5" }, + ]); }), ); it.effect("fails loudly when a page claims more older rows without a cursor", () => - withAdapter( - { - restClient: { - ...unusedRestClient, - listProjects: () => Effect.succeed([project()]), - getTask: () => Effect.succeed(processingTask), - getConversationMessages: () => - Effect.succeed({ - task: processingTask, - messages: timelineFixture, - activity: [], - activeProcessingTurn: null, - latestSequence: 6, - oldestSequenceLoaded: null, - oldestSortTimestampLoaded: null, - hasMoreOlder: true, - }), + Effect.gen(function* () { + // The FIRST fetch (the startSession ledger rebuild) is well-formed; + // the readThread walk then hits the contract break. + let calls = 0; + yield* withAdapter( + { + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + getTask: () => Effect.succeed(processingTask), + getConversationMessages: () => + Effect.sync(() => { + calls++; + }).pipe( + Effect.map(() => ({ + task: processingTask, + messages: timelineFixture, + activity: [], + activeProcessingTurn: null, + latestSequence: 6, + oldestSequenceLoaded: null, + oldestSortTimestampLoaded: null, + hasMoreOlder: calls > 1, + })), + ), + }, }, - }, - (adapter) => - Effect.gen(function* () { - const session = yield* adapter.startSession( - startInput({ resumeCursor: { schemaVersion: 1, taskId: "task-1", latestSequence: 6 } }), - ); - const error = yield* Effect.flip(adapter.readThread(session.threadId)); - expect(error._tag).toBe("ProviderAdapterRequestError"); - expect(error.message).toContain("no older-page cursor"); - }), - ), + (adapter) => + Effect.gen(function* () { + const session = yield* adapter.startSession( + startInput({ + resumeCursor: { schemaVersion: 1, taskId: "task-1", latestSequence: 6 }, + }), + ); + const error = yield* Effect.flip(adapter.readThread(session.threadId)); + expect(error._tag).toBe("ProviderAdapterRequestError"); + expect(error.message).toContain("no older-page cursor"); + }), + ); + }), ); }); @@ -958,6 +1065,7 @@ describe("AetherAdapter event pipeline", () => { ...unusedRestClient, listProjects: () => Effect.succeed([project()]), getTask: () => Effect.succeed(processingTask), + getConversationMessages: () => Effect.succeed(messagesPage(processingTask)), connectWorkspace: () => Effect.succeed({ state: "running", @@ -1069,6 +1177,7 @@ describe("AetherAdapter event pipeline", () => { ...unusedRestClient, listProjects: () => Effect.succeed([project()]), getTask: () => Effect.succeed(processingTask), + getConversationMessages: () => Effect.succeed(messagesPage(processingTask)), connectWorkspace: () => Effect.succeed({ state: "running", @@ -1156,6 +1265,209 @@ describe("AetherAdapter event pipeline", () => { }), ); + it.effect( + "an IDLE session eagerly reconciles a remote turn: warning precedes its live output", + () => + Effect.gen(function* () { + const sockets: Array = []; + let deltaCalls = 0; + const restClient: AetherRestClient = { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + getTask: () => Effect.succeed(idleMessageTask), + getConversationMessages: () => Effect.succeed(messagesPage(idleMessageTask)), + connectWorkspace: () => + Effect.succeed({ + state: "running", + transport: { websocket_path: "/workspaces/ws-1/ws", preview_token: "t".repeat(32) }, + } as const), + getConversationDelta: (_taskId, after) => + Effect.sync(() => { + deltaCalls++; + return deltaCalls === 1 + ? emptyDelta(idleMessageTask, after) + : ({ + task: processingTask, + messages: [ + { + id: "u9", + role: "user", + content: "driven from the app", + deliveryStatus: "processing", + timestamp: "t8", + sequence: 8, + }, + ], + activity: [], + activeProcessingTurn: { messageId: "u9", startedAt: "2026-08-08T10:03:00Z" }, + latestSequence: 8, + removedMessageIds: [], + truncated: false, + } satisfies AetherConversationDelta); + }), + }; + yield* withAdapter( + { + restClient, + socket: { + apiBaseUrl: "https://api.runaether.dev", + apiKey: "aether_test_key", + timing: { ...zeroSocketTiming, requestTimeoutMs: 60_000 }, + webSocketFactory: () => { + const socket = diffAnsweringSocket(); + sockets.push(socket); + return socket; + }, + }, + }, + (adapter) => + Effect.gen(function* () { + const collector = yield* adapter.streamEvents.pipe( + Stream.take(4), + Stream.runCollect, + Effect.forkScoped, + ); + yield* adapter.startSession( + startInput({ + resumeCursor: { schemaVersion: 1, taskId: "task-1", latestSequence: 7 }, + }), + ); + yield* settleAdapterPump; + // The session sits IDLE on a healthy WS: no driver turn is + // active so the settle poll is not running — the live frame + // itself must trigger the durable reconcile that carries the + // remote user row (spec resolved note 9). + sockets[0]!.message({ ...wsAssistantDelta, turnId: "u9", messageId: "m9" }); + yield* settleAdapterPump; + const events = yield* Fiber.join(collector); + expect(events.map((event) => event.type)).toEqual([ + "session.started", + // Build item 13: the injected prompt's warning card lands + // BEFORE the remote turn's live output. + "runtime.warning", + "session.state.changed", + "content.delta", + ]); + expect(events[1]).toMatchObject({ eventId: "aether:task-1:remote:u9" }); + expect(events[1]!.type === "runtime.warning" && events[1]!.payload.message).toContain( + "driven from the app", + ); + expect(events[3]).toMatchObject({ eventId: "aether:task-1:stream:m9:1" }); + }), + ); + }), + ); + + it.effect( + "a durable backlog the eager reconcile ingests is never re-emitted by the live frame it raced", + () => + Effect.gen(function* () { + const sockets: Array = []; + let deltaCalls = 0; + const restClient: AetherRestClient = { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + getTask: () => Effect.succeed(idleMessageTask), + getConversationMessages: () => Effect.succeed(messagesPage(idleMessageTask)), + connectWorkspace: () => + Effect.succeed({ + state: "running", + transport: { websocket_path: "/workspaces/ws-1/ws", preview_token: "t".repeat(32) }, + } as const), + getConversationDelta: (_taskId, after) => + Effect.sync(() => { + deltaCalls++; + // Reconnect/backlog: by the time the FIRST live frame of the + // remote turn is delivered, the durable feed already carries + // that turn whole — its user row, its assistant item AND its + // settle (the task is back at awaiting_input). + return deltaCalls === 1 + ? emptyDelta(idleMessageTask, after) + : ({ + task: idleMessageTask, + messages: [ + { + id: "u9", + role: "user", + content: "driven from the app", + deliveryStatus: "delivered", + timestamp: "t8", + sequence: 8, + }, + { + id: "m9", + role: "assistant", + variant: "text", + content: "the whole answer", + timestamp: "t9", + sequence: 9, + }, + ], + activity: [], + activeProcessingTurn: null, + latestSequence: 9, + removedMessageIds: [], + truncated: false, + } satisfies AetherConversationDelta); + }), + }; + yield* withAdapter( + { + restClient, + socket: { + apiBaseUrl: "https://api.runaether.dev", + apiKey: "aether_test_key", + timing: { ...zeroSocketTiming, requestTimeoutMs: 60_000 }, + webSocketFactory: () => { + const socket = diffAnsweringSocket(); + sockets.push(socket); + return socket; + }, + }, + }, + (adapter) => + Effect.gen(function* () { + // Six, with `session.exited` as the sentinel: a stale replay of + // the live frame would land BEFORE it and shift the tail. + const collector = yield* adapter.streamEvents.pipe( + Stream.take(6), + Stream.runCollect, + Effect.forkScoped, + ); + const session = yield* adapter.startSession( + startInput({ + resumeCursor: { schemaVersion: 1, taskId: "task-1", latestSequence: 7 }, + }), + ); + yield* settleAdapterPump; + // The live frame carries the SAME item the durable backlog + // already holds. + sockets[0]!.message({ ...wsAssistantDelta, turnId: "u9", messageId: "m9" }); + yield* settleAdapterPump; + yield* adapter.stopSession(session.threadId); + const events = yield* Fiber.join(collector); + const types = events.map((event) => event.type); + // The eager reconcile runs BEFORE the frame is mapped, so the + // frame is mapped against a mapper that already ingested the + // durable twin: the stale delta is swallowed instead of + // trailing the turn's own settle. + expect(types).toEqual([ + "session.started", + "runtime.warning", + "item.completed", + "turn.diff.updated", + "turn.completed", + "session.exited", + ]); + expect(types).not.toContain("content.delta"); + expect(events[1]).toMatchObject({ eventId: "aether:task-1:remote:u9" }); + expect(events[2]).toMatchObject({ eventId: "aether:task-1:item:m9" }); + expect(events[4]).toMatchObject({ turnId: "aether-turn-u9" }); + }), + ); + }), + ); + it.effect("does not attach when the thread has no task yet", () => Effect.gen(function* () { const sockets: Array = []; @@ -1220,6 +1532,7 @@ describe("AetherAdapter turn lifecycle", () => { readonly messages?: ReadonlyArray; readonly activeMessageId?: string; readonly latestSequence: number; + readonly removedMessageIds?: ReadonlyArray; }): AetherConversationDelta => ({ task: input.task, messages: input.messages ?? [], @@ -1229,7 +1542,7 @@ describe("AetherAdapter turn lifecycle", () => { ? { messageId: input.activeMessageId, startedAt: "2026-08-08T10:02:00Z" } : null, latestSequence: input.latestSequence, - removedMessageIds: [], + removedMessageIds: input.removedMessageIds ?? [], truncated: false, }); @@ -1536,6 +1849,7 @@ describe("AetherAdapter turn lifecycle", () => { ...unusedRestClient, listProjects: () => Effect.succeed([project()]), getTask: () => Effect.succeed(processingTask), + getConversationMessages: () => Effect.succeed(messagesPage(processingTask)), respondToTask: (taskId, request) => Effect.sync(() => { respondRequests.push({ taskId, request }); @@ -1610,6 +1924,7 @@ describe("AetherAdapter turn lifecycle", () => { listProjects: () => Effect.succeed([project()]), // Interrupt confirmation: the task has already left processing. getTask: () => Effect.succeed(messageIdleTask), + getConversationMessages: () => Effect.succeed(messagesPage(processingTask)), respondToTask: () => Effect.sync(() => { respondCount++; @@ -1768,6 +2083,7 @@ describe("AetherAdapter turn lifecycle", () => { ...unusedRestClient, listProjects: () => Effect.succeed([project()]), getTask: () => Effect.succeed(messageIdleTask), + getConversationMessages: () => Effect.succeed(messagesPage(processingTask)), respondToTask: (_taskId, request) => Effect.suspend(() => { seenIds.push((request as { client_message_id: string }).client_message_id); @@ -1822,6 +2138,7 @@ describe("AetherAdapter turn lifecycle", () => { ...unusedRestClient, listProjects: () => Effect.succeed([project()]), getTask: () => Effect.succeed(messageIdleTask), + getConversationMessages: () => Effect.succeed(messagesPage(processingTask)), respondToTask: () => Effect.sync(() => { respondCount++; @@ -1901,6 +2218,7 @@ describe("AetherAdapter turn lifecycle", () => { ...unusedRestClient, listProjects: () => Effect.succeed([project()]), getTask: () => Effect.succeed(messageIdleTask), + getConversationMessages: () => Effect.succeed(messagesPage(processingTask)), respondToTask: () => Effect.succeed({ message_id: "m2" }), stopTask: () => Effect.fail( @@ -2000,6 +2318,669 @@ describe("AetherAdapter turn lifecycle", () => { ), ); + // -- questions + plans (T7) ----------------------------------------------- + + const optionsQuestionTask: AetherTask = { + ...processingTask, + status: "awaiting_input", + awaiting_input: { + kind: "questions", + tool_id: "input-1", + input: { + questions: [ + { + id: "q1", + question: "Which approach?", + options: [{ label: "Patch" }, { label: "Rewrite" }], + }, + { id: "q2", question: "Anything else?", options: [] }, + ], + }, + }, + }; + + const planPendingTask: AetherTask = { + ...processingTask, + status: "awaiting_input", + awaiting_input: { + kind: "plan", + tool_id: "plan-1", + input: { summary: "Fix it", plan: "1. Reproduce\n2. Fix" }, + }, + }; + + it.effect( + "respondToUserInput maps labels to raw indices, uses the -1 custom sentinel, resumes the turn", + () => + Effect.gen(function* () { + const respondRequests: Array = []; + const deltas = scriptedDeltas([ + delta({ + task: processingTask, + messages: [userRow("u1", 1)], + activeMessageId: "u1", + latestSequence: 1, + }), + delta({ task: optionsQuestionTask, messages: [userRow("u1", 1)], latestSequence: 1 }), + ]); + yield* withAdapter( + { + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + createTask: () => Effect.succeed({ id: "task-9", name: "n" }), + respondToTask: (_taskId, request) => + Effect.sync(() => { + respondRequests.push(request); + }).pipe(Effect.as({ message_id: "m2" })), + getConversationDelta: deltas.getConversationDelta, + }, + }, + (adapter) => + Effect.gen(function* () { + const collector = yield* adapter.streamEvents.pipe( + Stream.take(6), + Stream.runCollect, + Effect.forkScoped, + ); + const session = yield* adapter.startSession(startInput()); + yield* adapter.sendTurn({ threadId: session.threadId, input: "go" }); + yield* drainPoll; + + yield* adapter.respondToUserInput( + session.threadId, + ApprovalRequestId.make("input-1"), + { q1: "Rewrite", q2: "use sqlite instead" }, + ); + + // The aether-exact wire shape: answers keyed by RAW question + // index, labels resolved to raw option indices, free-typed + // text as the -1 sentinel + customAnswers (tasks.go oneOf). + const expectedData = { + answers: { "0": [1], "1": [-1] }, + customAnswers: { "1": "use sqlite instead" }, + }; + expect(respondRequests).toHaveLength(1); + expect(respondRequests[0]).toMatchObject({ + // @effect-diagnostics-next-line preferSchemaOverJson:off - asserts the wire-exact transcript row aether-web writes. + message: JSON.stringify(expectedData), + tool_response: { tool_name: "ask_user", data: expectedData }, + }); + expect( + (respondRequests[0] as { client_message_id?: string }).client_message_id, + ).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/); + + const events = yield* Fiber.join(collector); + expect(events.map((event) => event.type)).toEqual([ + "turn.started", + "turn.completed", + "user-input.requested", + "session.state.changed", + // The panel resolves BEFORE the resumed turn is announced. + "user-input.resolved", + "turn.started", + ]); + expect(events[4]).toMatchObject({ + requestId: "input-1", + payload: { answers: { q1: "Rewrite", q2: "use sqlite instead" } }, + }); + expect(events[5]).toMatchObject({ turnId: "aether-turn-m2" }); + + // The ledger carries both driver-originated turns. + const settled = (yield* adapter.listSessions())[0]!; + expect(settled.resumeCursor).toMatchObject({ + turnLedger: [ + { turnId: "aether-turn-u1", messageId: "u1" }, + { turnId: "aether-turn-m2", messageId: "m2" }, + ], + }); + }), + ); + }), + ); + + it.effect("respondToUserInput with a stale requestId renders as t3's stale-request error", () => + withAdapter( + { + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + getTask: () => Effect.succeed(messageIdleTask), + getConversationMessages: () => Effect.succeed(messagesPage(processingTask)), + }, + }, + (adapter) => + Effect.gen(function* () { + const session = yield* adapter.startSession( + startInput({ + resumeCursor: { schemaVersion: 1, taskId: "task-1", latestSequence: 2 }, + }), + ); + const failure = yield* Effect.flip( + adapter.respondToUserInput(session.threadId, ApprovalRequestId.make("input-gone"), { + q1: "yes", + }), + ); + expect(failure._tag).toBe("ProviderAdapterRequestError"); + // The EXACT substring t3's reactor/decider key their stale + // rendering on ("Stale pending user-input request … restart"). + expect(failure.message).toContain("unknown pending user-input request"); + }), + ), + ); + + it.effect( + "a 409 on the answer classifies as a stale request AND carries the body's message", + () => + Effect.gen(function* () { + const deltas = scriptedDeltas([ + delta({ + task: processingTask, + messages: [userRow("u1", 1)], + activeMessageId: "u1", + latestSequence: 1, + }), + delta({ task: optionsQuestionTask, messages: [userRow("u1", 1)], latestSequence: 1 }), + ]); + yield* withAdapter( + { + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + createTask: () => Effect.succeed({ id: "task-9", name: "n" }), + respondToTask: () => + Effect.fail( + new AetherApiConflictError({ + endpoint: "POST /tasks/{id}/respond", + detail: "Task is no longer awaiting input", + code: "task_not_accepting_messages", + }), + ), + getConversationDelta: deltas.getConversationDelta, + }, + }, + (adapter) => + Effect.gen(function* () { + const session = yield* adapter.startSession(startInput()); + yield* adapter.sendTurn({ threadId: session.threadId, input: "go" }); + yield* drainPoll; + const failure = yield* Effect.flip( + adapter.respondToUserInput(session.threadId, ApprovalRequestId.make("input-1"), { + q1: "Patch", + }), + ); + expect(failure._tag).toBe("ProviderAdapterRequestError"); + // Spec §2.4: the 409 path MUST carry the exact substring t3's + // stale-request machinery (reactor/decider/projection) keys on — + // the decoded body's message rides along for context. + expect(failure.message).toContain("unknown pending user-input request"); + expect(failure.message).toContain("Task is no longer awaiting input"); + }), + ); + }), + ); + + it.effect("a sendTurn while a plan is pending ACCEPTS it (interactionMode default)", () => + Effect.gen(function* () { + const respondRequests: Array = []; + const deltas = scriptedDeltas([ + delta({ + task: processingTask, + messages: [userRow("u1", 1)], + activeMessageId: "u1", + latestSequence: 1, + }), + delta({ task: planPendingTask, messages: [userRow("u1", 1)], latestSequence: 1 }), + ]); + yield* withAdapter( + { + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + createTask: () => Effect.succeed({ id: "task-9", name: "n" }), + respondToTask: (_taskId, request) => + Effect.sync(() => { + respondRequests.push(request); + }).pipe(Effect.as({ message_id: "m2" })), + getConversationDelta: deltas.getConversationDelta, + }, + }, + (adapter) => + Effect.gen(function* () { + const collector = yield* adapter.streamEvents.pipe( + Stream.take(4), + Stream.runCollect, + Effect.forkScoped, + ); + const session = yield* adapter.startSession(startInput()); + yield* adapter.sendTurn({ threadId: session.threadId, input: "plan it" }); + yield* drainPoll; + const events = yield* Fiber.join(collector); + expect(events.map((event) => event.type)).toEqual([ + "turn.started", + "turn.completed", + "turn.proposed.completed", + "session.state.changed", + ]); + + const accept = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "build it", + interactionMode: "default", + }); + expect(accept.turnId).toBe("aether-turn-m2"); + expect(respondRequests).toHaveLength(1); + expect(respondRequests[0]).toMatchObject({ + message: "build it", + interaction_mode: "default", + tool_response: { tool_name: "propose_plan", data: { approved: true } }, + }); + expect( + (respondRequests[0] as { tool_response: { data: Record } }) + .tool_response.data.feedback, + ).toBeUndefined(); + }), + ); + }), + ); + + it.effect("a plan-mode follow-up REJECTS the pending plan with feedback", () => + Effect.gen(function* () { + const respondRequests: Array = []; + const deltas = scriptedDeltas([ + delta({ + task: processingTask, + messages: [userRow("u1", 1)], + activeMessageId: "u1", + latestSequence: 1, + }), + delta({ task: planPendingTask, messages: [userRow("u1", 1)], latestSequence: 1 }), + ]); + yield* withAdapter( + { + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + createTask: () => Effect.succeed({ id: "task-9", name: "n" }), + respondToTask: (_taskId, request) => + Effect.sync(() => { + respondRequests.push(request); + }).pipe(Effect.as({ message_id: "m2" })), + getConversationDelta: deltas.getConversationDelta, + }, + }, + (adapter) => + Effect.gen(function* () { + const session = yield* adapter.startSession(startInput()); + yield* adapter.sendTurn({ threadId: session.threadId, input: "plan it" }); + yield* drainPoll; + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "tighten the rollout steps", + interactionMode: "plan", + }); + expect(respondRequests).toHaveLength(1); + expect(respondRequests[0]).toMatchObject({ + message: "tighten the rollout steps", + interaction_mode: "plan", + tool_response: { + tool_name: "propose_plan", + data: { approved: false, feedback: "tighten the rollout steps" }, + }, + }); + }), + ); + }), + ); + + // -- hardening + steer-queue polish (T8) ------------------------------------ + + it.effect( + "an unledgered user row surfaces as a remote-originated warning before its output", + () => + Effect.gen(function* () { + const deltas = scriptedDeltas([ + delta({ task: processingTask, activeMessageId: "m2", latestSequence: 3 }), + delta({ + task: messageIdleTask, + messages: [userRow("remote-1", 5), assistantRow("a5", 6)], + latestSequence: 6, + }), + ]); + let respondCount = 0; + yield* withAdapter( + { + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + getTask: () => Effect.succeed(processingTask), + getConversationMessages: () => Effect.succeed(messagesPage(processingTask)), + respondToTask: () => + Effect.sync(() => { + respondCount++; + }).pipe(Effect.map(() => ({ message_id: `m${respondCount + 1}` }))), + getConversationDelta: deltas.getConversationDelta, + }, + }, + (adapter) => + Effect.gen(function* () { + const collector = yield* adapter.streamEvents.pipe( + Stream.take(6), + Stream.runCollect, + Effect.forkScoped, + ); + const session = yield* adapter.startSession( + startInput({ + resumeCursor: { schemaVersion: 1, taskId: "task-1", latestSequence: 2 }, + }), + ); + yield* adapter.sendTurn({ threadId: session.threadId, input: "turn two" }); + yield* drainPoll; + const events = yield* Fiber.join(collector); + const types = events.map((event) => event.type); + const warningIndex = types.indexOf("runtime.warning"); + const outputIndex = types.indexOf("item.completed"); + expect(warningIndex).toBeGreaterThanOrEqual(0); + // The injected prompt lands BEFORE the turn's output. + expect(warningIndex).toBeLessThan(outputIndex); + const warning = events[warningIndex]!; + expect(warning.eventId).toBe("aether:task-1:remote:remote-1"); + expect(warning.type === "runtime.warning" && warning.payload.message).toContain( + "This task was driven from the Aether app: message remote-1", + ); + }), + ); + }), + ); + + it.effect("a reconcile racing the steer's 202 does NOT misclassify the driver's own row", () => + Effect.gen(function* () { + const gate = yield* Deferred.make(); + let respondCount = 0; + let sessionEpoch = ""; + let steerRowVisible = false; + let taskIdlePhase = false; + const getDelta = (_taskId: string, _after: number) => + Effect.sync(() => + taskIdlePhase + ? delta({ task: messageIdleTask, latestSequence: 6 }) + : steerRowVisible + ? delta({ + task: processingTask, + activeMessageId: "m2", + messages: [ + { + id: "m3", + role: "user", + content: "steer it", + deliveryStatus: "queued", + timestamp: "t5", + sequence: 5, + // The row the server committed for the in-flight steer + // carries the driver's own deterministic id. + clientMessageId: deterministicClientMessageId({ + taskId: "task-1", + sessionEpoch, + sendOrdinal: 1, + }), + }, + ], + latestSequence: 5, + }) + : delta({ task: processingTask, activeMessageId: "m2", latestSequence: 3 }), + ); + yield* withAdapter( + { + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + getTask: () => Effect.succeed(processingTask), + getConversationMessages: () => Effect.succeed(messagesPage(processingTask)), + respondToTask: () => + Effect.suspend(() => { + respondCount++; + if (respondCount === 1) { + return Effect.succeed({ message_id: "m2" }); + } + // The server commits the user row BEFORE returning the 202 — + // from this moment the settle poll can observe it while the + // steer's sendTurn still awaits the response. + steerRowVisible = true; + return Deferred.await(gate).pipe(Effect.as({ message_id: "m3" })); + }), + getConversationDelta: getDelta, + }, + }, + (adapter) => + Effect.gen(function* () { + const collector = yield* adapter.streamEvents.pipe( + Stream.take(3), + Stream.runCollect, + Effect.forkScoped, + ); + const session = yield* adapter.startSession( + startInput({ + resumeCursor: { schemaVersion: 1, taskId: "task-1", latestSequence: 2 }, + }), + ); + sessionEpoch = session.createdAt; + yield* adapter.sendTurn({ threadId: session.threadId, input: "turn two" }); + const steer = yield* Effect.forkScoped( + adapter.sendTurn({ threadId: session.threadId, input: "steer it" }), + ); + // Poll beats run while the 202 is still parked on the gate: they + // observe the committed steer row (not yet in the turn ledger). + yield* drainPoll; + yield* Deferred.succeed(gate, undefined); + yield* Fiber.join(steer); + taskIdlePhase = true; + yield* drainPoll; + const events = yield* Fiber.join(collector); + // The pre-registered client_message_id classifies the row as the + // driver's own send — with the ledger entry landing only after + // the 202, a post-202 registration would have surfaced a false + // "driven from the Aether app" warning here instead of the + // settle. + expect(events.map((event) => event.type)).toEqual([ + "turn.started", + "session.state.changed", + "turn.completed", + ]); + expect(events[2]).toMatchObject({ + turnId: "aether-turn-m2", + payload: { state: "completed" }, + }); + }), + ); + }), + ); + + it.effect("a queued steer unqueued remotely settles its deferred turn (removedMessageIds)", () => + Effect.gen(function* () { + const deltas = scriptedDeltas([ + delta({ task: processingTask, activeMessageId: "m2", latestSequence: 3 }), + // The remote unqueue never surfaces as a row — cancelled user + // messages are filtered out of the conversation wire entirely; the + // ONLY signal is the id landing in the delta's removedMessageIds + // (the cancel bumps the revision sequence). + delta({ + task: processingTask, + activeMessageId: "m2", + removedMessageIds: ["m3"], + latestSequence: 5, + }), + ]); + let respondCount = 0; + yield* withAdapter( + { + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + getTask: () => Effect.succeed(processingTask), + getConversationMessages: () => Effect.succeed(messagesPage(processingTask)), + respondToTask: () => + Effect.sync(() => { + respondCount++; + }).pipe(Effect.map(() => ({ message_id: respondCount === 1 ? "m2" : "m3" }))), + getConversationDelta: deltas.getConversationDelta, + }, + }, + (adapter) => + Effect.gen(function* () { + const collector = yield* adapter.streamEvents.pipe( + Stream.take(4), + Stream.runCollect, + Effect.forkScoped, + ); + const session = yield* adapter.startSession( + startInput({ + resumeCursor: { schemaVersion: 1, taskId: "task-1", latestSequence: 2 }, + }), + ); + yield* adapter.sendTurn({ threadId: session.threadId, input: "turn two" }); + yield* adapter.sendTurn({ threadId: session.threadId, input: "steer it" }); + yield* drainPoll; + const events = yield* Fiber.join(collector); + expect(events.map((event) => event.type)).toEqual([ + "turn.started", + "session.state.changed", + "runtime.warning", + "turn.completed", + ]); + expect(events[2]!.type === "runtime.warning" && events[2]!.payload.message).toContain( + "steer it", + ); + expect(events[3]).toMatchObject({ + turnId: "aether-turn-m3", + payload: { state: "interrupted" }, + }); + // The session falls back to the still-running predecessor + // instead of staying wedged on the cancelled steer. + const after = (yield* adapter.listSessions())[0]!; + expect(after.status).toBe("running"); + expect(after.activeTurnId).toBe("aether-turn-m2"); + }), + ); + }), + ); + + it.effect("a model change between turns PUTs the full settings replace, then responds", () => + Effect.gen(function* () { + const updates: Array = []; + const respondRequests: Array = []; + const deltas = scriptedDeltas([delta({ task: messageIdleTask, latestSequence: 3 })]); + yield* withAdapter( + { + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + getTask: () => Effect.succeed(messageIdleTask), + getConversationMessages: () => Effect.succeed(messagesPage(processingTask)), + updateTask: (_taskId, request) => + Effect.sync(() => { + updates.push(request); + }).pipe(Effect.as(messageIdleTask)), + respondToTask: (_taskId, request) => + Effect.sync(() => { + respondRequests.push(request); + }).pipe(Effect.as({ message_id: "m2" })), + getConversationDelta: deltas.getConversationDelta, + }, + }, + (adapter) => + Effect.gen(function* () { + expect(adapter.capabilities.sessionModelSwitch).toBe("in-session"); + const session = yield* adapter.startSession( + startInput({ + resumeCursor: { schemaVersion: 1, taskId: "task-1", latestSequence: 2 }, + }), + ); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "switch to claude", + modelSelection: { instanceId, model: "claude-code/claude-opus-5" }, + }); + // FULL replace (every field required; reasoning_effort is + // required-but-nullable), with the live-mutable auto_fix_* flags + // read back from the task row, never assumed false. + expect(updates).toEqual([ + { + agent_type: "claude-code", + model: "claude-opus-5", + interaction_mode: "default", + reasoning_effort: null, + auto_fix_ci: false, + auto_fix_pr_comments: false, + auto_rebase: false, + }, + ]); + expect(respondRequests).toHaveLength(1); + const after = (yield* adapter.listSessions())[0]!; + expect(after.model).toBe("claude-code/claude-opus-5"); + }), + ); + }), + ); + + it.effect("a reasoning-effort change on the SAME model slug rides every respond", () => + Effect.gen(function* () { + const respondRequests: Array = []; + const deltas = scriptedDeltas([delta({ task: messageIdleTask, latestSequence: 3 })]); + yield* withAdapter( + { + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + // updateTask stays the defecting stub on purpose: an option-only + // change must never take the full-replace PUT path (which is + // refused outright while a turn is running). + getTask: () => Effect.succeed(messageIdleTask), + getConversationMessages: () => Effect.succeed(messagesPage(processingTask)), + respondToTask: (_taskId, request) => + Effect.sync(() => { + respondRequests.push(request); + return { message_id: `m${respondRequests.length + 1}` }; + }), + getConversationDelta: deltas.getConversationDelta, + }, + }, + (adapter) => + Effect.gen(function* () { + const session = yield* adapter.startSession( + startInput({ + resumeCursor: { schemaVersion: 1, taskId: "task-1", latestSequence: 2 }, + }), + ); + // The project defaults name the session's slug; only the effort + // OPTION moves between the two sends. + const selection = (effort: string) => ({ + instanceId, + model: "codex/gpt-5.6-sol", + options: [{ id: "reasoningEffort", value: effort }], + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "think harder", + modelSelection: selection("high"), + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "actually, be quick", + modelSelection: selection("low"), + }); + // `POST /respond` carries the per-message reasoning_effort the + // runner reads; without it the second turn would inherit the + // task row's stored effort. + expect(respondRequests).toMatchObject([ + { message: "think harder", reasoning_effort: "high" }, + { message: "actually, be quick", reasoning_effort: "low" }, + ]); + expect((yield* adapter.listSessions())[0]!.model).toBe("codex/gpt-5.6-sol"); + }), + ); + }), + ); + it.effect("stopSession deregisters the mirror-guard claim", () => Effect.gen(function* () { const registrations: Array = []; diff --git a/apps/server/src/provider/Layers/AetherAdapter.ts b/apps/server/src/provider/Layers/AetherAdapter.ts index e1d97dba1916..184035cf1b0d 100644 --- a/apps/server/src/provider/Layers/AetherAdapter.ts +++ b/apps/server/src/provider/Layers/AetherAdapter.ts @@ -16,7 +16,18 @@ * - interruptTurn stops with `discard_queued_messages: true`, surfaces any * discarded driver-queued message text, and settles `interrupted` only * after read-side confirmation. - * respondToUserInput/rollbackThread still fail loudly until items 9/10. + * The questions/plans slice (build item 9): + * - respondToUserInput answers a pending ask_user via `POST /respond` + * `tool_response {tool_name:"ask_user", data:{answers, customAnswers}}`, + * mapping option labels → raw option indices and free-typed text → the + * `-1` custom sentinel (apitypes/tasks.go askUserToolResponseSchema); + * - a sendTurn that lands while a plan is pending IS the accept/reject + * verb: interactionMode default → `propose_plan {approved:true}` + + * interaction_mode 'default', plan → `{approved:false, feedback}` + + * interaction_mode 'plan' (t3 routes plan acceptance as a fresh turn — + * ChatView sends thread.turn.start with interactionMode 'default'). + * rollbackThread is a deliberate typed refusal in v1: the local checkout is + * a one-way mirror, and reverting a cloud session locally would desync it. * * Design invariants (docs/aether-driver-plumbing-spec.md §2.3): * - startSession NEVER creates a task — the task is created on the first @@ -30,6 +41,12 @@ * - resumeCursor = `{schemaVersion: 1, taskId, latestSequence, * mirrorFingerprint?, turnLedger?}`; replay safety comes from the * mapper's deterministic event IDs, not cursor freshness. + * - turnLedger records the turn→messageId pairs (turn 1's id harvested + * from the timeline, every later own send from the respond 202, and the + * WHOLE ledger rebuilt from the conversation page on resume — resolved + * note 7) — the future revert slice consumes the pairs, and build item + * 13 uses the ledger to classify unledgered user rows as + * remote-originated turns. * * @module provider/Layers/AetherAdapter */ @@ -44,6 +61,7 @@ import { type ProviderInstanceId, type ProviderRuntimeEvent, type ProviderSession, + type ProviderUserInputAnswers, type ThreadId, } from "@t3tools/contracts"; import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; @@ -76,7 +94,11 @@ import type { ProviderThreadTurnSnapshot, } from "../Services/ProviderAdapter.ts"; import { AETHER_API_KEY_ENV_VAR } from "./AetherProvider.ts"; -import { makeAetherEventMapper, type AetherEventMapper } from "./aether/eventMapper.ts"; +import { + makeAetherEventMapper, + type AetherAnswerableQuestion, + type AetherEventMapper, +} from "./aether/eventMapper.ts"; import { makeAetherMirrorSync, type AetherMirrorSyncEngine } from "./aether/mirrorSync.ts"; import type { AetherRestClient } from "./aether/restClient.ts"; import type { @@ -101,18 +123,6 @@ import { const PROVIDER = ProviderDriverKind.make("aether"); -const NOT_IMPLEMENTED_DETAIL = - "Aether driver: not implemented until the questions/revert slices (build items 9-10)"; - -const notImplemented = (method: string): Effect.Effect => - Effect.fail( - new ProviderAdapterRequestError({ - provider: PROVIDER, - method, - detail: NOT_IMPLEMENTED_DETAIL, - }), - ); - /** * Version tag stamped into the Aether resume cursor. Bump if the cursor * shape changes so stale-shaped cursors written by older builds are ignored @@ -120,6 +130,18 @@ const notImplemented = (method: string): Effect.Effect; +} + +/** + * Parse a persisted ledger. Any malformed entry drops the WHOLE ledger (a + * partial ledger would misclassify the dropped turns as remote-originated) — + * the session still resumes, matching the cursor parser's lenient contract. + */ +function parseTurnLedger(raw: unknown): ReadonlyArray | undefined { + if (!Array.isArray(raw)) { + return undefined; + } + const entries: Array = []; + for (const entry of raw) { + if (typeof entry !== "object" || entry === null || Array.isArray(entry)) { + return undefined; + } + const record = entry as Record; + if ( + typeof record.turnId !== "string" || + record.turnId.length === 0 || + typeof record.messageId !== "string" || + record.messageId.length === 0 + ) { + return undefined; + } + entries.push({ turnId: record.turnId, messageId: record.messageId }); + } + return entries; } /** @@ -158,6 +204,7 @@ export function parseAetherResume(raw: unknown): AetherResumeCursor | undefined if (typeof record.latestSequence !== "number" || !Number.isFinite(record.latestSequence)) { return undefined; } + const turnLedger = parseTurnLedger(record.turnLedger); return { schemaVersion: AETHER_RESUME_VERSION, taskId: record.taskId.trim(), @@ -165,7 +212,7 @@ export function parseAetherResume(raw: unknown): AetherResumeCursor | undefined ...(typeof record.mirrorFingerprint === "string" && record.mirrorFingerprint.length > 0 ? { mirrorFingerprint: record.mirrorFingerprint } : {}), - ...(record.turnLedger !== undefined ? { turnLedger: record.turnLedger } : {}), + ...(turnLedger !== undefined ? { turnLedger } : {}), }; } @@ -281,8 +328,29 @@ interface AetherSessionContext { */ firstTurnFingerprint: string | undefined; latestSequence: number; - /** Opaque turn ledger carried from the resume cursor (see AetherResumeCursor). */ - turnLedger: unknown; + /** The driver's own turn→messageId pairs, oldest first (see AetherResumeCursor). */ + turnLedger: Array; + /** + * Every `client_message_id` this session issued, registered BEFORE the + * respond call goes out — the second half of the own-send classification. + * The server commits the user row before returning the 202, so a + * settle-poll reconcile can observe the fresh row while sendTurn still + * awaits the response (the row is not yet in `turnLedger`); the + * pre-registered id keeps that window from misclassifying the driver's + * own send as remote-originated. In-memory only: across a restart the + * classification is covered by the ledger rebuild from the conversation + * page at startSession instead. + */ + readonly issuedClientMessageIds: Set; + /** Remote-originated user rows already surfaced as warnings (build item 13). */ + readonly warnedRemoteRows: Set; + /** + * Wire turns a live frame revealed that this adapter never issued — each + * triggers exactly ONE eager durable reconcile so the build-item-13 + * warning precedes the remote turn's live output (see + * eagerRemoteTurnReconcile). + */ + readonly remoteTurnSyncs: Set; /** Owns the attach pump, socket and turn poll; closed on stopSession/stopAll. */ sessionScope: Scope.Closeable | undefined; /** The session's event mapper; its latestSequence() is the live cursor. */ @@ -327,7 +395,7 @@ function buildAetherResumeCursor(context: AetherSessionContext): AetherResumeCur taskId: context.taskId, latestSequence: context.latestSequence, ...(mirrorFingerprint !== undefined ? { mirrorFingerprint } : {}), - ...(context.turnLedger !== undefined ? { turnLedger: context.turnLedger } : {}), + ...(context.turnLedger.length > 0 ? { turnLedger: [...context.turnLedger] } : {}), }; } @@ -391,6 +459,104 @@ export function resolveAetherModelSlug(slug: string): { return { agentType: "opencode", model: slug, catalogAgentType: undefined }; } +/** + * The custom-answer sentinel: Aether's ask_user wire marks a free-typed + * answer as `answers[q] = [-1]` paired with `customAnswers[q]` (apitypes/ + * tasks.go askUserToolResponseSchema documents `-1`; the web composer's + * serializeResponse in packages/conversation question-drafts.ts emits it). + */ +const AETHER_CUSTOM_ANSWER_SENTINEL = -1; + +/** + * Map t3's ProviderUserInputAnswers (question id → answer label(s) / typed + * text) onto the aether-exact ask_user data payload: answers keyed by the + * question's RAW wire index, option labels resolved to raw option indices, + * one free-typed answer per question via the `-1` sentinel + customAnswers. + * Pure and total: every unrepresentable submission returns a named issue. + */ +export function buildAskUserToolResponse( + questions: ReadonlyArray, + answers: ProviderUserInputAnswers, +): + | { + readonly data: { + readonly answers: Readonly>>; + readonly customAnswers?: Readonly>; + }; + } + | { readonly issue: string } { + const answerRecord: Record> = {}; + const customAnswers: Record = {}; + for (const [questionId, value] of Object.entries(answers)) { + const question = questions.find((candidate) => candidate.id === questionId); + if (question === undefined) { + return { issue: `The answer targets an unknown question '${questionId}'.` }; + } + const texts = normalizeUserInputAnswer(value); + if (texts === undefined) { + return { + issue: `The answer for question '${questionId}' has an unsupported shape (expected a string, an array of strings, or {answers: string[]}).`, + }; + } + const trimmed = texts.map((text) => text.trim()).filter((text) => text.length > 0); + if (trimmed.length === 0) { + continue; + } + const indices: Array = []; + const unmatched: Array = []; + for (const text of trimmed) { + const option = question.options.find((candidate) => candidate.label === text); + if (option !== undefined) { + indices.push(option.rawIndex); + } else { + unmatched.push(text); + } + } + const key = String(question.rawIndex); + if (unmatched.length === 0) { + answerRecord[key] = indices; + continue; + } + if (unmatched.length === 1 && indices.length === 0) { + answerRecord[key] = [AETHER_CUSTOM_ANSWER_SENTINEL]; + customAnswers[key] = unmatched[0]!; + continue; + } + return { + issue: `The answer for question '${questionId}' mixes free-typed text with option selections; Aether accepts either option labels or exactly one custom answer.`, + }; + } + if (Object.keys(answerRecord).length === 0) { + return { issue: "The submission carries no answers." }; + } + return { + data: { + answers: answerRecord, + ...(Object.keys(customAnswers).length > 0 ? { customAnswers } : {}), + }, + }; +} + +/** The three answer-value shapes t3 submits (mirrors the Codex adapter). */ +function normalizeUserInputAnswer(value: unknown): ReadonlyArray | undefined { + if (typeof value === "string") { + return [value]; + } + if (Array.isArray(value)) { + return value.every((entry): entry is string => typeof entry === "string") ? value : undefined; + } + if ( + typeof value === "object" && + value !== null && + "answers" in value && + Array.isArray((value as { answers: unknown }).answers) && + (value as { answers: Array }).answers.every((entry) => typeof entry === "string") + ) { + return (value as { answers: Array }).answers; + } + return undefined; +} + /** The platform attachment allowlist (libs/go/promptattachment, kept in sync). */ const AETHER_ATTACHMENT_MEDIA_TYPES: ReadonlySet = new Set([ "image/png", @@ -752,6 +918,125 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( } }); + /** + * Adapter-side delta inspection, run BEFORE the mapper consumes the batch + * so its emissions precede the turn's own output: + * - build item 14: a driver-queued steer the remote unqueued (web + * unqueue, remote stop with discard) will never be picked up — settle + * its deferred turn and re-offer the text, or the session stays wedged + * on a turn the remote no longer knows. The ONLY wire signal for this + * is `removedMessageIds`: cancelled user rows never cross the + * conversation wire (the timeline queries select only delivery_status + * queued|processing|processed), but the cancel bumps the revision + * sequence, which lands the id in the delta's removed set; + * - build item 13: user rows the driver never sent (id not in the turn + * ledger, clientMessageId not issued here) are remote-originated turns + * — surface the injected prompt as a warning card, since t3 persists + * user bubbles only from its own thread.turn.start. + */ + const inspectDeltaRows = ( + context: AetherSessionContext, + delta: { + readonly messages: ReadonlyArray; + readonly removedMessageIds: ReadonlyArray; + }, + afterSequence: number, + ) => + Effect.gen(function* () { + const taskId = context.taskId; + if (taskId === undefined) { + return; + } + for (const removedId of delta.removedMessageIds) { + const deferredIndex = context.deferredTurns.findIndex( + (candidate) => candidate.wireTurnId === removedId, + ); + if (deferredIndex === -1) { + continue; + } + const discarded = context.deferredTurns[deferredIndex]!; + context.deferredTurns.splice(deferredIndex, 1); + yield* emit({ + ...(yield* baseEvent(context)), + eventId: EventId.make(`aether:${taskId}:turn:${discarded.wireTurnId}:cancelled`), + type: "runtime.warning", + payload: { + message: `Your queued message was removed on the Aether side before the agent picked it up. You can send it again:\n\n${discarded.text}`, + }, + }); + yield* emit({ + ...(yield* baseEvent(context)), + eventId: EventId.make(`aether:${taskId}:turn:${discarded.wireTurnId}:settled`), + turnId: discarded.turnId, + type: "turn.completed", + payload: { state: "interrupted" }, + }); + yield* onTurnSettled(context, discarded.turnId); + } + for (const row of delta.messages) { + if (row.role !== "user" || row.sequence <= afterSequence) { + continue; + } + const ledgered = + context.turnLedger.some((entry) => entry.messageId === row.id) || + (row.clientMessageId !== undefined && + context.issuedClientMessageIds.has(row.clientMessageId)); + if (ledgered || context.warnedRemoteRows.has(row.id)) { + continue; + } + context.warnedRemoteRows.add(row.id); + yield* emit({ + ...(yield* baseEvent(context)), + eventId: EventId.make(`aether:${taskId}:remote:${row.id}`), + type: "runtime.warning", + payload: { + message: `This task was driven from the Aether app: ${row.content}`, + }, + }); + } + }); + + /** + * Remote-turn eager reconcile (build item 13, idle path): while the session + * sits idle on a healthy WS, NOTHING else triggers the durable reconcile — + * a turn injected from the Aether app would stream its whole output live + * with the warning card arriving only at the next sendTurn/reconnect, + * violating the warning-before-output contract (spec resolved note 9). + * + * Runs on the LIVE FRAME'S OWN wire turn, BEFORE the frame is mapped: the + * durable feed is authoritative (`inspectDeltaRows` emits the warning, then + * the delta's rows flow through the mapper), so the frame is mapped against + * a mapper that has already absorbed everything durable. A frame whose + * durable twin the reconcile just ingested is then swallowed by the + * mapper's own gates instead of trailing the reconcile's events as a stale + * replay — which is exactly what a socket backlog delivers on reconnect. + * The guard set is populated BEFORE the reconcile so its own + * processMapperEvents cannot re-enter; a transiently failed reconcile warns + * loudly through the reconcile's own catch, and the warning then lands on a + * later reconcile beat. + */ + const eagerRemoteTurnReconcile = ( + context: AetherSessionContext, + wireTurnId: string | undefined, + ) => + Effect.gen(function* () { + if ( + wireTurnId === undefined || + // A resumed in-flight turn belongs to the adoption path (spec §2.3), + // not to a remote injection — its opening row predates the resume + // snapshot, so no warning is owed for it. + context.adoptActiveTurn || + context.activeTurn?.wireTurnId === wireTurnId || + context.deferredTurns.some((candidate) => candidate.wireTurnId === wireTurnId) || + context.turnLedger.some((entry) => entry.messageId === wireTurnId) || + context.remoteTurnSyncs.has(wireTurnId) + ) { + return; + } + context.remoteTurnSyncs.add(wireTurnId); + yield* context.reconcile ?? Effect.void; + }); + /** * THE event funnel: every mapper output batch flows through here. The * mapper stays pure — its `turn.completed` IS the pre-settle signal, and @@ -849,7 +1134,11 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( // drives. A transient REST failure warns loudly and leaves the cursor // untouched, so the next beat retries the exact same range. context.reconcile = Effect.gen(function* () { - const delta = yield* restClient.getConversationDelta(taskId, mapper.latestSequence()); + const afterSequence = mapper.latestSequence(); + const delta = yield* restClient.getConversationDelta(taskId, afterSequence); + // Inspect BEFORE the mapper: remote-originated warnings and + // superseded-steer settles must precede the batch's own output. + yield* inspectDeltaRows(context, delta, afterSequence); const events = mapper.reconcileDelta(delta, yield* nowIso); context.latestSequence = mapper.latestSequence(); yield* processMapperEvents(context, events); @@ -945,6 +1234,8 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( slashCommandsLogged = true; yield* Effect.logInfo("aether.slash-commands.ignored", { taskId }); } + // STRICTLY before the frame is mapped — see eagerRemoteTurnReconcile. + yield* eagerRemoteTurnReconcile(context, "turnId" in event ? event.turnId : undefined); const events = mapper.mapWsEvent(event, yield* nowIso); context.latestSequence = mapper.latestSequence(); yield* processMapperEvents(context, events); @@ -1052,6 +1343,51 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( ); }); + /** + * Fetch the FULL conversation timeline, oldest row first, walking + * `hasMoreOlder` back to the first turn — the endpoint serves the NEWEST + * page first. Shared by readThread (snapshot) and the startSession ledger + * rebuild. The cursor must advance every page and must exist whenever more + * rows are claimed — either violation is a contract break, surfaced loudly + * instead of looping forever or silently truncating. + */ + const fetchFullTimeline = Effect.fn("fetchAetherFullTimeline")(function* ( + restClient: AetherRestClient, + taskId: string, + method: string, + ): Effect.fn.Return, ProviderAdapterError> { + let page = yield* restClient + .getConversationMessages(taskId) + .pipe(Effect.mapError(toRestRequestError(method))); + const rows: Array = [...page.messages]; + while (page.hasMoreOlder) { + const beforeSequence = page.oldestSequenceLoaded; + const beforeSortTimestamp = page.oldestSortTimestampLoaded; + if (beforeSequence === null || beforeSortTimestamp === null) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: `Aether conversation page for task '${taskId}' reports more older rows but carries no older-page cursor.`, + }); + } + page = yield* restClient + .getConversationMessages(taskId, { + sequence: beforeSequence, + sortTimestamp: beforeSortTimestamp, + }) + .pipe(Effect.mapError(toRestRequestError(method))); + if (page.oldestSequenceLoaded !== null && page.oldestSequenceLoaded >= beforeSequence) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method, + detail: `Aether conversation paging for task '${taskId}' did not advance past sequence ${beforeSequence}.`, + }); + } + rows.unshift(...page.messages); + } + return rows; + }); + const startSession: ProviderAdapterShape["startSession"] = Effect.fn( "startSession", )(function* (input) { @@ -1142,7 +1478,7 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( // mirror that repo's diffs onto this checkout. let taskId: string | undefined; let latestSequence = 0; - let turnLedger: unknown; + let turnLedger: Array = []; let resumedTask: AetherTask | undefined; if (resume !== undefined) { const task = yield* restClient.getTask(resume.taskId).pipe( @@ -1168,7 +1504,19 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( // Keep the CURSOR's sequence, not the task row's: it is the safe // replay point — fast-forwarding here would skip never-ingested rows. latestSequence = resume.latestSequence; - turnLedger = resume.turnLedger; + // Rebuild the WHOLE turn ledger from the conversation page rather than + // trusting the cursor snapshot (spec build item 10, resolved note 7): + // the persisted ledger is stale by up to a turn after a crash (a + // respond's entry lives only in memory until the next cursor + // snapshot), and an unledgered own row would be misclassified as + // remote-originated. Every user row IS a turn (the wire turn id is the + // opening user row's id), so remote-turn warnings (build item 13) + // apply only to rows arriving AFTER this snapshot — exactly the set + // the readThread snapshot cannot already render as real turns. + const timeline = yield* fetchFullTimeline(restClient, resume.taskId, "startSession"); + turnLedger = timeline + .filter((row) => row.role === "user") + .map((row) => ({ turnId: String(turnIdForWire(row.id)), messageId: row.id })); } // (4) Session record. Model precedence: explicit selection, else the @@ -1195,6 +1543,9 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( taskId, latestSequence, turnLedger, + issuedClientMessageIds: new Set(), + warnedRemoteRows: new Set(), + remoteTurnSyncs: new Set(), sessionScope: undefined, mapper: undefined, mirror: makeAetherMirrorSync({ @@ -1293,40 +1644,9 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( return { threadId, turns: [] } satisfies ProviderThreadSnapshot; } const restClient = yield* requireRestClient("readThread"); - const taskId = context.taskId; - let page = yield* restClient - .getConversationMessages(taskId) - .pipe(Effect.mapError(toRestRequestError("readThread"))); - const rows: Array = [...page.messages]; - // Walk `hasMoreOlder` back to the first turn: the endpoint serves the - // NEWEST page first, and a snapshot missing older turns would be silent - // data loss. The cursor must advance every page — a stuck cursor is a - // contract break, surfaced loudly instead of looping forever. - while (page.hasMoreOlder) { - const beforeSequence = page.oldestSequenceLoaded; - const beforeSortTimestamp = page.oldestSortTimestampLoaded; - if (beforeSequence === null || beforeSortTimestamp === null) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "readThread", - detail: `Aether conversation page for task '${taskId}' reports more older rows but carries no older-page cursor.`, - }); - } - page = yield* restClient - .getConversationMessages(taskId, { - sequence: beforeSequence, - sortTimestamp: beforeSortTimestamp, - }) - .pipe(Effect.mapError(toRestRequestError("readThread"))); - if (page.oldestSequenceLoaded !== null && page.oldestSequenceLoaded >= beforeSequence) { - return yield* new ProviderAdapterRequestError({ - provider: PROVIDER, - method: "readThread", - detail: `Aether conversation paging for task '${taskId}' did not advance past sequence ${beforeSequence}.`, - }); - } - rows.unshift(...page.messages); - } + // A snapshot missing older turns would be silent data loss — walk the + // whole timeline back to the first turn. + const rows = yield* fetchFullTimeline(restClient, context.taskId, "readThread"); return { threadId, turns: snapshotTurnsFromMessages(rows), @@ -1498,6 +1818,32 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( }); }); + /** + * Shared post-202 bookkeeping for a respond that dispatches IMMEDIATELY + * (idle follow-up, plan accept/reject, question answer): ledger the new + * wire turn, (re)attach the pipeline with the one-shot start permission, + * register + announce the turn, and arm the settle backstop. + */ + const activateRespondedTurn = Effect.fn("activateAetherRespondedTurn")(function* ( + context: AetherSessionContext, + restClient: AetherRestClient, + taskId: string, + wireTurnId: string, + ): Effect.fn.Return { + const turn: AetherActiveTurn = { wireTurnId, turnId: turnIdForWire(wireTurnId) }; + context.turnLedger.push({ turnId: String(turn.turnId), messageId: wireTurnId }); + // RECORD THE TURN FIRST (T6 invariant): the attach below forks a pump + // whose onConnected reconcile can settle this very turn on its first + // beat — attaching before the turn exists strands activeTurn state. + const { mapper } = yield* ensureTaskMapper(context, restClient, taskId); + context.activeTurn = turn; + yield* processMapperEvents(context, mapper.noteTurnStarted(wireTurnId, yield* nowIso)); + yield* emitTurnStarted(context, turn, {}); + yield* ensureTaskPipeline(context, restClient, taskId, { allowStart: true }); + yield* startSettlePoll(context); + return turn; + }); + const sendTurn: ProviderAdapterShape["sendTurn"] = Effect.fn("sendTurn")( function* (input) { const context = yield* ensureContext(input.threadId); @@ -1586,6 +1932,9 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( // re-enters HERE — never the respond path, never a second create. const wireTurnId = yield* harvestFirstUserRowId(restClient, taskIdForFirstTurn); const turn: AetherActiveTurn = { wireTurnId, turnId: turnIdForWire(wireTurnId) }; + // Ledger the harvested pair — `POST /tasks` returns no message_id, + // so the timeline harvest is turn 1's only naming (resolved note 7). + context.turnLedger.push({ turnId: String(turn.turnId), messageId: wireTurnId }); // RECORD THE TURN FIRST. The mapper must learn the active wire turn // so a settle observed only through the REST backstop still lands — // and the attach below forks a fiber whose onConnected reconcile can @@ -1614,21 +1963,130 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( // -- later turns: respond --------------------------------------------- const taskId = context.taskId; + + // The selection's reasoning effort, resolved (and validated) ONCE for + // both the settings PUT below and the respond. It rides EVERY respond, + // not just a slug switch: a model OPTION can change while the slug + // stays the same, and a respond that omits it inherits the task row's + // stored effort — silently pinning the previous one. `POST /respond` + // carries a per-message `reasoning_effort` (apitypes/tasks.go + // RespondToTaskRequest → task_messages.reasoning_effort) that IS what + // the runner reads for the turn, so stating it here is both the + // smallest fix and the only one that works for a steer queued behind a + // running turn (a PUT is refused while the task is processing). + const selectionEffort = + input.modelSelection === undefined + ? undefined + : yield* resolveEffortSelection( + input.modelSelection, + resolveAetherModelSlug(input.modelSelection.model), + ); + + // Model switch between turns (build item 11): `PUT /tasks/{id}` is a + // FULL settings replace, so the current row is read back first — the + // auto_fix_* flags are live-mutable remotely and must not be clobbered + // with the driver's create-time `false`. if ( input.modelSelection !== undefined && input.modelSelection.model !== context.session.model ) { - return yield* new ProviderAdapterValidationError({ - provider: PROVIDER, - operation: "sendTurn", - issue: `Mid-thread model switch to '${input.modelSelection.model}' is not supported yet (Aether driver build item 11).`, - }); + if (context.activeTurn !== undefined || context.deferredTurns.length > 0) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `Cannot switch the model to '${input.modelSelection.model}' while a turn is running; stop the turn or let it finish first.`, + }); + } + const resolved = resolveAetherModelSlug(input.modelSelection.model); + const current = yield* restClient + .getTask(taskId) + .pipe(Effect.mapError(toRestRequestError("sendTurn"))); + if (current.status === "processing" || current.status === "queued") { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: `Cannot switch the model: Aether task '${taskId}' is currently ${current.status} (a turn driven from the Aether app may be running). Wait for it to settle, then retry.`, + }); + } + yield* restClient + .updateTask(taskId, { + agent_type: resolved.agentType, + model: resolved.model, + // Per-turn plan mode travels on the respond below; the stored + // task setting is preserved as-is. + interaction_mode: current.interaction_mode, + // Required-but-nullable on update: null means an explicit null. + reasoning_effort: selectionEffort ?? null, + auto_fix_ci: current.auto_fix_ci, + auto_fix_pr_comments: current.auto_fix_pr_comments, + auto_rebase: current.auto_rebase, + }) + .pipe(Effect.mapError(toRestRequestError("sendTurn"))); + context.session = { + ...context.session, + model: input.modelSelection.model, + updatedAt: yield* nowIso, + }; } + const clientMessageId = deterministicClientMessageId({ taskId, sessionEpoch: context.session.createdAt, sendOrdinal: context.sentCount, }); + + // A task parked on a proposed plan makes this send the accept/reject + // verb (spec §2.4): t3 routes plan acceptance as a fresh turn with + // interactionMode 'default' (ChatView thread.turn.start) and a + // keep-planning follow-up as interactionMode 'plan' — Aether demands + // the propose_plan tool_response either way. + const pendingPlan = context.mapper?.openUserInput(); + if (pendingPlan !== undefined && pendingPlan.toolName === "propose_plan") { + const approved = input.interactionMode !== "plan"; + // Registered BEFORE the call: the server commits the user row before + // the 202 returns, so a concurrent settle-poll reconcile can observe + // it mid-flight — pre-registration keeps the own-send classification + // from warning on it. Safe: the id is deterministic per ordinal, and + // the ordinal advances only on a confirmed 202. + context.issuedClientMessageIds.add(clientMessageId); + const responded = yield* restClient + .respondToTask(taskId, { + message, + ...(promptContext !== undefined ? { context: promptContext } : {}), + interaction_mode: approved ? "default" : "plan", + tool_response: { + tool_name: "propose_plan", + data: { approved, ...(approved ? {} : { feedback: message }) }, + }, + client_message_id: clientMessageId, + }) + .pipe(Effect.mapError(toRestRequestError("sendTurn"))); + context.sentCount++; + context.adoptActiveTurn = false; + if (context.mapper !== undefined) { + // Close the pending slot (no resolution event for plan cards). + yield* processMapperEvents( + context, + context.mapper.noteInputResolved(pendingPlan.pendingId, {}, yield* nowIso), + ); + } + const turn = yield* activateRespondedTurn( + context, + restClient, + taskId, + responded.message_id, + ); + const resumeCursor = buildAetherResumeCursor(context); + return { + threadId: input.threadId, + turnId: turn.turnId, + ...(resumeCursor !== undefined ? { resumeCursor } : {}), + }; + } + + // Registered BEFORE the call (see the plan branch above): the row can + // hit the wire before the 202 lands here. + context.issuedClientMessageIds.add(clientMessageId); const responded = yield* restClient .respondToTask(taskId, { message, @@ -1636,6 +2094,11 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( ...(input.interactionMode !== undefined ? { interaction_mode: input.interactionMode } : {}), + // Only on a plain prompt: workspace-service applies a queued + // message's reasoning_effort ONLY when the message carries no + // tool_response (http/agent-handlers.ts), so stating it on the plan + // branch above would be dead payload. + ...(selectionEffort !== undefined ? { reasoning_effort: selectionEffort } : {}), client_message_id: clientMessageId, }) .pipe(Effect.mapError(toRestRequestError("sendTurn"))); @@ -1648,13 +2111,14 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( // remotely running turn no longer applies. context.adoptActiveTurn = false; const wireTurnId = responded.message_id; - const turn: AetherActiveTurn = { wireTurnId, turnId: turnIdForWire(wireTurnId) }; if (context.activeTurn !== undefined || context.deferredTurns.length > 0) { // STEER: Aether queues the message server-side; the running turn // completes first. DEFER turn.started until remote pickup, but set // the session's activeTurnId to the new turn NOW (spec §2.1 // queued/steering row). + const turn: AetherActiveTurn = { wireTurnId, turnId: turnIdForWire(wireTurnId) }; + context.turnLedger.push({ turnId: String(turn.turnId), messageId: wireTurnId }); context.deferredTurns.push({ ...turn, text: message }); context.session = { ...context.session, @@ -1670,16 +2134,11 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( }; } - // Idle task: the respond dispatches immediately. The turn is recorded - // BEFORE the attach for the same reason as the create path — a pump - // restarted here reconciles on its first beat and can settle it. - const { mapper } = yield* ensureTaskMapper(context, restClient, taskId); - context.activeTurn = turn; - yield* processMapperEvents(context, mapper.noteTurnStarted(wireTurnId, yield* nowIso)); - yield* emitTurnStarted(context, turn, {}); - // Re-attach if the pump ended (suspended VM) — active, may start. - yield* ensureTaskPipeline(context, restClient, taskId, { allowStart: true }); - yield* startSettlePoll(context); + // Idle task: the respond dispatches immediately. activateRespondedTurn + // records the turn BEFORE the attach for the same reason as the create + // path — a pump restarted here reconciles on its first beat and can + // settle it. Re-attaches if the pump ended (suspended VM); may start. + const turn = yield* activateRespondedTurn(context, restClient, taskId, wireTurnId); const resumeCursor = buildAetherResumeCursor(context); return { threadId: input.threadId, @@ -1783,28 +2242,152 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( } }); + // -- questions (build item 9) ---------------------------------------------- + + const respondToUserInput: ProviderAdapterShape["respondToUserInput"] = + Effect.fn("respondToUserInput")(function* (threadId, requestId, answers) { + const context = yield* ensureContext(threadId); + const restClient = yield* requireRestClient("respondToUserInput"); + const requestKey = String(requestId); + const pending = context.mapper?.openUserInput(); + // The exact substring `unknown pending user-input request` is t3's + // stale-request trigger (ProviderCommandReactor / decider render it as + // "Stale pending user-input request … restart the turn"). + if ( + context.taskId === undefined || + pending === undefined || + pending.pendingId !== requestKey + ) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "respondToUserInput", + detail: `Aether driver: unknown pending user-input request '${requestKey}'. The question may have been answered from the Aether app or superseded; the transcript catches up on the next sync.`, + }); + } + if (pending.toolName !== "ask_user") { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "respondToUserInput", + detail: `Pending input '${requestKey}' is a proposed plan, not a question. Send a message to accept the plan, or a plan-mode follow-up to keep planning.`, + }); + } + const taskId = context.taskId; + const built = buildAskUserToolResponse(pending.questions, answers); + if ("issue" in built) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "respondToUserInput", + issue: built.issue, + }); + } + const clientMessageId = deterministicClientMessageId({ + taskId, + sessionEpoch: context.session.createdAt, + sendOrdinal: context.sentCount, + }); + // Registered BEFORE the call (see sendTurn): the answer row can hit + // the wire before the 202 lands here. + context.issuedClientMessageIds.add(clientMessageId); + const responded = yield* restClient + .respondToTask(taskId, { + // The transcript row: the raw response JSON, exactly like Aether's + // own composer (packages/conversation toolResponseRequestBody). + // @effect-diagnostics-next-line preferSchemaOverJson:off - mirrors aether-web's wire-exact transcript row, not a schema decode. + message: JSON.stringify(built.data), + tool_response: { tool_name: "ask_user", data: built.data }, + client_message_id: clientMessageId, + }) + .pipe( + Effect.catch((cause) => + cause._tag === "AetherApiConflictError" + ? // 409: already answered / wrong pending kind. Re-sync the + // durable feed FIRST so the stale panel resolves before any + // retry (best-effort — the reconcile swallows its own + // transient failures), then fail with a detail that CARRIES + // the exact `unknown pending user-input request` substring: + // spec §2.4 mandates it for the 409 path, and t3's + // stale-request machinery (ProviderCommandReactor, decider, + // ProjectionPipeline) classifies the failure only by that + // substring. The decoded body's message rides along. + (context.reconcile ?? Effect.void).pipe( + Effect.andThen( + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "respondToUserInput", + detail: `Aether driver: unknown pending user-input request '${requestKey}' — Aether rejected the answer (409): ${cause.detail}`, + cause, + }), + ), + ), + ) + : Effect.fail(toRestRequestError("respondToUserInput")(cause)), + ), + ); + context.sentCount++; + context.adoptActiveTurn = false; + // Resolve the panel BEFORE announcing the resumed turn. + if (context.mapper !== undefined) { + yield* processMapperEvents( + context, + context.mapper.noteInputResolved(requestKey, answers, yield* nowIso), + ); + } + yield* activateRespondedTurn(context, restClient, taskId, responded.message_id); + }); + return { provider: PROVIDER, capabilities: { - // Flips to "in-session" with the model-switch slice (build item 11). - sessionModelSwitch: "unsupported", + // "in-session", not restart-based: `PUT /tasks/{id}` replaces the + // task's settings in place (apitypes/tasks.go UpdateTaskRequest), so a + // mid-thread model pick lands on the SAME cloud conversation. The + // restart path (`unsupported` + requiresNewThreadForModelChange) would + // lie here — a restarted session rebinds the same taskId anyway, and + // the reactor would silently pin the previous model for turns sent + // without an explicit selection. + sessionModelSwitch: "in-session", }, startSession, sendTurn, interruptTurn, - respondToRequest: () => notImplemented("respondToRequest"), - respondToUserInput: () => notImplemented("respondToUserInput"), + // Aether surfaces no command/file approvals to clients — tools are + // auto-approved remotely inside the VM (tool_response is only + // ask_user | propose_plan). No request.opened is ever emitted, so this + // is unreachable; if it fires anyway, say what actually happens. + respondToRequest: (_threadId, requestId) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "respondToRequest", + detail: `Aether cloud tasks run with full workspace access and auto-approve tool use remotely; there is no approval request '${String(requestId)}' to answer.`, + }), + ), + respondToUserInput, stopSession, listSessions: () => Effect.sync(() => [...sessions.values()].map((context) => context.session)), hasSession: (threadId) => Effect.sync(() => sessions.has(threadId)), readThread, - rollbackThread: () => notImplemented("rollbackThread"), + // v1 refusal (spec §2.2 revert row): the WS `git restore` verb exists, + // but reverting also truncates the remote conversation and moves the + // VM's tree — wiring that safely is the revert slice. Refuse loudly with + // the actionable alternative instead of a silent no-op. + rollbackThread: (threadId) => + Effect.fail( + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "rollbackThread", + detail: `Reverting turns is not supported for Aether cloud sessions yet (thread '${String(threadId)}'): the local checkout is a one-way mirror of the cloud workspace. Revert the task from the Aether app; the next turn's sync re-baselines the local checkout.`, + }), + ), // Pure disconnect for every session; remote tasks are untouched. Each // thread gets the same scope-closing teardown and graceful session.exited // stopSession emits — ingestion clears per-session state from that event. stopAll: () => Effect.gen(function* () { - for (const [threadId, context] of [...sessions.entries()]) { + // The copy is load-bearing: disconnectSession deletes from the map + // mid-iteration (Array.from over a spread per unicorn/no-useless-spread). + for (const [threadId, context] of Array.from(sessions.entries())) { yield* disconnectSession(threadId, context); } }), diff --git a/apps/server/src/provider/Layers/AetherProvider.ts b/apps/server/src/provider/Layers/AetherProvider.ts index f84571997312..43419c29f1d2 100644 --- a/apps/server/src/provider/Layers/AetherProvider.ts +++ b/apps/server/src/provider/Layers/AetherProvider.ts @@ -35,6 +35,9 @@ import { const AETHER_PRESENTATION = { displayName: "Aether", + // Parity with the web driver metadata (providerDriverMeta.ts): every + // instance of the driver advertises the early-access gate. + badgeLabel: "Early Access", showInteractionModeToggle: true, } as const; diff --git a/apps/server/src/provider/Layers/aether/eventMapper.fixtures.ts b/apps/server/src/provider/Layers/aether/eventMapper.fixtures.ts index df2a1e6d8957..c8335abed721 100644 --- a/apps/server/src/provider/Layers/aether/eventMapper.fixtures.ts +++ b/apps/server/src/provider/Layers/aether/eventMapper.fixtures.ts @@ -371,6 +371,9 @@ const taskBase = { agent_type: "codex", model: "gpt-5.6-sol", interaction_mode: "default", + auto_fix_ci: false, + auto_fix_pr_comments: false, + auto_rebase: false, } as const; export const taskProcessing: AetherTask = { diff --git a/apps/server/src/provider/Layers/aether/eventMapper.test.ts b/apps/server/src/provider/Layers/aether/eventMapper.test.ts index ea3958931252..a14dc42fd81c 100644 --- a/apps/server/src/provider/Layers/aether/eventMapper.test.ts +++ b/apps/server/src/provider/Layers/aether/eventMapper.test.ts @@ -817,3 +817,202 @@ describe("AetherEventMapper — interrupted turns (T6)", () => { expect(mapper.activeWireTurnId()).toBe("u2"); }); }); + +describe("AetherEventMapper — open pending input (T7/T8)", () => { + it("exposes the open ask_user input with raw answer indices", () => { + const mapper = makeMapper(); + mapper.mapWsEvent(parseFrame(wsAwaitingInputQuestions), NOW); + expect(mapper.openUserInput()).toEqual({ + pendingId: "pi-1", + toolName: "ask_user", + wireTurnId: "u1", + questions: [ + { + id: "q1", + rawIndex: 0, + multiSelect: false, + options: [ + { label: "Patch the reducer", rawIndex: 0 }, + { label: "Rewrite the module", rawIndex: 1 }, + ], + }, + ], + }); + }); + + it("preserves RAW wire indices when malformed questions/options are skipped", () => { + // Question 0 and option 0 are malformed and skipped from the rendered + // questions — the answer keys must still address the wire positions. + const parsed = parseAetherQuestions({ + questions: [ + "not-an-object", + { + question: "Which db?", + options: [{ description: "no label" }, { label: "sqlite" }], + }, + ], + }); + expect(parsed.issues).toHaveLength(2); + expect(parsed.answerable).toEqual([ + { + id: "Which db?", + rawIndex: 1, + multiSelect: false, + options: [{ label: "sqlite", rawIndex: 1 }], + }, + ]); + }); + + it("dedupes synthesized question ids so answers stay addressable", () => { + const parsed = parseAetherQuestions({ + questions: [{ question: "Proceed?" }, { question: "Proceed?" }], + }); + expect(parsed.questions.map((question) => question.id)).toEqual(["Proceed?", "Proceed?#2"]); + expect(parsed.answerable.map((question) => question.rawIndex)).toEqual([0, 1]); + }); + + it("noteInputResolved emits user-input.resolved once and closes the slot", () => { + const mapper = makeMapper(); + mapper.mapWsEvent(parseFrame(wsAwaitingInputQuestions), NOW); + const answers = { q1: ["Rewrite the module"] }; + const events = mapper.noteInputResolved("pi-1", answers, NOW); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "user-input.resolved", + eventId: "aether:task-1:input:pi-1:resolved", + requestId: "pi-1", + turnId: "aether-turn-u1", + payload: { answers }, + }); + expect(mapper.openUserInput()).toBeUndefined(); + // Already resolved: nothing more, whatever id arrives. + expect(mapper.noteInputResolved("pi-1", answers, NOW)).toEqual([]); + }); + + it("resolves an open question out of band when a different turn starts (live)", () => { + const mapper = makeMapper(); + mapper.mapWsEvent(parseFrame(wsAwaitingInputQuestions), NOW); + // The answer was submitted from the Aether app: the resumed turn's first + // live event proves it — the panel must clear before the new output. + const events = mapper.mapWsEvent( + parseFrame({ ...wsAssistantDelta, turnId: "u2", messageId: "m9" }), + NOW, + ); + expect(events.map((event) => event.type)).toEqual(["user-input.resolved", "content.delta"]); + expect(events[0]).toMatchObject({ requestId: "pi-1", payload: { answers: {} } }); + expect(mapper.openUserInput()).toBeUndefined(); + }); + + it("does NOT resolve an open question on a bare processing beat (awaiting_input commit race)", () => { + const mapper = makeMapper(); + mapper.mapWsEvent(parseFrame(wsAwaitingInputQuestions), NOW); + // The workspace emits the live question BEFORE the API commits the task + // row to awaiting_input, so a reconcile in that window reads a stale + // `processing` with NO new rows and no activeProcessingTurn flip. + // Resolving on that would permanently suppress the question + // (requestedInputs never re-surfaces the same tool_id) and wedge the + // thread — the bare status is not evidence of an out-of-band answer. + const events = mapper.reconcileDelta(makeDelta({ task: taskProcessing, messages: [] }), NOW); + expect(events).toEqual([]); + expect(mapper.openUserInput()).toMatchObject({ pendingId: "pi-1" }); + // Same for a stale `queued` beat. + const queued = mapper.reconcileTask( + { ...taskProcessing, status: "queued", run_context: null }, + NOW, + ); + expect(queued).toEqual([]); + expect(mapper.openUserInput()).toMatchObject({ pendingId: "pi-1" }); + }); + + it("resolves an open question when the delta names a DIFFERENT processing turn", () => { + const mapper = makeMapper(); + mapper.mapWsEvent(parseFrame(wsAwaitingInputQuestions), NOW); + // Genuine out-of-band answer: the resumed turn IS the evidence — the + // delta's activeProcessingTurn names a wire turn other than the asker. + const events = mapper.reconcileDelta( + makeDelta({ + task: taskProcessing, + messages: [], + activeProcessingTurn: { messageId: "m9", startedAt: NOW }, + }), + NOW, + ); + expect(events.map((event) => event.type)).toEqual([ + "user-input.resolved", + "session.state.changed", + ]); + expect(events[0]).toMatchObject({ requestId: "pi-1", payload: { answers: {} } }); + expect(events[1]).toMatchObject({ payload: { state: "running" } }); + expect(mapper.openUserInput()).toBeUndefined(); + }); + + it("resolves an open question when the delta carries the answering user row", () => { + const mapper = makeMapper(); + mapper.mapWsEvent(parseFrame(wsAwaitingInputQuestions), NOW); + // The remotely submitted answer arrives as a delivered user row — a turn + // opener for a different wire turn, which resolves the parked input. + const events = mapper.reconcileDelta( + makeDelta({ + task: taskProcessing, + messages: [ + { + id: "m9", + role: "user", + content: '{"answers":{"0":[0]}}', + deliveryStatus: "processing", + timestamp: NOW, + sequence: 10, + }, + ], + }), + NOW, + ); + expect(events.map((event) => event.type)).toEqual([ + "user-input.resolved", + "session.state.changed", + ]); + expect(events[0]).toMatchObject({ requestId: "pi-1" }); + expect(mapper.openUserInput()).toBeUndefined(); + }); + + it("out-of-band resolution into message-idle emits the corrective READY", () => { + const mapper = makeMapper(); + mapper.mapWsEvent(parseFrame(wsAwaitingInputQuestions), NOW); + // The whole answer turn happened while detached: the next observation is + // already the idle state, so nothing else corrects the projected + // `waiting` — the explicit ready emission must. + const events = mapper.reconcileDelta( + makeDelta({ task: taskAwaitingMessage, messages: [] }), + NOW, + ); + expect(events.map((event) => event.type)).toEqual([ + "user-input.resolved", + "session.state.changed", + ]); + expect(events[1]).toMatchObject({ payload: { state: "ready" } }); + }); + + it("a NEW pending input supersedes and resolves the previous one", () => { + const mapper = makeMapper(); + mapper.mapWsEvent(parseFrame(wsAwaitingInputQuestions), NOW); + // Aether holds one pending slot per task: a second callback overwrites + // it wholesale, so the first input resolves before the new card. + const events = mapper.mapWsEvent( + parseFrame({ + ...wsAwaitingInputPlan, + turnId: "u2", + pendingInputId: "pi-9", + }), + NOW, + ); + expect(events.map((event) => event.type)).toEqual([ + "user-input.resolved", + // The new asking turn's own settle (awaiting_input IS a settle). + "turn.completed", + "turn.proposed.completed", + "session.state.changed", + ]); + expect(events[0]).toMatchObject({ requestId: "pi-1" }); + expect(mapper.openUserInput()).toMatchObject({ pendingId: "pi-9", toolName: "propose_plan" }); + }); +}); diff --git a/apps/server/src/provider/Layers/aether/eventMapper.ts b/apps/server/src/provider/Layers/aether/eventMapper.ts index 615969269937..60c51d1d000c 100644 --- a/apps/server/src/provider/Layers/aether/eventMapper.ts +++ b/apps/server/src/provider/Layers/aether/eventMapper.ts @@ -67,6 +67,43 @@ export interface AetherEventMapperOptions { readonly initialSequence: number; } +/** + * One selectable option of an open ask_user question, keyed for the answer + * wire: Aether's respond verb addresses options by their RAW index in the + * tool input's options array (packages/conversation question-drafts.ts — + * "question identity is array position"), so the mapper records the raw + * index alongside the label the t3 UI echoes back. + */ +export interface AetherAnswerableOption { + readonly label: string; + readonly rawIndex: number; +} + +export interface AetherAnswerableQuestion { + /** The id emitted on the t3 `user-input.requested` question (unique per input). */ + readonly id: string; + /** The question's raw index in the wire input — the `answers` record key. */ + readonly rawIndex: number; + readonly multiSelect: boolean; + readonly options: ReadonlyArray; +} + +/** + * The single pending interaction the remote task is parked on. Aether's + * domain holds at most ONE pending input per task (the + * `tasks.pending_question_payload` slot — a new callback overwrites it + * wholesale), so the mapper mirrors that as a single open slot. + */ +export interface AetherOpenUserInput { + /** `pendingInputId` (WS) ≡ `tool_id` (REST) — the id the respond verb answers. */ + readonly pendingId: string; + readonly toolName: "ask_user" | "propose_plan"; + /** The wire turn that asked, when known (stamps the resolution event). */ + readonly wireTurnId: string | undefined; + /** Empty for propose_plan. */ + readonly questions: ReadonlyArray; +} + export interface AetherEventMapper { /** Map one parsed live WS agent event. `slash_commands.updated` maps to []. */ readonly mapWsEvent: ( @@ -111,6 +148,25 @@ export interface AetherEventMapper { * after the stop skips its error card (a stop is not a provider failure). */ readonly markInterrupted: (wireTurnId: string) => void; + /** + * The pending input the remote task is currently parked on, if any — + * the answer-side twin of `user-input.requested`/`turn.proposed.completed` + * (build item 9: respondToUserInput / plan accept read it to build the + * aether-exact tool_response). + */ + readonly openUserInput: () => AetherOpenUserInput | undefined; + /** + * The driver answered the open input itself (respond 202 landed): emit its + * `user-input.resolved` (ask_user only — plan cards have no resolution + * event) carrying the submitted answers, and close the slot so the durable + * reconcile does not re-resolve it. A stale/mismatched pendingId maps to [] + * — the slot was already resolved out of band. + */ + readonly noteInputResolved: ( + pendingId: string, + answers: Record, + nowIso: string, + ) => ReadonlyArray; } // --------------------------------------------------------------------------- @@ -231,6 +287,13 @@ function readTodoItems(blocks: ReadonlyArray | undefined): ReadonlyArra interface ParsedQuestions { readonly questions: ReadonlyArray; readonly issues: ReadonlyArray; + /** + * Aligned 1:1 with `questions`: the raw wire indices the aether respond + * verb keys `answers`/`customAnswers` by. Malformed questions/options are + * SKIPPED from `questions`, which shifts positions — the raw indices here + * are the only correct answer keys after such a skip. + */ + readonly answerable: ReadonlyArray; } /** @@ -244,9 +307,14 @@ export function parseAetherQuestions(input: Record): ParsedQues const issues: Array = []; const rawQuestions = Array.isArray(input.questions) ? input.questions : undefined; if (rawQuestions === undefined) { - return { questions: [], issues: ["ask_user input carries no questions array"] }; + return { questions: [], issues: ["ask_user input carries no questions array"], answerable: [] }; } const questions: Array = []; + const answerable: Array = []; + // t3 keys answers by question id, so ids must be unique per input even + // though the wire's `id` is optional and the synthesized fallback (the + // question text) can repeat — a collision gets the raw index appended. + const usedIds = new Set(); rawQuestions.forEach((raw, index) => { if (!isRecord(raw)) { issues.push(`question ${index} is not an object`); @@ -258,6 +326,7 @@ export function parseAetherQuestions(input: Record): ParsedQues return; } const options: Array<{ label: string; description: string }> = []; + const answerableOptions: Array = []; if (Array.isArray(raw.options)) { raw.options.forEach((rawOption, optionIndex) => { if (!isRecord(rawOption)) { @@ -274,17 +343,25 @@ export function parseAetherQuestions(input: Record): ParsedQues label, description: trimmedOrUndefined(readString(rawOption, "description")) ?? label, }); + answerableOptions.push({ label, rawIndex: optionIndex }); }); } + let id = trimmedOrUndefined(readString(raw, "id")) ?? question; + if (usedIds.has(id)) { + id = `${id}#${index + 1}`; + } + usedIds.add(id); + const multiSelect = raw.multiSelect === true; questions.push({ - id: trimmedOrUndefined(readString(raw, "id")) ?? question, + id, header: trimmedOrUndefined(readString(raw, "header")) ?? `Question ${index + 1}`, question, options, - multiSelect: raw.multiSelect === true, + multiSelect, }); + answerable.push({ id, rawIndex: index, multiSelect, options: answerableOptions }); }); - return { questions, issues }; + return { questions, issues, answerable }; } // --------------------------------------------------------------------------- @@ -318,6 +395,13 @@ export function makeAetherEventMapper(options: AetherEventMapperOptions): Aether const warnedOnce = new Set(); /** The wire turn id currently in flight, for settles observed via REST. */ let activeWireTurnId: string | undefined; + /** + * The single pending interaction, mirroring Aether's one-slot domain + * (`tasks.pending_question_payload`). Set when the input surfaces, cleared + * by the driver's own answer (`noteInputResolved`) or by an out-of-band + * resolution observed on either transport (build item 14). + */ + let openInput: AetherOpenUserInput | undefined; /** Wire turns the user interrupted — their settle state is `interrupted`. */ const interruptedTurns = new Set(); /** @@ -387,12 +471,18 @@ export function makeAetherEventMapper(options: AetherEventMapperOptions): Aether if (wireTurnId === undefined || settledTurns.has(wireTurnId)) { return []; } - const predecessor = - activeWireTurnId !== undefined && activeWireTurnId !== wireTurnId - ? settleTurn({ wireTurnId: activeWireTurnId, state: "completed", createdAt }) - : []; + const events: Array = []; + if (activeWireTurnId !== undefined && activeWireTurnId !== wireTurnId) { + events.push(...settleTurn({ wireTurnId: activeWireTurnId, state: "completed", createdAt })); + } + // A turn OTHER than the one that asked becoming active proves the + // pending input was answered out of band (an answer is the only thing + // that resumes a parked task) — clear the panel (build item 14). + if (openInput !== undefined && openInput.wireTurnId !== wireTurnId) { + events.push(...resolveOpenInput({}, createdAt)); + } activeWireTurnId = wireTurnId; - return predecessor; + return events; }; /** @@ -718,6 +808,38 @@ export function makeAetherEventMapper(options: AetherEventMapperOptions): Aether ]; }; + /** + * Close the open pending-input slot and emit its resolution. ask_user + * inputs pair `user-input.requested` with `user-input.resolved` (t3 clears + * the composer panel from it); plan cards have no resolution event — the + * follow-up turn's own lifecycle supersedes the banner. + */ + const resolveOpenInput = ( + answers: Record, + createdAt: string, + ): ReadonlyArray => { + if (openInput === undefined) { + return []; + } + const resolved = openInput; + openInput = undefined; + if (resolved.toolName !== "ask_user") { + return []; + } + return [ + { + ...base({ + eventId: `aether:${taskId}:input:${resolved.pendingId}:resolved`, + createdAt, + wireTurnId: resolved.wireTurnId, + requestId: resolved.pendingId, + }), + type: "user-input.resolved", + payload: { answers }, + }, + ]; + }; + const pendingInput = (input: { readonly pendingId: string; readonly toolName: "ask_user" | "propose_plan"; @@ -731,9 +853,15 @@ export function makeAetherEventMapper(options: AetherEventMapperOptions): Aether return []; } const events: Array = []; + // Aether holds ONE pending input per task: a new callback overwrites the + // slot wholesale, so a different id arriving means the previous input + // was superseded remotely — resolve it before surfacing the new one. + if (openInput !== undefined && openInput.pendingId !== input.pendingId) { + events.push(...resolveOpenInput({}, input.createdAt)); + } if (input.toolName === "ask_user") { - const { questions, issues } = parseAetherQuestions(input.payload); + const { questions, issues, answerable } = parseAetherQuestions(input.payload); if (issues.length > 0) { events.push( ...warningOnce( @@ -750,6 +878,12 @@ export function makeAetherEventMapper(options: AetherEventMapperOptions): Aether return events; } requestedInputs.add(input.pendingId); + openInput = { + pendingId: input.pendingId, + toolName: "ask_user", + wireTurnId: input.wireTurnId, + questions: answerable, + }; events.push({ ...base({ eventId: `aether:${taskId}:input:${input.pendingId}`, @@ -763,14 +897,23 @@ export function makeAetherEventMapper(options: AetherEventMapperOptions): Aether } else { const plan = trimmedOrUndefined(readString(input.payload, "plan")); if (plan === undefined) { - return warningOnce( - `input:${input.pendingId}:malformed`, - "Aether proposed a plan with no plan markdown.", - { input: input.payload }, - input.createdAt, + events.push( + ...warningOnce( + `input:${input.pendingId}:malformed`, + "Aether proposed a plan with no plan markdown.", + { input: input.payload }, + input.createdAt, + ), ); + return events; } requestedInputs.add(input.pendingId); + openInput = { + pendingId: input.pendingId, + toolName: "propose_plan", + wireTurnId: input.wireTurnId, + questions: [], + }; events.push({ ...base({ eventId: `aether:${taskId}:input:${input.pendingId}`, @@ -1017,14 +1160,30 @@ export function makeAetherEventMapper(options: AetherEventMapperOptions): Aether // Status projection (spec §2.1 working-indicator row, must): // queued→starting, processing→running — without these a passive resume // onto a mid-turn task shows an idle thread receiving assistant output. + // A bare queued/processing observation is NOT proof an open input was + // answered out of band: the workspace emits the live + // turn.awaiting_input BEFORE the API transaction that parks the task + // row commits (handler.ts emits, then flushes the question callback — + // a failed flush widens the window to its replay), so a reconcile beat + // landing in that window still reads the pre-park status. Resolving + // here would permanently suppress the question (`requestedInputs` + // dedupes re-surfacing) and wedge the thread: Aether 409s a plain + // respond while awaiting questions/plan. A GENUINE out-of-band answer + // always surfaces harder evidence — the answering user row / + // `activeProcessingTurn` names a DIFFERENT wire turn (trackTurn + // resolves the input, build item 14), or the task lands on an advanced + // state (awaiting_input / errored, handled below). Until that evidence + // arrives, leave the panel and the `waiting` projection untouched. case "queued": - return projectSessionState( - "starting", - "Aether queued the task; waiting for a workspace.", - createdAt, - ); + return openInput !== undefined + ? [] + : projectSessionState( + "starting", + "Aether queued the task; waiting for a workspace.", + createdAt, + ); case "processing": - return projectSessionState("running", undefined, createdAt); + return openInput !== undefined ? [] : projectSessionState("running", undefined, createdAt); case "awaiting_input": { const events: Array = []; @@ -1043,7 +1202,25 @@ export function makeAetherEventMapper(options: AetherEventMapperOptions): Aether switch (task.awaiting_input.kind) { case "message": // The idle state between EVERY pair of turns: session READY, - // deliberately NO state emission (spec resolved note 2). + // deliberately NO state emission (spec resolved note 2). An open + // input observed here was answered out of band; when nothing + // else corrects the projected `waiting` (no tracked turn to + // settle), the explicit READY emission does. + if (openInput !== undefined) { + events.push(...resolveOpenInput({}, createdAt)); + if (lastProjectedState === "waiting") { + lastProjectedState = "ready"; + stateEmissions++; + events.push({ + ...base({ + eventId: `aether:${taskId}:state:${stateEmissions}:ready`, + createdAt, + }), + type: "session.state.changed", + payload: { state: "ready" }, + }); + } + } return events; case "questions": return [ @@ -1084,6 +1261,8 @@ export function makeAetherEventMapper(options: AetherEventMapperOptions): Aether case "errored": { const events: Array = []; + // An errored task no longer waits on anything — clear a stale panel. + events.push(...resolveOpenInput({}, createdAt)); if (activeWireTurnId !== undefined) { events.push( ...settleTurn({ @@ -1253,5 +1432,10 @@ export function makeAetherEventMapper(options: AetherEventMapperOptions): Aether markInterrupted: (wireTurnId) => { interruptedTurns.add(wireTurnId); }, + openUserInput: () => openInput, + noteInputResolved: (pendingId, answers, nowIso) => + openInput !== undefined && openInput.pendingId === pendingId + ? resolveOpenInput(answers, stamp(undefined, nowIso)) + : [], }; } diff --git a/apps/server/src/provider/Layers/aether/restClient.test.ts b/apps/server/src/provider/Layers/aether/restClient.test.ts index 71323130d312..3e1443c5468c 100644 --- a/apps/server/src/provider/Layers/aether/restClient.test.ts +++ b/apps/server/src/provider/Layers/aether/restClient.test.ts @@ -67,6 +67,9 @@ const taskBase = { agent_type: "codex", model: "gpt-5.6-sol", interaction_mode: "default", + auto_fix_ci: false, + auto_fix_pr_comments: false, + auto_rebase: false, latest_sequence: 41, // Additive fields the client must tolerate without declaring them: display_status: "Working", diff --git a/apps/server/src/provider/Layers/aether/restSchemas.ts b/apps/server/src/provider/Layers/aether/restSchemas.ts index 084005601927..e4f7dcaeacff 100644 --- a/apps/server/src/provider/Layers/aether/restSchemas.ts +++ b/apps/server/src/provider/Layers/aether/restSchemas.ts @@ -117,6 +117,12 @@ const aetherTaskBaseFields = { reasoning_effort: Schema.optional(Schema.NullOr(Schema.String)), last_error: Schema.optional(Schema.NullOr(Schema.String)), head_branch: Schema.optional(Schema.NullOr(Schema.String)), + // Live-mutable remotely (Aether web toggles). The model-switch PUT is a + // FULL settings replace, so the driver reads these back rather than + // clobbering a remote flip with its create-time `false` (build item 11). + auto_fix_ci: Schema.Boolean, + auto_fix_pr_comments: Schema.Boolean, + auto_rebase: Schema.Boolean, latest_sequence: Schema.Number, } as const; From 6913385957e733dd52b89b9b9f2149dbe8e412a5 Mon Sep 17 00:00:00 2001 From: Pranav Sharan Date: Sat, 8 Aug 2026 16:52:03 -0700 Subject: [PATCH 08/44] fix(aether): durable-authoritative turn settlement so checkpoint diffs render (#9) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #7. Aether stamps every live WS frame with turnId = msg.messageId, a fresh crypto.randomUUID minted per prompt dispatch — distinct from the durable user-row turn id the driver keys turns by. Keying settlement off that random live id fragmented one user turn into multiple t3 turns (4 checkpoints; "Latest turn" diff read a post-change-vs-post-change pair and showed nothing), and no per-turn alias could disambiguate a random id arriving out of order across turn boundaries (a stale terminal frame could settle the wrong or next turn). Settlement is now durable-authoritative: when a durable turn is grounded, live turn.completed/turn.failed/turn.awaiting_input frames no longer settle (nor fabricate a runtime.error card) — they only trigger an immediate durable reconcile so settle latency stays low; the durable reconcile (task-status flip) emits the single settle, which the adapter intercepts for mirror-sync-then-forward. A live random id can no longer settle any turn in the grounded path. The cold mapper-only path keeps live settlement so unit tests / degenerate resume still terminate. Net effect: exactly one turn.started/turn.completed per user turn, the mirror change in that settled segment, so t3's CheckpointReactor captures baseline+post and the diff panel renders. Regression tests: live terminal frame with a grounded turn emits no settle; stale-frame-after-next-turn- start cannot settle the new turn; cold path still settles; mirror change lands in the settled segment. Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h Co-authored-by: Claude Fable 5 --- .../src/provider/Layers/AetherAdapter.test.ts | 253 +++++++++++++++++- .../src/provider/Layers/AetherAdapter.ts | 35 ++- .../Layers/aether/eventMapper.test.ts | 235 +++++++++++++++- .../src/provider/Layers/aether/eventMapper.ts | 217 +++++++++++++-- 4 files changed, 701 insertions(+), 39 deletions(-) diff --git a/apps/server/src/provider/Layers/AetherAdapter.test.ts b/apps/server/src/provider/Layers/AetherAdapter.test.ts index 9e203dbafca7..ab69932f3744 100644 --- a/apps/server/src/provider/Layers/AetherAdapter.test.ts +++ b/apps/server/src/provider/Layers/AetherAdapter.test.ts @@ -190,6 +190,7 @@ const withAdapter = ( readonly hasRestClient?: boolean; readonly socket?: AetherAdapterSocketOptions; readonly mirrorRegistry?: AetherMirrorRegistration; + readonly turnTiming?: Partial; }, use: (adapter: ProviderAdapterShape) => Effect.Effect, ) => @@ -203,7 +204,7 @@ const withAdapter = ( restClient: options.hasRestClient === false ? undefined : (options.restClient ?? unusedRestClient), socket: options.socket, - turnTiming: zeroTurnTiming, + turnTiming: options.turnTiming ?? zeroTurnTiming, }); return yield* use(adapter); }).pipe( @@ -1468,6 +1469,256 @@ describe("AetherAdapter event pipeline", () => { }), ); + it.effect( + "one user turn under a random live turnId settles exactly once across BOTH transports", + () => + Effect.gen(function* () { + // The turn-fragmentation regression: Aether stamps a FRESH random + // `turnId` on the live agent frames (agent-handlers.ts mints + // `messageId: crypto.randomUUID()` per dispatch), which is NEVER the + // durable user-row id (u1) the driver keys the turn by. Both the LIVE + // settle and the REST-backstop reconcile report the SAME wire turn — + // exactly one turn.started and one turn.completed must reach the + // stream, with the mirror diff on that single durable turn. + const sockets: Array = []; + let deltaCalls = 0; + let taskIdle = false; + const liveWireTurnId = "9d1f0e2a-7777-4abc-8def-0123456789ab"; + const restClient: AetherRestClient = { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + getTask: () => Effect.succeed(processingTask), + getConversationMessages: () => Effect.succeed(messagesPage(processingTask)), + connectWorkspace: () => + Effect.succeed({ + state: "running", + transport: { websocket_path: "/workspaces/ws-1/ws", preview_token: "t".repeat(32) }, + } as const), + getConversationDelta: (_taskId, after) => + Effect.sync(() => { + deltaCalls++; + // The durable side grounds the turn as u1 via activeProcessingTurn, + // then flips to message-idle — the REST backstop settle of the + // same wire turn the live settle also reports. + return { + task: taskIdle ? idleMessageTask : processingTask, + messages: [], + activity: [], + activeProcessingTurn: taskIdle + ? null + : { messageId: "u1", startedAt: "2026-08-08T10:02:00Z" }, + latestSequence: after, + removedMessageIds: [], + truncated: false, + } satisfies AetherConversationDelta; + }), + }; + yield* withAdapter( + { + restClient, + socket: { + apiBaseUrl: "https://api.runaether.dev", + apiKey: "aether_test_key", + timing: { ...zeroSocketTiming, requestTimeoutMs: 60_000 }, + webSocketFactory: () => { + const socket = diffAnsweringSocket(); + sockets.push(socket); + return socket; + }, + }, + }, + (adapter) => + Effect.gen(function* () { + const collector = yield* adapter.streamEvents.pipe( + Stream.take(6), + Stream.runCollect, + Effect.forkScoped, + ); + yield* adapter.startSession( + startInput({ + resumeCursor: { schemaVersion: 1, taskId: "task-1", latestSequence: 7 }, + }), + ); + yield* settleAdapterPump; + // Adoption reconstructed the durable turn as u1. + expect((yield* adapter.listSessions())[0]!.activeTurnId).toBe("aether-turn-u1"); + + // Live output streams under the RANDOM per-dispatch id while the + // task is still processing — the alias binds it to u1. + sockets[0]!.message({ ...wsAssistantDelta, turnId: liveWireTurnId, messageId: "m1" }); + yield* settleAdapterPump; + + // The turn completes: the live settle AND the REST-backstop + // idle flip both report the same wire turn. + taskIdle = true; + sockets[0]!.message({ ...wsTurnCompleted, turnId: liveWireTurnId }); + yield* settleAdapterPump; + + const events = yield* Fiber.join(collector); + const types = events.map((event) => event.type); + // EXACTLY ONE turn.started and ONE turn.completed — never the + // FOUR fragmented cycles the two id namespaces used to produce. + expect(types.filter((type) => type === "turn.started")).toHaveLength(1); + expect(types.filter((type) => type === "turn.completed")).toHaveLength(1); + expect(types).toEqual([ + "session.started", + "turn.started", + "session.state.changed", + "content.delta", + "turn.diff.updated", + "turn.completed", + ]); + // The started, the mirror diff and the settle all name the ONE + // durable turn u1 — never the random live id. The mirror-applied + // change therefore lands in the single segment the checkpoint + // reactor pairs against its pre-turn baseline. + expect(events.find((event) => event.type === "turn.started")).toMatchObject({ + turnId: "aether-turn-u1", + }); + expect(events.find((event) => event.type === "turn.diff.updated")).toMatchObject({ + eventId: "aether:task-1:turn:u1:diff", + turnId: "aether-turn-u1", + }); + expect(events.find((event) => event.type === "turn.completed")).toMatchObject({ + turnId: "aether-turn-u1", + payload: { state: "completed" }, + }); + expect(types).not.toContain("runtime.error"); + // The REST backstop actually ran (more than the attach reconcile). + expect(deltaCalls).toBeGreaterThan(1); + }), + ); + }), + ); + + it.effect( + "an OWN live frame under a random turnId is not read as a remote turn (no early settle)", + () => + Effect.gen(function* () { + // The own-vs-remote classification used to compare the frame's RAW + // live turnId — a fresh randomUUID per prompt dispatch — against the + // DURABLE ids the driver keys turns by, which can never match: every + // own frame read as a turn injected from the Aether app and fired an + // eager durable reconcile. This asserts an own CONTENT frame is NOT + // misclassified (no eager reconcile on it), while settlement is now + // DURABLE-AUTHORITATIVE: the live turn.completed does not settle the + // grounded turn itself — it TRIGGERS one immediate reconcile whose + // durable observation emits the single settle (mirror sync first). + // + // The settle poll is parked (a 60s cadence no TestClock beat reaches), + // so `deltaCalls` counts the attach reconcile (1) plus the terminal + // frame's triggered reconcile (2). The content frame adding NONE is the + // proof it was not misclassified as remote. + const sockets: Array = []; + let deltaCalls = 0; + const liveWireTurnId = "3f7c1b90-4444-4def-8abc-fedcba987654"; + const restClient: AetherRestClient = { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + getTask: () => Effect.succeed(processingTask), + getConversationMessages: () => Effect.succeed(messagesPage(processingTask)), + connectWorkspace: () => + Effect.succeed({ + state: "running", + transport: { websocket_path: "/workspaces/ws-1/ws", preview_token: "t".repeat(32) }, + } as const), + getConversationDelta: (_taskId, after) => + Effect.sync(() => { + deltaCalls++; + // The attach reconcile grounds the durable turn u1. EVERY later + // read reports the task already parked at message-idle — so a + // spurious eager reconcile would immediately settle u1 and its + // turn.completed would precede the turn's own live output. + return deltaCalls === 1 + ? ({ + task: processingTask, + messages: [], + activity: [], + activeProcessingTurn: { messageId: "u1", startedAt: "2026-08-08T10:02:00Z" }, + latestSequence: after, + removedMessageIds: [], + truncated: false, + } satisfies AetherConversationDelta) + : emptyDelta(idleMessageTask, after); + }), + }; + yield* withAdapter( + { + restClient, + turnTiming: { ...zeroTurnTiming, settlePollMs: 60_000 }, + socket: { + apiBaseUrl: "https://api.runaether.dev", + apiKey: "aether_test_key", + timing: { ...zeroSocketTiming, requestTimeoutMs: 60_000 }, + webSocketFactory: () => { + const socket = diffAnsweringSocket(); + sockets.push(socket); + return socket; + }, + }, + }, + (adapter) => + Effect.gen(function* () { + const collector = yield* adapter.streamEvents.pipe( + Stream.take(6), + Stream.runCollect, + Effect.forkScoped, + ); + yield* adapter.startSession( + startInput({ + resumeCursor: { schemaVersion: 1, taskId: "task-1", latestSequence: 7 }, + }), + ); + yield* settleAdapterPump; + expect((yield* adapter.listSessions())[0]!.activeTurnId).toBe("aether-turn-u1"); + expect(deltaCalls).toBe(1); + + // An OWN live frame under the random per-dispatch id. + sockets[0]!.message({ ...wsAssistantDelta, turnId: liveWireTurnId, messageId: "m1" }); + yield* settleAdapterPump; + // Not remote: no extra reconcile, so no early REST settle … + expect(deltaCalls).toBe(1); + // … and the turn is still the one the driver started. + expect((yield* adapter.listSessions())[0]!.activeTurnId).toBe("aether-turn-u1"); + + // The live terminal frame (also under the random id) does not + // settle the grounded turn itself — it triggers ONE durable + // reconcile whose observation of the message-idle flip settles u1. + sockets[0]!.message({ ...wsTurnCompleted, turnId: liveWireTurnId }); + yield* settleAdapterPump; + + const events = yield* Fiber.join(collector); + const types = events.map((event) => event.type); + expect(types).toEqual([ + "session.started", + "turn.started", + "session.state.changed", + "content.delta", + "turn.diff.updated", + "turn.completed", + ]); + // No remote-originated warning was raised for our own frames. + expect(types).not.toContain("runtime.warning"); + // The settle is DURABLE-sourced on the durable turn, emitted after + // mirror sync — the terminal frame triggered exactly one extra + // reconcile (the content frame triggered none). + expect(events.find((event) => event.type === "turn.completed")).toMatchObject({ + turnId: "aether-turn-u1", + payload: { state: "completed" }, + }); + // Mirror-sync-then-forward: the applied diff lands in the SAME + // settled segment (turn.diff.updated keyed to the settled turn, + // emitted before its turn.completed). + const diffIndex = types.indexOf("turn.diff.updated"); + expect(diffIndex).toBeGreaterThanOrEqual(0); + expect(diffIndex).toBeLessThan(types.indexOf("turn.completed")); + expect(events[diffIndex]).toMatchObject({ turnId: "aether-turn-u1" }); + expect(deltaCalls).toBe(2); + }), + ); + }), + ); + it.effect("does not attach when the thread has no task yet", () => Effect.gen(function* () { const sockets: Array = []; diff --git a/apps/server/src/provider/Layers/AetherAdapter.ts b/apps/server/src/provider/Layers/AetherAdapter.ts index 184035cf1b0d..4b29f186348f 100644 --- a/apps/server/src/provider/Layers/AetherAdapter.ts +++ b/apps/server/src/provider/Layers/AetherAdapter.ts @@ -1014,9 +1014,20 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( * processMapperEvents cannot re-enter; a transiently failed reconcile warns * loudly through the reconcile's own catch, and the warning then lands on a * later reconcile beat. + * + * The id on the frame is the LIVE per-dispatch id (a fresh randomUUID per + * prompt — never the durable user-row id), so the durable-id comparisons + * below can never match it on their own: every own frame would read as a + * remote injection and fire a reconcile whose REST backstop can settle the + * in-flight durable turn (an awaiting_input/processing read) before the live + * id ever binds to it. The mapper owns the live→durable attribution, so it + * answers own-vs-remote here; the durable comparisons stay for the ids that + * ARE durable (a live frame stamped with a durable turn id, the ledger, a + * deferred steer). */ const eagerRemoteTurnReconcile = ( context: AetherSessionContext, + mapper: AetherEventMapper, wireTurnId: string | undefined, ) => Effect.gen(function* () { @@ -1026,6 +1037,7 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( // not to a remote injection — its opening row predates the resume // snapshot, so no warning is owed for it. context.adoptActiveTurn || + mapper.isOwnLiveTurnId(wireTurnId) || context.activeTurn?.wireTurnId === wireTurnId || context.deferredTurns.some((candidate) => candidate.wireTurnId === wireTurnId) || context.turnLedger.some((entry) => entry.messageId === wireTurnId) || @@ -1235,10 +1247,31 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( yield* Effect.logInfo("aether.slash-commands.ignored", { taskId }); } // STRICTLY before the frame is mapped — see eagerRemoteTurnReconcile. - yield* eagerRemoteTurnReconcile(context, "turnId" in event ? event.turnId : undefined); + yield* eagerRemoteTurnReconcile( + context, + mapper, + "turnId" in event ? event.turnId : undefined, + ); const events = mapper.mapWsEvent(event, yield* nowIso); context.latestSequence = mapper.latestSequence(); yield* processMapperEvents(context, events); + // Durable-authoritative settlement: for a grounded turn the mapper + // suppresses the live terminal frame's settle (no turn.completed in + // `events`). Use the frame as a TRIGGER to fire the durable reconcile + // NOW, so the durable settle (and its mirror sync via + // processMapperEvents) emits promptly instead of waiting for the ~3s + // settle poll. In the cold path the live settle already produced a + // turn.completed, so this no-ops. reconcile is idempotent + // (settledTurns/latestSequence guards), so firing before the task row + // has flipped is harmless — the settle poll backstop still catches it. + if ( + (event.kind === "turn.completed" || + event.kind === "turn.failed" || + event.kind === "turn.awaiting_input") && + !events.some((mapped) => mapped.type === "turn.completed") + ) { + yield* reconcile; + } }), onFrameDropped: (problem) => Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/aether/eventMapper.test.ts b/apps/server/src/provider/Layers/aether/eventMapper.test.ts index a14dc42fd81c..f448db941010 100644 --- a/apps/server/src/provider/Layers/aether/eventMapper.test.ts +++ b/apps/server/src/provider/Layers/aether/eventMapper.test.ts @@ -758,6 +758,211 @@ describe("AetherEventMapper — durable reconciliation", () => { }); }); +describe("AetherEventMapper — live per-dispatch turn ids (turn fragmentation)", () => { + // Aether stamps a FRESH random `turnId` on every live agent frame + // (agent-handlers.ts mints `messageId: crypto.randomUUID()` per dispatch), + // which is NEVER the durable user-row id the rest of the driver keys turns + // by. A single user turn therefore arrives under two id namespaces; the + // mapper must attribute every live frame to the ONE durable turn, or the one + // turn settles multiple times (an empty pre-write checkpoint, then the + // post-write one) — the demo-blocking turn-fragmentation bug. + const M1 = "aaaaaaaa-1111-4aaa-8bbb-cccccccccccc"; + const M2 = "bbbbbbbb-2222-4aaa-8bbb-cccccccccccc"; + const M3 = "cccccccc-3333-4aaa-8bbb-cccccccccccc"; + + it("attributes live frames under a random per-dispatch turnId to the durable turn", () => { + const mapper = makeMapper(); + // The durable side grounds turn u1 (sendTurn's noteTurnStarted / adoption). + mapper.noteTurnStarted("u1", NOW); + const delta = mapper.mapWsEvent(parseFrame({ ...wsAssistantDelta, turnId: M1 }), NOW); + // No premature settle of u1; the delta is owned by u1, not by M1. + expect(delta.map((event) => event.type)).toEqual(["content.delta"]); + expect(delta[0]).toMatchObject({ turnId: "aether-turn-u1" }); + expect(mapper.activeWireTurnId()).toBe("u1"); + + // Durable-authoritative settlement: the live turn.completed for a grounded + // turn does NOT settle — it only attributes ownership. + const completed = mapper.mapWsEvent(parseFrame({ ...wsTurnCompleted, turnId: M1 }), NOW); + expect(completed).toHaveLength(0); + + // The single settle comes from the durable reconcile observing the flip. + const settle = mapper.reconcileTask(taskAwaitingMessage, NOW); + const settleCompleted = settle.filter((event) => event.type === "turn.completed"); + expect(settleCompleted).toHaveLength(1); + expect(settleCompleted[0]).toMatchObject({ + type: "turn.completed", + eventId: "aether:task-1:turn:u1:settled", + turnId: "aether-turn-u1", + payload: { state: "completed" }, + }); + // No phantom aether-turn- turn was ever created. + expect([...delta, ...completed, ...settle].map((event) => event.turnId)).not.toContain( + `aether-turn-${M1}`, + ); + }); + + it("coalesces multiple distinct live ids within one durable turn to a single settle", () => { + const mapper = makeMapper(); + mapper.noteTurnStarted("u1", NOW); + // A message frame under M1 and a tool frame under a DIFFERENT per-tool id + // M2 (handler.ts stamps tool events with `event.turnId ?? turnId`). + mapper.mapWsEvent(parseFrame({ ...wsAssistantDelta, turnId: M1 }), NOW); + const tool = mapper.mapWsEvent(parseFrame({ ...wsCodexFileChange, turnId: M2 }), NOW); + // The second distinct live id does NOT settle u1 as a "displaced" turn. + expect(tool.every((event) => event.type !== "turn.completed")).toBe(true); + expect(tool.find((event) => event.type === "item.completed")).toMatchObject({ + turnId: "aether-turn-u1", + }); + // No live id settles the grounded turn (durable-authoritative) … + expect(mapper.mapWsEvent(parseFrame({ ...wsTurnCompleted, turnId: M2 }), NOW)).toHaveLength(0); + // … the single settle is on u1, from the durable reconcile. + const completed = mapper + .reconcileTask(taskAwaitingMessage, NOW) + .filter((event) => event.type === "turn.completed"); + expect(completed).toHaveLength(1); + expect(completed[0]).toMatchObject({ + turnId: "aether-turn-u1", + payload: { state: "completed" }, + }); + }); + + it("settles a grounded turn from the REST backstop, never the live frame", () => { + const mapper = makeMapper(); + mapper.noteTurnStarted("u1", NOW); + // Live streaming binds the random id to u1, then the live terminal frame + // lands — but for a grounded turn it emits NO settle. + mapper.mapWsEvent(parseFrame({ ...wsAssistantDelta, turnId: M1 }), NOW); + const live = mapper.mapWsEvent(parseFrame({ ...wsTurnCompleted, turnId: M1 }), NOW); + expect(live.filter((event) => event.type === "turn.completed")).toHaveLength(0); + // The REST backstop observing the turn parked at message-idle emits the + // single settle — exactly one turn.completed across both transports. + const rest = mapper.reconcileDelta(makeDelta({ task: taskAwaitingMessage, messages: [] }), NOW); + expect(rest.filter((event) => event.type === "turn.completed")).toHaveLength(1); + }); + + it("dedupes a late live settle arriving after the REST backstop settled the same turn", () => { + const mapper = makeMapper(); + mapper.noteTurnStarted("u1", NOW); + // The live frames stream first (binding the random id → u1) … + mapper.mapWsEvent(parseFrame({ ...wsAssistantDelta, turnId: M1 }), NOW); + // … the REST backstop settles u1 first (awaiting_input flip on a poll) … + const rest = mapper.reconcileDelta(makeDelta({ task: taskAwaitingMessage, messages: [] }), NOW); + expect(rest.filter((event) => event.type === "turn.completed")).toHaveLength(1); + // … then the buffered live turn.completed flushes under its random id: it + // resolves through the alias to the already-settled u1 and dedupes. + expect(mapper.mapWsEvent(parseFrame({ ...wsTurnCompleted, turnId: M1 }), NOW)).toHaveLength(0); + }); + + it("dedupes a late live settle whose random id NOTHING bound before the REST settle", () => { + const mapper = makeMapper(); + mapper.noteTurnStarted("u1", NOW); + // The settle-before-bind race: the REST backstop settles u1 while the + // live stream for this dispatch is still buffered, so NO frame ever bound + // the random id — and settleTurn cleared activeWireTurnId, so the + // bind-to-the-turn-in-flight rule no longer applies either. + const rest = mapper.reconcileDelta(makeDelta({ task: taskAwaitingMessage, messages: [] }), NOW); + expect(rest.filter((event) => event.type === "turn.completed")).toHaveLength(1); + expect(mapper.activeWireTurnId()).toBeUndefined(); + // The buffered settle then flushes under an id the mapper has NEVER seen. + // It must land on the last durable turn — already settled, so the + // exactly-one-settle-per-wire-turn guard drops it — instead of opening + // aether-turn- and settling the ONE user turn a second time. + expect(mapper.mapWsEvent(parseFrame({ ...wsTurnCompleted, turnId: M3 }), NOW)).toHaveLength(0); + // A late tail frame under the same unseen id is owned by u1 too. + const tail = mapper.mapWsEvent( + parseFrame({ ...wsAssistantDelta, turnId: M3, messageId: "m-late" }), + NOW, + ); + expect(tail).toHaveLength(1); + expect(tail[0]).toMatchObject({ type: "content.delta", turnId: "aether-turn-u1" }); + }); + + it("drops a late live turn.failed for a settled grounded turn (no phantom, no error card)", () => { + const mapper = makeMapper(); + mapper.noteTurnStarted("u1", NOW); + mapper.reconcileDelta(makeDelta({ task: taskAwaitingMessage, messages: [] }), NOW); + // Durable-authoritative: a live turn.failed for a grounded turn neither + // settles a second time NOR fabricates a runtime.error — the durable + // reconcile owns both. A phantom `aether:task-1:turn::*` would + // replay as a distinct activity on every reconnect. + const late = mapper.mapWsEvent(parseFrame({ ...wsTurnFailed, turnId: M3 }), NOW); + expect(late).toHaveLength(0); + }); + + it("a stale live settle for turn A cannot settle the newly started turn B (round-2 race)", () => { + // The bug the durable-authoritative redesign closes: a buffered live + // turn.completed from a settled turn A, arriving AFTER the user starts + // turn B, must not bind to and settle B (or open a phantom). + const mapper = makeMapper(); + // Turn A grounded, streaming under a bound live id, then REST-settled. + mapper.noteTurnStarted("uA", NOW); + mapper.mapWsEvent(parseFrame({ ...wsAssistantDelta, turnId: M1, messageId: "mA" }), NOW); + const settleA = mapper + .reconcileDelta(makeDelta({ task: taskAwaitingMessage, messages: [] }), NOW) + .filter((event) => event.type === "turn.completed"); + expect(settleA).toHaveLength(1); + expect(settleA[0]).toMatchObject({ turnId: "aether-turn-uA" }); + + // The user starts turn B; that must not re-settle the already-settled A. + const startB = mapper.noteTurnStarted("uB", NOW); + expect(startB.some((event) => event.type === "turn.completed")).toBe(false); + expect(mapper.activeWireTurnId()).toBe("uB"); + + // A's stale/buffered live settle now flushes — both the id A's stream bound + // (M1) and an id nothing ever bound (M2). Neither settles B, neither opens + // a phantom aether-turn-. + expect(mapper.mapWsEvent(parseFrame({ ...wsTurnCompleted, turnId: M1 }), NOW)).toHaveLength(0); + expect(mapper.mapWsEvent(parseFrame({ ...wsTurnCompleted, turnId: M2 }), NOW)).toHaveLength(0); + expect(mapper.activeWireTurnId()).toBe("uB"); + + // B settles exactly once, from its own durable observation. + const settleB = mapper + .reconcileTask(taskAwaitingMessage, NOW) + .filter((event) => event.type === "turn.completed"); + expect(settleB).toHaveLength(1); + expect(settleB[0]).toMatchObject({ + turnId: "aether-turn-uB", + payload: { state: "completed" }, + }); + }); + + it("keeps a raw live id as its own turn when NOTHING durable was ever grounded", () => { + const mapper = makeMapper(); + // The cold mapper-only path (unit tests / a degenerate resume): with no + // durable turn to attribute to, the live id is the only turn identity + // there is — the settle must still land under it. + const settled = mapper.mapWsEvent(parseFrame({ ...wsTurnCompleted, turnId: M3 }), NOW); + expect(settled).toHaveLength(1); + expect(settled[0]).toMatchObject({ + type: "turn.completed", + turnId: `aether-turn-${M3}`, + }); + }); + + it("classifies a live id as own only while a durable turn grounds it, binding nothing", () => { + // The adapter asks this BEFORE mapping a frame, to tell its own output + // from a turn injected from the Aether app (build item 13) — the raw live + // id can never be compared against durable ids directly. + const mapper = makeMapper(); + // Cold: no durable turn, so a live id is evidence of nothing. + expect(mapper.isOwnLiveTurnId(M1)).toBe(false); + mapper.noteTurnStarted("u1", NOW); + // A durable turn in flight owns EVERY live id arriving while it runs. + expect(mapper.isOwnLiveTurnId(M1)).toBe(true); + expect(mapper.isOwnLiveTurnId(M2)).toBe(true); + // Only M1 actually carries a frame; M2 stays a bare query. + mapper.mapWsEvent(parseFrame({ ...wsAssistantDelta, turnId: M1, messageId: "m5" }), NOW); + mapper.reconcileDelta(makeDelta({ task: taskAwaitingMessage, messages: [] }), NOW); + expect(mapper.activeWireTurnId()).toBeUndefined(); + // The id the live stream bound stays own … + expect(mapper.isOwnLiveTurnId(M1)).toBe(true); + // … the merely-queried one does not: the query memoized nothing, so a + // first live frame between turns stays free to be read as a remote + // injection and reconciled BEFORE it is mapped. + expect(mapper.isOwnLiveTurnId(M2)).toBe(false); + }); +}); + describe("parseAetherQuestions", () => { it("reports malformed questions as issues instead of dropping silently", () => { const { questions, issues } = parseAetherQuestions({ @@ -779,14 +984,20 @@ describe("parseAetherQuestions", () => { }); describe("AetherEventMapper — interrupted turns (T6)", () => { - it("settles an interrupt-flagged turn as interrupted, whichever transport observes it", () => { + it("settles an interrupt-flagged turn as interrupted through the durable path", () => { const mapper = makeMapper(); mapper.noteTurnStarted("u1", NOW); expect(mapper.activeWireTurnId()).toBe("u1"); mapper.markInterrupted("u1"); - const events = mapper.mapWsEvent(parseFrame(wsTurnCompleted), NOW); - expect(events).toHaveLength(1); - expect(events[0]).toMatchObject({ + // Durable-authoritative: the live turn.completed for the grounded turn does + // not settle … + expect(mapper.mapWsEvent(parseFrame(wsTurnCompleted), NOW)).toHaveLength(0); + // … the durable reconcile emits the single settle, and the interrupt flag + // makes it read `interrupted` whichever transport observes the flip. + const events = mapper.reconcileTask(taskAwaitingMessage, NOW); + const completed = events.filter((event) => event.type === "turn.completed"); + expect(completed).toHaveLength(1); + expect(completed[0]).toMatchObject({ type: "turn.completed", turnId: "aether-turn-u1", payload: { state: "interrupted" }, @@ -794,15 +1005,19 @@ describe("AetherEventMapper — interrupted turns (T6)", () => { expect(mapper.activeWireTurnId()).toBeUndefined(); }); - it("suppresses the error card when turn.failed lands after a user stop", () => { + it("suppresses the error card when a live turn.failed lands after a user stop", () => { const mapper = makeMapper(); mapper.noteTurnStarted("u1", NOW); mapper.markInterrupted("u1"); - const events = mapper.mapWsEvent(parseFrame(wsTurnFailed), NOW); - // A stop often surfaces remotely as a failed turn: the settle reads - // interrupted and NO runtime.error follows — the user asked for it. - expect(events.map((event) => event.type)).toEqual(["turn.completed"]); - expect(events[0]).toMatchObject({ payload: { state: "interrupted" } }); + // A stop often surfaces remotely as a failed turn. For a grounded turn the + // live frame neither settles nor fabricates a runtime.error. + expect(mapper.mapWsEvent(parseFrame(wsTurnFailed), NOW)).toHaveLength(0); + // The durable reconcile emits the single interrupted settle, no error card. + const events = mapper.reconcileTask(taskAwaitingMessage, NOW); + expect(events.some((event) => event.type === "runtime.error")).toBe(false); + const completed = events.filter((event) => event.type === "turn.completed"); + expect(completed).toHaveLength(1); + expect(completed[0]).toMatchObject({ payload: { state: "interrupted" } }); }); it("noteTurnStarted settles a displaced predecessor exactly like an observed transition", () => { diff --git a/apps/server/src/provider/Layers/aether/eventMapper.ts b/apps/server/src/provider/Layers/aether/eventMapper.ts index 60c51d1d000c..c520ebe8dff5 100644 --- a/apps/server/src/provider/Layers/aether/eventMapper.ts +++ b/apps/server/src/provider/Layers/aether/eventMapper.ts @@ -130,6 +130,19 @@ export interface AetherEventMapper { readonly latestSequence: () => number; /** The wire turn id currently tracked as in flight, if any. */ readonly activeWireTurnId: () => string | undefined; + /** + * Does this RAW live wire turn id belong to a turn the DURABLE side already + * grounded here — i.e. is the frame carrying it this driver's own output + * rather than evidence of a turn injected from the Aether app (build item + * 13)? The live transport stamps a fresh per-dispatch id on every frame, so + * the caller cannot answer this by comparing against durable ids itself. + * + * PURE, unlike `resolveLiveWireTurnId`: it binds no alias, because the + * caller asks BEFORE the frame is mapped and a genuinely remote id must + * stay free to bind to the durable turn the caller's reconcile is about to + * ground. + */ + readonly isOwnLiveTurnId: (rawTurnId: string) => boolean; /** * Register a driver-initiated turn (sendTurn minted it from the 202 / * harvested user row) as the active wire turn, so a settle observed ONLY @@ -395,6 +408,47 @@ export function makeAetherEventMapper(options: AetherEventMapperOptions): Aether const warnedOnce = new Set(); /** The wire turn id currently in flight, for settles observed via REST. */ let activeWireTurnId: string | undefined; + /** + * The last durable turn that was in flight, RETAINED past its settle. A + * settle clears `activeWireTurnId`, and the REST backstop can settle a turn + * before the live stream's first frame for that dispatch ever bound its + * random id (`reconcileTask` observing awaiting_input/processing on a poll + * beat) — the settle-before-bind race. A late live `turn.completed` / + * `turn.failed` then carries an id nothing grounded; resolving it to the last + * durable turn keeps it inside `durableTurns`, so the durable-authoritative + * gate suppresses its live settle instead of letting it fall through to the + * cold path and open a phantom `aether-turn-`. Its content tail + * attributes to that same settled turn. + */ + let lastDurableWireTurnId: string | undefined; + /** + * Turn ids the DURABLE side established — the ONLY source of turn identity + * (an opening user row, `activeProcessingTurn`, or the driver's own + * `noteTurnStarted`). The live WS transport stamps every frame with a FRESH + * per-dispatch `turnId` (a `crypto.randomUUID` minted per prompt in aether + * agent-handlers.ts), which is NEVER the durable user-row id the rest of the + * driver keys turns by. A single user turn therefore arrives under two id + * namespaces. + * + * Settlement is DURABLE-AUTHORITATIVE: once a turn is grounded here, the + * live random-id `turn.completed`/`turn.failed`/`turn.awaiting_input` frames + * do NOT settle it (mapWsEvent returns tracking only); the durable reconcile + * observing the task-status flip owns the single settle. This makes an + * ambiguous live id unable to settle any turn (right, wrong, or phantom). + * Live frames still ATTRIBUTE content to the grounded turn (ownership is + * safe). A live id is authoritative for turn identity — and still settles — + * only in the cold mapper-only path where nothing durable grounds it (unit + * tests / a degenerate resume). + */ + const durableTurns = new Set(); + /** + * Live per-dispatch wire turn id → the durable turn it belongs to. Bound the + * first time a live frame is seen while a durable turn is in flight, so the + * whole live stream attributes CONTENT to the ONE durable turn (and + * `isOwnLiveTurnId` can classify the frame as this driver's own output). + * Settlement no longer rides this alias — it is durable-authoritative. + */ + const liveTurnAlias = new Map(); /** * The single pending interaction, mirroring Aether's one-slot domain * (`tasks.pending_question_payload`). Set when the input surfaces, cleared @@ -482,9 +536,68 @@ export function makeAetherEventMapper(options: AetherEventMapperOptions): Aether events.push(...resolveOpenInput({}, createdAt)); } activeWireTurnId = wireTurnId; + if (durableTurns.has(wireTurnId)) { + lastDurableWireTurnId = wireTurnId; + } return events; }; + /** Record a turn id the DURABLE side established (see `durableTurns`). */ + const noteDurableTurn = (wireTurnId: string | undefined): void => { + if (wireTurnId !== undefined) { + durableTurns.add(wireTurnId); + } + }; + + /** + * Attribute a live frame's per-dispatch wire turn id to the durable turn it + * belongs to (see `durableTurns` / `liveTurnAlias`). A live id never opens or + * transitions a turn: while a durable turn is active, EVERY live id resolves + * to it (bound once so the whole live stream attributes to the one durable + * turn); after it settled, an id nothing ever bound resolves to that same + * last durable turn (see `lastDurableWireTurnId`); with no durable turn EVER + * grounded — the cold mapper-only path — the live id stands in as its own + * turn unchanged. + */ + function resolveLiveWireTurnId(rawTurnId: string): string; + function resolveLiveWireTurnId(rawTurnId: string | undefined): string | undefined; + function resolveLiveWireTurnId(rawTurnId: string | undefined): string | undefined { + if (rawTurnId === undefined) { + return undefined; + } + const aliased = liveTurnAlias.get(rawTurnId); + if (aliased !== undefined) { + return aliased; + } + if (activeWireTurnId !== undefined && durableTurns.has(activeWireTurnId)) { + liveTurnAlias.set(rawTurnId, activeWireTurnId); + return activeWireTurnId; + } + // Settled-before-bind: no durable turn is in flight, so this frame is the + // tail of the one that just settled. Deliberately NOT bound — the binding + // above keeps a turn's whole live stream together, while this is a + // best-effort attribution for a turn already over; leaving the id unbound + // lets the very next frame re-resolve onto a NEW durable turn as soon as + // one is grounded (the eager reconcile of a remote injection). Resolving to + // the last durable turn keeps the id inside `durableTurns` so the + // durable-authoritative gate suppresses its settle (no phantom). + if (lastDurableWireTurnId !== undefined) { + return lastDurableWireTurnId; + } + return rawTurnId; + } + + /** See `AetherEventMapper.isOwnLiveTurnId` — pure, binds nothing. */ + const isOwnLiveTurnId = (rawTurnId: string): boolean => + liveTurnAlias.has(rawTurnId) || + durableTurns.has(rawTurnId) || + // A durable turn in flight owns every live frame that arrives while it + // runs — that is exactly the binding rule above. `lastDurableWireTurnId` + // deliberately does NOT count: between turns a live frame is the first + // evidence of a turn injected from the Aether app, and claiming it as our + // own would suppress the remote-originated warning (build item 13). + (activeWireTurnId !== undefined && durableTurns.has(activeWireTurnId)); + /** * The status projection (spec §2.1 working-indicator row): queued→starting, * processing→running, errored→error, emitted only when the projected state @@ -774,8 +887,11 @@ export function makeAetherEventMapper(options: AetherEventMapperOptions): Aether readonly errorMessage?: string | undefined; readonly createdAt: string; }): ReadonlyArray => { - // Exactly one terminal settle per turn, no matter how many transports - // observe it (live turn.* + REST status projection). + // Exactly one terminal settle per turn. For a grounded turn this is + // DURABLE-AUTHORITATIVE — only the durable reconcile paths (reconcileTask + // awaiting_input/errored, trackTurn's displaced-predecessor transition, + // noteTurnStarted) reach here; the live turn.* frames are suppressed + // upstream. The cold mapper-only path still settles from the live frame. if (settledTurns.has(input.wireTurnId)) { return []; } @@ -954,7 +1070,12 @@ export function makeAetherEventMapper(options: AetherEventMapperOptions): Aether case "tool_call.completed": case "tool_call.failed": return mapToolUpdate( - toolUpdateFromWs(event.toolCallId, event.payload, event.turnId, createdAt), + toolUpdateFromWs( + event.toolCallId, + event.payload, + resolveLiveWireTurnId(event.turnId), + createdAt, + ), "live", ); @@ -969,7 +1090,8 @@ export function makeAetherEventMapper(options: AetherEventMapperOptions): Aether if (completedItems.has(canonicalId)) { return []; } - const turnEvents = trackTurn(event.turnId, createdAt); + const wireTurnId = resolveLiveWireTurnId(event.turnId); + const turnEvents = trackTurn(wireTurnId, createdAt); const counter = (deltaCounters.get(canonicalId) ?? 0) + 1; deltaCounters.set(canonicalId, counter); return [ @@ -978,7 +1100,7 @@ export function makeAetherEventMapper(options: AetherEventMapperOptions): Aether ...base({ eventId: `aether:${taskId}:stream:${canonicalId}:${counter}`, createdAt, - wireTurnId: event.turnId, + wireTurnId, itemId: canonicalId, }), type: "content.delta", @@ -993,7 +1115,8 @@ export function makeAetherEventMapper(options: AetherEventMapperOptions): Aether if (completedItems.has(canonicalId)) { return []; } - const turnEvents = trackTurn(event.turnId, createdAt); + const wireTurnId = resolveLiveWireTurnId(event.turnId); + const turnEvents = trackTurn(wireTurnId, createdAt); const counter = (deltaCounters.get(canonicalId) ?? 0) + 1; deltaCounters.set(canonicalId, counter); return [ @@ -1002,7 +1125,7 @@ export function makeAetherEventMapper(options: AetherEventMapperOptions): Aether ...base({ eventId: `aether:${taskId}:stream:${canonicalId}:${counter}`, createdAt, - wireTurnId: event.turnId, + wireTurnId, itemId: canonicalId, }), // NEVER assistant_text: remapping thinking into the assistant @@ -1017,49 +1140,71 @@ export function makeAetherEventMapper(options: AetherEventMapperOptions): Aether case "stream.complete": return []; - case "assistant_message.completed": + case "assistant_message.completed": { + const wireTurnId = resolveLiveWireTurnId(event.turnId); return [ - ...trackTurn(event.turnId, createdAt), + ...trackTurn(wireTurnId, createdAt), ...messageItemCompleted({ canonicalId: canonicalMessageItemId(event.messageId), itemType: "assistant_message", content: event.payload.content, - wireTurnId: event.turnId, + wireTurnId, createdAt, }), ]; + } - case "thinking.completed": + case "thinking.completed": { + const wireTurnId = resolveLiveWireTurnId(event.turnId); return [ - ...trackTurn(event.turnId, createdAt), + ...trackTurn(wireTurnId, createdAt), ...messageItemCompleted({ canonicalId: canonicalThinkingItemId(event.messageId), itemType: "reasoning", content: event.payload.content, - wireTurnId: event.turnId, + wireTurnId, createdAt, }), ]; + } - case "turn.completed": + case "turn.completed": { + const wireTurnId = resolveLiveWireTurnId(event.turnId); + // Durable-authoritative settlement: a grounded turn is settled ONLY by + // the durable reconcile observing the task-status flip. The live + // random-id frame just attributes ownership here; the adapter uses it + // as a trigger to fire the durable reconcile immediately (low latency). + if (durableTurns.has(wireTurnId)) { + return trackTurn(wireTurnId, createdAt); + } + // Cold mapper-only path (no durable turn ever grounded): the live + // settle is the only terminator, so keep it. return [ - ...trackTurn(event.turnId, createdAt), - ...settleTurn({ wireTurnId: event.turnId, state: "completed", createdAt }), + ...trackTurn(wireTurnId, createdAt), + ...settleTurn({ wireTurnId, state: "completed", createdAt }), ]; + } case "turn.failed": { - if (interruptedTurns.has(event.turnId)) { + const wireTurnId = resolveLiveWireTurnId(event.turnId); + // Grounded: the durable reconcileTask "errored" path owns both the + // failed settle AND the runtime.error card. A live turn.failed that + // does not actually error the task must not fabricate an error card. + if (durableTurns.has(wireTurnId)) { + return trackTurn(wireTurnId, createdAt); + } + if (interruptedTurns.has(wireTurnId)) { // A stop often surfaces remotely as a failed turn; the user asked // for it, so no error card — just the interrupted settle. return [ - ...trackTurn(event.turnId, createdAt), - ...settleTurn({ wireTurnId: event.turnId, state: "failed", createdAt }), + ...trackTurn(wireTurnId, createdAt), + ...settleTurn({ wireTurnId, state: "failed", createdAt }), ]; } return [ - ...trackTurn(event.turnId, createdAt), + ...trackTurn(wireTurnId, createdAt), ...settleTurn({ - wireTurnId: event.turnId, + wireTurnId, state: "failed", errorMessage: event.payload.errorMessage, createdAt, @@ -1071,7 +1216,7 @@ export function makeAetherEventMapper(options: AetherEventMapperOptions): Aether // cleared it, which would wedge the session on a settled turn and // make the conflict guard drop every later turn.completed. ...base({ - eventId: `aether:${taskId}:turn:${event.turnId}:error`, + eventId: `aether:${taskId}:turn:${wireTurnId}:error`, createdAt, }), type: "runtime.error", @@ -1084,9 +1229,17 @@ export function makeAetherEventMapper(options: AetherEventMapperOptions): Aether // An awaiting_input IS a settle: the remote turn ended and parked on // a pending input. Dispatch on payload.toolName (the live shape has // no `kind` — spec resolved note 12). + const wireTurnId = resolveLiveWireTurnId(event.turnId); + // Grounded: the durable reconcileTask "awaiting_input" branch owns + // both the settle AND re-surfacing the pending input (deduped by + // settledTurns/requestedInputs). The adapter's immediate reconcile + // trigger keeps the question/plan prompt latency low. + if (durableTurns.has(wireTurnId)) { + return trackTurn(wireTurnId, createdAt); + } const settle = [ - ...trackTurn(event.turnId, createdAt), - ...settleTurn({ wireTurnId: event.turnId, state: "completed", createdAt }), + ...trackTurn(wireTurnId, createdAt), + ...settleTurn({ wireTurnId, state: "completed", createdAt }), ]; switch (event.payload.toolName) { case "ask_user": @@ -1096,7 +1249,7 @@ export function makeAetherEventMapper(options: AetherEventMapperOptions): Aether pendingId: event.pendingInputId, toolName: "ask_user", payload: event.payload.input, - wireTurnId: event.turnId, + wireTurnId, createdAt, }), ]; @@ -1107,7 +1260,7 @@ export function makeAetherEventMapper(options: AetherEventMapperOptions): Aether pendingId: event.pendingInputId, toolName: "propose_plan", payload: event.payload.input, - wireTurnId: event.turnId, + wireTurnId, createdAt, }), ]; @@ -1330,6 +1483,9 @@ export function makeAetherEventMapper(options: AetherEventMapperOptions): Aether // delta). A cold mapper with none of the three leaves rows unowned — // attribution would be a guess. const activeWireTurnIdForRows = delta.activeProcessingTurn?.messageId; + // Both durable turn-identity sources — an opening user row and + // activeProcessingTurn — ground the live→durable alias resolver. + noteDurableTurn(activeWireTurnIdForRows); const isTurnOpener = (row: (typeof rows)[number]): boolean => row.role === "user" && row.deliveryStatus !== "queued" && row.deliveryStatus !== "cancelled"; let runningWireTurnId = activeWireTurnId; @@ -1352,6 +1508,7 @@ export function makeAetherEventMapper(options: AetherEventMapperOptions): Aether // resume to an already-awaiting task. if (isTurnOpener(row)) { runningWireTurnId = row.id; + noteDurableTurn(runningWireTurnId); events.push(...trackTurn(runningWireTurnId, createdAt)); } continue; @@ -1428,7 +1585,13 @@ export function makeAetherEventMapper(options: AetherEventMapperOptions): Aether reconcileTask, latestSequence: () => lastSequence, activeWireTurnId: () => activeWireTurnId, - noteTurnStarted: (wireTurnId, nowIso) => trackTurn(wireTurnId, stamp(undefined, nowIso)), + isOwnLiveTurnId, + noteTurnStarted: (wireTurnId, nowIso) => { + // A driver-initiated turn: the durable turn identity every subsequent + // live frame's random per-dispatch id must resolve to. + noteDurableTurn(wireTurnId); + return trackTurn(wireTurnId, stamp(undefined, nowIso)); + }, markInterrupted: (wireTurnId) => { interruptedTurns.add(wireTurnId); }, From e4dcd25476cf77bdde6be6fd68cddf1a8653338a Mon Sep 17 00:00:00 2001 From: Pranav Sharan Date: Sat, 8 Aug 2026 21:41:43 -0700 Subject: [PATCH 09/44] fix(aether): skip clean-tree preflight for driver-owned worktrees (#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(aether): skip the clean-tree preflight for driver-owned worktrees An Aether turn leaves its mirror output as uncommitted working-tree content (so t3's diff/checkpoint panel shows it), but startSession's clean-tree preflight then refuses the next thread on that checkout — even a chat message trips it. When a thread runs in its own worktree, that friction is pointless: a `git worktree add` branch has no upstream and (after turn 1) a dirty tree, yet the mirror owns it exclusively and resets --hard to baseRef every sync, so there is no user work to protect. The orchestration layer (the authority on worktree ownership) now sets managedWorktree=true on ProviderSessionStartInput when thread.worktreePath is non-null; the Aether adapter then uses a structural-only preflight (is-repo + non-detached) instead of the clean-tree/upstream checks. The shared "Current checkout" path is unchanged — it still refuses on uncommitted work, protecting real edits. Both fresh and resume start paths flow through the shared helper. Regression tests: a dirty, no-upstream, ahead managed worktree starts ready; "Current checkout" with uncommitted changes still refuses; the worktree cwd (not the project root) is what registers with the mirror. Follow-ups (not blockers): default Aether threads to a fresh worktree in the composer (primary-agent + live integration test), worktree cleanup on thread archive, mobile default. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * fix(aether): gate managedWorktree on a bootstrap-created marker, not path shape Review: managedWorktree = (worktreePath !== null) also matched a user's pre-existing secondary worktree, whose uncommitted edits the mirror would then reset --hard/clean — data loss. Now the bootstrap prepareWorktree handler stamps a durable worktreeManaged=true on the thread when it creates a fresh ephemeral worktree; that marker is plumbed through the projection (decider → projector → ProjectionThreads + migration 039) to the read model, and the reactor sets managedWorktree only from it. A user-attached worktree has no marker → the clean-tree preflight is enforced and their work is protected. Regression test: worktreePath set but unmanaged + dirty still refuses; bootstrap-managed still skips. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * fix(aether): make worktreeManaged server-authoritative and sticky Review round 2 on T10. Two holes from making the marker a client-writable, clearable meta field: (1) a client could smuggle worktreeManaged:true onto a thread.meta.update and skip the dirty-tree preflight on its own worktree; (2) the first-turn branch rename (a meta update omitting the field) cleared it off a genuinely-managed worktree. Fix: worktreeManaged is dropped from the client ThreadMetaUpdateCommand entirely and set only via a dedicated server-origin ThreadWorktreeAttachManagedCommand the bootstrap emits, so no client input can set it; and the projection preserves it across meta updates that keep the same worktree, resetting only when the worktree path changes. Tests: a smuggled client worktreeManaged is ignored; the first-turn branch rename keeps the marker. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h * test: update server-seam bootstrap sequences for the attach-managed command The bootstrap now dispatches thread.worktree.attach-managed (server-only) in place of the thread.meta.update it used to emit for the worktree marker, so the three server.test.ts command-sequence assertions are updated to match. Behavior unchanged; the new command still carries worktreePath. (Missed initially because the scoped test run excluded src/server.test.ts — full vp run test is green: 2158 passed.) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017YBSC5kYcomzhv5TJ1cn1h --------- Co-authored-by: Claude Fable 5 --- .../Layers/ProjectionPipeline.ts | 11 +- .../Layers/ProjectionSnapshotQuery.test.ts | 2 + .../Layers/ProjectionSnapshotQuery.ts | 10 + .../Layers/ProviderCommandReactor.test.ts | 268 ++++++++++++++++++ .../Layers/ProviderCommandReactor.ts | 9 + apps/server/src/orchestration/decider.ts | 44 ++- .../src/orchestration/projector.test.ts | 1 + apps/server/src/orchestration/projector.ts | 13 +- .../Layers/ProjectionRepositories.test.ts | 2 + .../persistence/Layers/ProjectionThreads.ts | 5 + apps/server/src/persistence/Migrations.ts | 2 + .../039_ProjectionThreadsWorktreeManaged.ts | 18 ++ .../persistence/Services/ProjectionThreads.ts | 3 + .../src/provider/Layers/AetherAdapter.test.ts | 103 ++++++- .../src/provider/Layers/AetherAdapter.ts | 38 ++- apps/server/src/server.test.ts | 16 +- apps/server/src/ws.ts | 9 +- packages/contracts/src/orchestration.test.ts | 52 ++++ packages/contracts/src/orchestration.ts | 36 +++ packages/contracts/src/provider.ts | 6 + 20 files changed, 633 insertions(+), 15 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/039_ProjectionThreadsWorktreeManaged.ts diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 38a70240d973..2cc369c253b8 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -603,6 +603,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti interactionMode: event.payload.interactionMode, branch: event.payload.branch, worktreePath: event.payload.worktreePath, + // A worktree named at thread creation is one the user already had; + // only the bootstrap's prepareWorktree marks its own via meta. + worktreeManaged: 0, latestTurnId: null, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, @@ -788,8 +791,14 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ? { modelSelection: event.payload.modelSelection } : {}), ...(event.payload.branch !== undefined ? { branch: event.payload.branch } : {}), + // The marker travels with the path it describes, already resolved + // by the decider (which is the only reader that knows the previous + // path), so it is applied verbatim rather than re-derived here. ...(event.payload.worktreePath !== undefined - ? { worktreePath: event.payload.worktreePath } + ? { + worktreePath: event.payload.worktreePath, + worktreeManaged: event.payload.worktreeManaged === true ? 1 : 0, + } : {}), updatedAt: event.payload.updatedAt, }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index c89124751b56..cf7e593adc4a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -302,6 +302,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runtimeMode: "full-access", branch: null, worktreePath: null, + worktreeManaged: false, latestTurn: { turnId: asTurnId("turn-1"), state: "completed", @@ -419,6 +420,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runtimeMode: "full-access", branch: null, worktreePath: null, + worktreeManaged: false, latestTurn: { turnId: asTurnId("turn-1"), state: "completed", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index e744574a73cc..fbd0c67acead 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -414,6 +414,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + worktree_managed AS "worktreeManaged", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -450,6 +451,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + worktree_managed AS "worktreeManaged", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -488,6 +490,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + worktree_managed AS "worktreeManaged", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -926,6 +929,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + worktree_managed AS "worktreeManaged", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -1557,6 +1561,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + worktreeManaged: row.worktreeManaged > 0, latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -1762,6 +1767,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + worktreeManaged: row.worktreeManaged > 0, latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -1898,6 +1904,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + worktreeManaged: row.worktreeManaged > 0, latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -2043,6 +2050,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + worktreeManaged: row.worktreeManaged > 0, latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -2320,6 +2328,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, + worktreeManaged: threadRow.value.worktreeManaged > 0, latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, @@ -2441,6 +2450,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, + worktreeManaged: threadRow.value.worktreeManaged > 0, latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 2b4d3771605a..54b6f00892b5 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -5,6 +5,7 @@ import * as NodePath from "node:path"; import { ModelSelection, + type OrchestrationCommand, ProviderRuntimeEvent, ProviderSession, ProviderDriverKind, @@ -1510,6 +1511,273 @@ describe("ProviderCommandReactor", () => { expect(harness.refreshStatus.mock.calls[0]?.[0]).toBe("/tmp/provider-project-worktree"); }); + it("marks the session managedWorktree for a bootstrap-created worktree", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.worktree.attach-managed", + commandId: CommandId.make("cmd-thread-worktree-managed"), + threadId: ThreadId.make("thread-1"), + branch: "feature/bootstrap-worktree", + worktreePath: "/tmp/provider-project-worktree", + }), + ); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-managed"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-managed"), + role: "user", + text: "hello managed worktree", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + cwd: "/tmp/provider-project-worktree", + managedWorktree: true, + }); + }); + + it("does not mark the session managedWorktree when the worktree path equals the workspace root", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + // A local-mode thread seeded with worktreePath == workspaceRoot resolves to + // the shared checkout as its cwd. Skipping the clean-tree preflight there + // would clobber the user's uncommitted work, so managedWorktree must stay + // unset even though worktreePath is non-null. + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-worktree-shared"), + threadId: ThreadId.make("thread-1"), + worktreePath: "/tmp/provider-project", + }), + ); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-shared"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-shared"), + role: "user", + text: "hello shared checkout", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + const startInput = harness.startSession.mock.calls[0]?.[1] as Record; + expect(startInput.cwd).toBe("/tmp/provider-project"); + expect(startInput.managedWorktree).toBeUndefined(); + }); + + it("does not mark the session managedWorktree for a worktree the user already had", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + // Picking a branch that is already checked out in one of the user's own + // worktrees points the thread at that path — outside the workspace root, + // but full of work they have not committed. Without the bootstrap marker + // the clean-tree preflight has to stay on. + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-worktree-user"), + threadId: ThreadId.make("thread-1"), + branch: "feature/user-branch", + worktreePath: "/tmp/user-worktrees/feature-user-branch", + }), + ); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-user-worktree"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-user-worktree"), + role: "user", + text: "hello user worktree", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + const startInput = harness.startSession.mock.calls[0]?.[1] as Record; + expect(startInput.cwd).toBe("/tmp/user-worktrees/feature-user-branch"); + expect(startInput.managedWorktree).toBeUndefined(); + }); + + it("clears the managed marker when the thread moves to a worktree the user already had", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.worktree.attach-managed", + commandId: CommandId.make("cmd-thread-worktree-bootstrap"), + threadId: ThreadId.make("thread-1"), + branch: "feature/bootstrap-worktree", + worktreePath: "/tmp/provider-project-worktree", + }), + ); + + // Re-pointing the thread carries no marker, so the previous one must not + // survive onto a path the driver does not own. + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-worktree-reattach"), + threadId: ThreadId.make("thread-1"), + worktreePath: "/tmp/user-worktrees/feature-reattached", + }), + ); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-reattached"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-reattached"), + role: "user", + text: "hello reattached worktree", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + const startInput = harness.startSession.mock.calls[0]?.[1] as Record; + expect(startInput.cwd).toBe("/tmp/user-worktrees/feature-reattached"); + expect(startInput.managedWorktree).toBeUndefined(); + }); + + it("keeps the managed marker when the first turn renames the worktree branch", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.worktree.attach-managed", + commandId: CommandId.make("cmd-thread-worktree-rename-bootstrap"), + threadId: ThreadId.make("thread-1"), + branch: "t3code/1234abcd", + worktreePath: "/tmp/provider-project-worktree", + }), + ); + + harness.generateBranchName.mockReturnValue(Effect.succeed({ branch: "feature/generated" })); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-rename-managed"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-rename-managed"), + role: "user", + text: "hello renamed worktree", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + // The rename repoints the thread at the same worktree under a new branch. + // That is a rename, not a re-attach, so the bootstrap's marker has to + // survive it — otherwise the driver's clean-tree preflight comes back on a + // worktree it owns and refuses the very first turn. + await waitFor(() => harness.renameBranch.mock.calls.length === 1); + await waitFor(async () => { + const readModel = await harness.readModel(); + return ( + readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"))?.branch === + "t3code/feature/generated" + ); + }); + + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.worktreePath).toBe("/tmp/provider-project-worktree"); + expect(thread?.worktreeManaged).toBe(true); + }); + + it("ignores a worktreeManaged field smuggled onto a thread.meta.update", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + + // The client schema has no worktreeManaged, so this shape cannot survive + // the RPC boundary. The cast checks the layer behind it: even handed the + // field directly, the decider derives the marker from the thread it already + // has, never from command input. + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-thread-worktree-smuggled"), + threadId: ThreadId.make("thread-1"), + worktreePath: "/tmp/user-worktrees/feature-smuggled", + worktreeManaged: true, + } as unknown as OrchestrationCommand), + ); + + const thread = (await harness.readModel()).threads.find( + (entry) => entry.id === ThreadId.make("thread-1"), + ); + expect(thread?.worktreePath).toBe("/tmp/user-worktrees/feature-smuggled"); + expect(thread?.worktreeManaged).toBe(false); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-smuggled"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-smuggled"), + role: "user", + text: "hello smuggled marker", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + const startInput = harness.startSession.mock.calls[0]?.[1] as Record; + expect(startInput.cwd).toBe("/tmp/user-worktrees/feature-smuggled"); + expect(startInput.managedWorktree).toBeUndefined(); + }); + it("forwards codex model options through session start and turn send", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index ff639797179f..ad7785869f41 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -626,6 +626,15 @@ const make = Effect.gen(function* () { ...(preferredProvider ? { provider: preferredProvider } : {}), providerInstanceId: desiredInstanceId, ...(effectiveCwd ? { cwd: effectiveCwd } : {}), + // `managedWorktree` tells the Aether adapter to skip its clean-tree + // preflight, so it must be true ONLY for a worktree the driver owns — + // that preflight is what stops the mirror from resetting away work the + // user has not committed. Path shape cannot decide this: a thread also + // points at the project checkout (local mode) or at a worktree the user + // already had (picking a branch that is checked out elsewhere), and + // both can be dirty. Only the bootstrap that created the worktree knows + // it is driver-owned, and it says so with `worktreeManaged`. + ...(thread.worktreeManaged === true ? { managedWorktree: true } : {}), modelSelection: desiredModelSelection, ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), runtimeMode: desiredRuntimeMode, diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 3de2592c884f..6a26a53f2b67 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -829,7 +829,49 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ? { modelSelection: command.modelSelection } : {}), ...(branch !== undefined ? { branch } : {}), - ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), + ...(command.worktreePath !== undefined + ? { + worktreePath: command.worktreePath, + // The marker describes a worktree, not an update. An update + // that keeps the thread on the same worktree — the first-turn + // branch rename is one — carries it forward; repointing the + // thread elsewhere (or clearing the path) drops it, because a + // worktree this server did not create is the user's and keeps + // its clean-tree guards until a bootstrap claims it. + worktreeManaged: + command.worktreePath !== null && + command.worktreePath === thread.worktreePath && + thread.worktreeManaged === true, + } + : {}), + updatedAt: occurredAt, + }, + }; + } + + case "thread.worktree.attach-managed": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + // Reuses thread.meta-updated so the marker rides the same event the + // branch and path already travel on: one event, one row write, and no + // window where the thread points at the worktree unmarked. + type: "thread.meta-updated", + payload: { + threadId: command.threadId, + branch: command.branch, + worktreePath: command.worktreePath, + worktreeManaged: true, updatedAt: occurredAt, }, }; diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 9c07a312023c..afae147dee1c 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -85,6 +85,7 @@ describe("orchestration projector", () => { interactionMode: "default", branch: null, worktreePath: null, + worktreeManaged: false, latestTurn: null, createdAt: now, updatedAt: now, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 5acf3ee6968e..d6529cf635a9 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -289,6 +289,9 @@ export function projectEvent( interactionMode: payload.interactionMode, branch: payload.branch, worktreePath: payload.worktreePath, + // A worktree named at thread creation is one the user already had; + // only the bootstrap's prepareWorktree marks its own via meta. + worktreeManaged: false, latestTurn: null, createdAt: payload.createdAt, updatedAt: payload.updatedAt, @@ -447,7 +450,15 @@ export function projectEvent( ? { modelSelection: payload.modelSelection } : {}), ...(payload.branch !== undefined ? { branch: payload.branch } : {}), - ...(payload.worktreePath !== undefined ? { worktreePath: payload.worktreePath } : {}), + // The marker travels with the path it describes, already resolved + // by the decider (which is the only reader that knows the previous + // path), so it is applied verbatim rather than re-derived here. + ...(payload.worktreePath !== undefined + ? { + worktreePath: payload.worktreePath, + worktreeManaged: payload.worktreeManaged === true, + } + : {}), updatedAt: payload.updatedAt, }), })), diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 71d7df566fd2..ef45f584278e 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -87,6 +87,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { interactionMode: "default", branch: null, worktreePath: null, + worktreeManaged: 0, latestTurnId: null, createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", @@ -150,6 +151,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { interactionMode: "default", branch: null, worktreePath: null, + worktreeManaged: 0, latestTurnId: null, createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-25T00:00:00.000Z", diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index b7d8ae137473..137464b78d06 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -39,6 +39,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode, branch, worktree_path, + worktree_managed, latest_turn_id, created_at, updated_at, @@ -66,6 +67,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.interactionMode}, ${row.branch}, ${row.worktreePath}, + ${row.worktreeManaged}, ${row.latestTurnId}, ${row.createdAt}, ${row.updatedAt}, @@ -93,6 +95,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode = excluded.interaction_mode, branch = excluded.branch, worktree_path = excluded.worktree_path, + worktree_managed = excluded.worktree_managed, latest_turn_id = excluded.latest_turn_id, created_at = excluded.created_at, updated_at = excluded.updated_at, @@ -127,6 +130,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + worktree_managed AS "worktreeManaged", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -163,6 +167,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + worktree_managed AS "worktreeManaged", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 733c52fab3e1..08f400c4c83d 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -51,6 +51,7 @@ import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts import Migration0036 from "./Migrations/036_ProjectionThreadsPinned.ts"; import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; +import Migration0039 from "./Migrations/039_ProjectionThreadsWorktreeManaged.ts"; /** * Migration loader with all migrations defined inline. @@ -101,6 +102,7 @@ export const migrationEntries = [ [36, "ProjectionThreadsPinned", Migration0036], [37, "ProjectionTurnsKeysetIndex", Migration0037], [38, "ProjectionThreadsPinOrderKey", Migration0038], + [39, "ProjectionThreadsWorktreeManaged", Migration0039], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/039_ProjectionThreadsWorktreeManaged.ts b/apps/server/src/persistence/Migrations/039_ProjectionThreadsWorktreeManaged.ts new file mode 100644 index 000000000000..c073c09cc076 --- /dev/null +++ b/apps/server/src/persistence/Migrations/039_ProjectionThreadsWorktreeManaged.ts @@ -0,0 +1,18 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + // Threads that predate the marker keep 0: a worktree whose provenance is + // unknown is treated as the user's, so driver clean-tree guards stay on. + if (!columns.some((column) => column.name === "worktree_managed")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN worktree_managed INTEGER NOT NULL DEFAULT 0 + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index c572e1d11ccd..c1abc762c21c 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -33,6 +33,9 @@ export const ProjectionThread = Schema.Struct({ interactionMode: ProviderInteractionMode, branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), + /** 0/1: was this worktree created by the thread bootstrap? See the + OrchestrationThread contract for why a user's worktree never is. */ + worktreeManaged: NonNegativeInt, latestTurnId: Schema.NullOr(TurnId), createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/apps/server/src/provider/Layers/AetherAdapter.test.ts b/apps/server/src/provider/Layers/AetherAdapter.test.ts index ab69932f3744..bc6fbd8e73b9 100644 --- a/apps/server/src/provider/Layers/AetherAdapter.test.ts +++ b/apps/server/src/provider/Layers/AetherAdapter.test.ts @@ -175,12 +175,17 @@ const startInput = (overrides?: { readonly resumeCursor?: unknown; readonly modelSelection?: { readonly instanceId: ProviderInstanceId; readonly model: string }; readonly threadId?: ThreadId; + readonly cwd?: string; + readonly managedWorktree?: boolean; }) => ({ threadId: overrides?.threadId ?? ThreadId.make("thread-1"), - cwd: "/repo", + cwd: overrides?.cwd ?? "/repo", runtimeMode: "full-access" as const, ...(overrides?.resumeCursor !== undefined ? { resumeCursor: overrides.resumeCursor } : {}), ...(overrides?.modelSelection !== undefined ? { modelSelection: overrides.modelSelection } : {}), + ...(overrides?.managedWorktree !== undefined + ? { managedWorktree: overrides.managedWorktree } + : {}), }); const withAdapter = ( @@ -354,6 +359,102 @@ describe("AetherAdapter startSession", () => { }), ); + // T10: a driver-owned per-thread worktree (managedWorktree) is created clean + // from origin/{base} and only the driver writes to it, so the clean-tree / + // pushed / in-sync preflight is skipped there. + it.effect( + "skips the clean-tree preflight for a managed worktree even when it is dirty and has no upstream", + () => + withAdapter( + { + // The worktree's temp branch is dirty (mirror output from a prior + // turn) and has no upstream (git worktree add -b makes a local-only + // branch) — both would fail the shared-checkout preflight. + git: gitWith({ + ...cleanStatus, + hasWorkingTreeChanges: true, + hasUpstream: false, + upstreamRef: null, + aheadCount: 4, + }), + restClient: { ...unusedRestClient, listProjects: () => Effect.succeed([project()]) }, + }, + (adapter) => + Effect.gen(function* () { + const session = yield* adapter.startSession( + startInput({ cwd: "/worktrees/thread-1", managedWorktree: true }), + ); + expect(session.status).toBe("ready"); + expect(session.cwd).toBe("/worktrees/thread-1"); + }), + ), + ); + + it.effect( + "still refuses the shared 'Current checkout' with uncommitted changes when not a managed worktree", + () => + Effect.gen(function* () { + const error = yield* withAdapter( + { + git: gitWith({ ...cleanStatus, hasWorkingTreeChanges: true }), + restClient: { ...unusedRestClient, listProjects: () => Effect.succeed([project()]) }, + }, + (adapter) => + Effect.flip(adapter.startSession(startInput({ cwd: "/repo", managedWorktree: false }))), + ); + expect(error._tag).toBe("ProviderAdapterValidationError"); + expect(error.message).toContain("Commit or stash"); + }), + ); + + it.effect( + "still refuses a dirty worktree the user already had, since it carries no managed marker", + () => + Effect.gen(function* () { + const error = yield* withAdapter( + { + git: gitWith({ ...cleanStatus, hasWorkingTreeChanges: true }), + restClient: { ...unusedRestClient, listProjects: () => Effect.succeed([project()]) }, + }, + (adapter) => + // A secondary worktree of the user's own looks exactly like a + // driver-owned one from the path alone, so only the absent marker + // separates them — and it must keep their work safe. + Effect.flip(adapter.startSession(startInput({ cwd: "/worktrees/user-branch" }))), + ); + expect(error._tag).toBe("ProviderAdapterValidationError"); + expect(error.message).toContain("Commit or stash"); + }), + ); + + it.effect("registers the worktree cwd as the mirror target, not the shared checkout", () => { + const registeredCwds: Array = []; + return withAdapter( + { + git: gitWith({ ...cleanStatus, hasUpstream: false, upstreamRef: null }), + restClient: { ...unusedRestClient, listProjects: () => Effect.succeed([project()]) }, + mirrorRegistry: { + register: (cwd) => + Effect.sync(() => { + registeredCwds.push(cwd); + }), + deregister: () => Effect.void, + }, + }, + (adapter) => + Effect.gen(function* () { + const session = yield* adapter.startSession( + startInput({ cwd: "/worktrees/thread-1", managedWorktree: true }), + ); + // The mirror re-baselines and applies diffs against the session + // cwd; registering the worktree path (never "/repo") is proof the + // mirror targets the isolated worktree, not the shared checkout. + expect(session.cwd).toBe("/worktrees/thread-1"); + expect(registeredCwds).toEqual(["/worktrees/thread-1"]); + }), + ); + }); + it.effect("matches an ssh local origin against an https project repo_url", () => withAdapter( { diff --git a/apps/server/src/provider/Layers/AetherAdapter.ts b/apps/server/src/provider/Layers/AetherAdapter.ts index 4b29f186348f..04b022e3fee0 100644 --- a/apps/server/src/provider/Layers/AetherAdapter.ts +++ b/apps/server/src/provider/Layers/AetherAdapter.ts @@ -616,6 +616,23 @@ function resumePreflightIssue(status: GitStatusDetails, cwd: string): string | u return undefined; } +/** + * Structural-only preflight for a driver-owned, per-thread worktree: it is + * created clean from origin/{base} and only the driver writes to it, so the + * clean-tree/pushed/in-sync checks do not apply (its temp branch has no + * upstream by design). Only the structural checks that the mirror engine + * itself relies on remain. + */ +function managedWorktreePreflightIssue(status: GitStatusDetails, cwd: string): string | undefined { + if (!status.isRepo) { + return `'${cwd}' is not a git repository. Aether cloud tasks need a git checkout of the linked repository.`; + } + if (status.branch === null) { + return "The Aether worktree is on a detached HEAD. This should not happen for a managed worktree."; + } + return undefined; +} + /** Snapshot item for a timeline row — minimal, per t3's opaque snapshot type. */ function snapshotItemFromMessage(row: AetherTimelineMessage): unknown { if (row.role === "user") { @@ -1438,17 +1455,26 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( }); } - // (1) Mirror preflight. A FRESH thread requires a clean tree on a - // pushed, in-sync branch — the mirror's base state. A RESUMED thread's - // mirror is dirty BY DESIGN (it holds the applied cumulative diff), so - // only the structural checks apply; content integrity is enforced by the - // sync engine's fingerprint verify instead. + // (1) Mirror preflight. A FRESH thread on the shared "Current checkout" + // requires a clean tree on a pushed, in-sync branch — the mirror's base + // state — because that mode can clobber the user's uncommitted work. A + // RESUMED thread's mirror is dirty BY DESIGN (it holds the applied + // cumulative diff), so only the structural checks apply; content integrity + // is enforced by the sync engine's fingerprint verify instead. A driver- + // owned per-thread worktree (input.managedWorktree) is created clean from + // origin/{base} and only the driver writes to it, so the clean-tree checks + // are unnecessary there and are skipped. const resume = parseAetherResume(input.resumeCursor); const isResume = resume !== undefined; const status = yield* options.git .statusDetails(cwd) .pipe(Effect.mapError(toGitRequestError("startSession"))); - const issue = isResume ? resumePreflightIssue(status, cwd) : preflightIssue(status, cwd); + const issue = + input.managedWorktree === true + ? managedWorktreePreflightIssue(status, cwd) + : isResume + ? resumePreflightIssue(status, cwd) + : preflightIssue(status, cwd); if (issue !== undefined) { return yield* new ProviderAdapterValidationError({ provider: PROVIDER, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index ad82fc37eadb..fd402a84e03f 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -7320,7 +7320,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { dispatchedCommands.map((command) => command.type), [ "thread.create", - "thread.meta.update", + "thread.worktree.attach-managed", "thread.activity.append", "thread.activity.append", "thread.turn.start", @@ -7566,7 +7566,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(response.sequence, 4); assert.deepEqual( dispatchedCommands.map((command) => command.type), - ["thread.create", "thread.meta.update", "thread.activity.append", "thread.turn.start"], + [ + "thread.create", + "thread.worktree.attach-managed", + "thread.activity.append", + "thread.turn.start", + ], ); const setupFailureActivity = dispatchedCommands.find( (command): command is Extract => @@ -7687,7 +7692,12 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assert.equal(response.sequence, 4); assert.deepEqual( dispatchedCommands.map((command) => command.type), - ["thread.create", "thread.meta.update", "thread.activity.append", "thread.turn.start"], + [ + "thread.create", + "thread.worktree.attach-managed", + "thread.activity.append", + "thread.turn.start", + ], ); const setupActivities = dispatchedCommands.filter( (command): command is Extract => diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index a040affd69d5..6a877b30104d 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -967,9 +967,14 @@ const makeWsRpcLayer = ( path: null, }); targetWorktreePath = worktree.worktree.path; + // This is the one place an ephemeral per-thread worktree is + // created, and attach-managed is the only command that marks one. + // The marker is what later lets drivers treat the worktree as + // theirs; every other worktree a thread can point at is the + // user's and keeps its clean-tree guards. yield* orchestrationEngine.dispatch({ - type: "thread.meta.update", - commandId: yield* serverCommandId("bootstrap-thread-meta-update"), + type: "thread.worktree.attach-managed", + commandId: yield* serverCommandId("bootstrap-thread-worktree-attach"), threadId: command.threadId, branch: worktree.worktree.refName, worktreePath: targetWorktreePath, diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index ecf7afa06105..ca199255d7f6 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { + ClientOrchestrationCommand, DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, ModelSelection, @@ -51,6 +52,7 @@ function getOptionValue( } const decodeThreadCreatedPayload = Schema.decodeUnknownEffect(ThreadCreatedPayload); const decodeOrchestrationCommand = Schema.decodeUnknownEffect(OrchestrationCommand); +const decodeClientOrchestrationCommand = Schema.decodeUnknownEffect(ClientOrchestrationCommand); const decodeOrchestrationEvent = Schema.decodeUnknownEffect(OrchestrationEvent); const decodeThreadMetaUpdatedPayload = Schema.decodeUnknownEffect(ThreadMetaUpdatedPayload); @@ -683,6 +685,56 @@ it.effect("rejects an explicit title combined with title regeneration", () => }), ); +// The managed-worktree marker makes drivers skip their clean-tree preflight, so +// a client that could set it could point a thread at the user's own worktree and +// have the driver reset away uncommitted work. The client boundary is where that +// is stopped: the field is not on the client command, and the command that does +// carry it is not dispatchable. +it.effect("drops a worktreeManaged field smuggled into a client thread.meta.update", () => + Effect.gen(function* () { + const parsed = yield* decodeClientOrchestrationCommand({ + type: "thread.meta.update", + commandId: "cmd-worktree-managed-spoof", + threadId: "thread-1", + worktreePath: "/home/user/my-worktree", + worktreeManaged: true, + }); + assert.strictEqual(parsed.type, "thread.meta.update"); + assert.ok(!("worktreeManaged" in parsed)); + }), +); + +it.effect("rejects thread.worktree.attach-managed dispatched by a client", () => + Effect.gen(function* () { + const result = yield* Effect.exit( + decodeClientOrchestrationCommand({ + type: "thread.worktree.attach-managed", + commandId: "cmd-worktree-attach-spoof", + threadId: "thread-1", + branch: "t3code/1234abcd", + worktreePath: "/home/user/my-worktree", + }), + ); + assert.strictEqual(result._tag, "Failure"); + }), +); + +it.effect("accepts thread.worktree.attach-managed from the server", () => + Effect.gen(function* () { + const parsed = yield* decodeOrchestrationCommand({ + type: "thread.worktree.attach-managed", + commandId: "cmd-worktree-attach", + threadId: "thread-1", + branch: "t3code/1234abcd", + worktreePath: "/tmp/worktrees/thread-1", + }); + assert.strictEqual(parsed.type, "thread.worktree.attach-managed"); + if (parsed.type === "thread.worktree.attach-managed") { + assert.strictEqual(parsed.worktreePath, "/tmp/worktrees/thread-1"); + } + }), +); + it.effect("accepts a source proposed plan reference in thread.turn.start", () => Effect.gen(function* () { const parsed = yield* decodeThreadTurnStartCommand({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 87270d98c1f9..c44621940575 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -361,6 +361,13 @@ export const OrchestrationThread = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + // True only for a worktree the thread bootstrap created for this thread and + // that only the driver writes to. A worktree the user already had (attached + // by picking a branch that is already checked out elsewhere) is never + // managed: it can hold their uncommitted work, so drivers must keep their + // clean-tree guards on it. Optional so payloads from pre-marker servers + // still decode — absent means "not managed", the safe reading. + worktreeManaged: Schema.optional(Schema.Boolean), latestTurn: Schema.NullOr(OrchestrationLatestTurn), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -428,6 +435,8 @@ export const OrchestrationThreadShell = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + // See OrchestrationThread.worktreeManaged. + worktreeManaged: Schema.optional(Schema.Boolean), latestTurn: Schema.NullOr(OrchestrationLatestTurn), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -741,6 +750,9 @@ const ThreadMetaUpdateCommand = Schema.Struct({ branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), expectedBranch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + // Deliberately no worktreeManaged: see ThreadWorktreeAttachManagedCommand. + // The marker is server-authoritative, so it must not be reachable from a + // command a client can dispatch. }).check( Schema.makeFilter( (input) => @@ -992,6 +1004,23 @@ const ThreadRevertCompleteCommand = Schema.Struct({ createdAt: IsoDateTime, }); +// The thread bootstrap claiming the worktree it just created: it points the +// thread at the new worktree and marks it driver-owned in one command, so the +// branch, the path, and the marker can never disagree. +// +// Server-only on purpose. The marker is what makes drivers drop their +// clean-tree guards, so a client that could set it could aim a thread at the +// user's own worktree and have the driver reset away uncommitted work. This +// command is absent from ClientOrchestrationCommand, so dispatchCommand +// rejects it at the RPC boundary — only in-process server code can send it. +const ThreadWorktreeAttachManagedCommand = Schema.Struct({ + type: Schema.Literal("thread.worktree.attach-managed"), + commandId: CommandId, + threadId: ThreadId, + branch: TrimmedNonEmptyString, + worktreePath: TrimmedNonEmptyString, +}); + const ThreadTitleRegenerationCompleteCommand = Schema.Struct({ type: Schema.Literal("thread.title.regeneration.complete"), commandId: CommandId, @@ -1008,6 +1037,7 @@ const InternalOrchestrationCommand = Schema.Union([ ThreadTurnDiffCompleteCommand, ThreadActivityAppendCommand, ThreadRevertCompleteCommand, + ThreadWorktreeAttachManagedCommand, ThreadTitleRegenerationCompleteCommand, ]); export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; @@ -1174,6 +1204,12 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({ modelSelection: Schema.optional(ModelSelection), branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + /** The resolved marker for the worktreePath in this same payload: see + OrchestrationThread. The decider writes it on every update that carries a + worktreePath — carrying it forward when the path is unchanged, clearing it + when the thread repoints — so readers apply it verbatim and never have to + reason about the previous path themselves. */ + worktreeManaged: Schema.optional(Schema.Boolean), updatedAt: IsoDateTime, }); diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 94fb007a7bc2..914bc5e9dbc2 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -56,6 +56,12 @@ export const ProviderSessionStartInput = Schema.Struct({ // See ProviderSession for the migration story. providerInstanceId: Schema.optional(ProviderInstanceId), cwd: Schema.optional(TrimmedNonEmptyString), + // True when `cwd` is a driver-owned, per-thread worktree (created clean from + // origin/{base}) rather than the shared "Current checkout". The Aether + // adapter uses this to skip its clean-tree/pushed preflight: a managed + // worktree is created clean, only the driver writes to it, and its temp + // branch has no upstream — so the shared-checkout preflight does not apply. + managedWorktree: Schema.optional(Schema.Boolean), modelSelection: Schema.optional(ModelSelection), resumeCursor: Schema.optional(Schema.Unknown), approvalPolicy: Schema.optional(ProviderApprovalPolicy), From 5a35c28a5f6b280aeec7c87d1e305daa1bc640b8 Mon Sep 17 00:00:00 2001 From: Pranav Sharan Date: Sun, 9 Aug 2026 00:04:08 -0700 Subject: [PATCH 10/44] feat(aether): default new Aether threads to a fresh worktree in the composer (#11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh, un-touched local composer draft with an Aether model selected now defaults its Workspace to a new worktree, so the Aether cloud driver never hits the clean-working-tree preflight error. This is a render-time overlay (resolveProviderDefaultsToWorktree) — never persisted — so switching the model to any non-Aether provider flips the Workspace back to the current checkout, and every other provider's default is unchanged. Hardening (from live testing + adversarial review): - The overlay never leaks into persisted draft.envMode: the branch auto-seed path uses the sticky (persisted) mode, not the effective overlay value. - Explicit workspace picks and PR-checkout drafts are marked user-set; legacy drafts (absent flag) are treated as user-set so upgrades never surprise-flip. - The auto-worktree honors the newWorktreesStartFromOrigin preference. --- .../components/BranchToolbar.logic.test.ts | 107 +++++++++++++++++- .../web/src/components/BranchToolbar.logic.ts | 48 +++++++- .../BranchToolbarBranchSelector.tsx | 8 +- apps/web/src/components/ChatView.tsx | 53 ++++++++- apps/web/src/composerDraftStore.test.ts | 77 +++++++++++++ apps/web/src/composerDraftStore.ts | 28 +++++ apps/web/src/hooks/useHandleNewThread.ts | 16 ++- 7 files changed, 323 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.logic.test.ts b/apps/web/src/components/BranchToolbar.logic.test.ts index 36d42a60fa81..9811d414424a 100644 --- a/apps/web/src/components/BranchToolbar.logic.test.ts +++ b/apps/web/src/components/BranchToolbar.logic.test.ts @@ -16,6 +16,7 @@ import { resolveLocalCheckoutBranchMismatch, resolvePreviousWorktreeLabel, resolvePreviousWorktreeSeed, + resolveProviderDefaultsToWorktree, shouldIncludeBranchPickerItem, shouldShowComposerContextStrip, shouldShowEnvironmentIndicator, @@ -115,7 +116,7 @@ describe("resolveDraftEnvModeAfterBranchChange", () => { resolveDraftEnvModeAfterBranchChange({ nextWorktreePath: null, currentWorktreePath: "/repo/.t3/worktrees/feature-a", - effectiveEnvMode: "worktree", + stickyEnvMode: "worktree", }), ).toBe("local"); }); @@ -125,7 +126,7 @@ describe("resolveDraftEnvModeAfterBranchChange", () => { resolveDraftEnvModeAfterBranchChange({ nextWorktreePath: null, currentWorktreePath: null, - effectiveEnvMode: "worktree", + stickyEnvMode: "worktree", }), ).toBe("worktree"); }); @@ -135,10 +136,24 @@ describe("resolveDraftEnvModeAfterBranchChange", () => { resolveDraftEnvModeAfterBranchChange({ nextWorktreePath: "/repo/.t3/worktrees/feature-a", currentWorktreePath: null, - effectiveEnvMode: "local", + stickyEnvMode: "local", }), ).toBe("worktree"); }); + + it("does not persist a worktree overlay: a sticky-local draft stays local after a base-ref change", () => { + // Regression: the Aether provider default makes the *effective* mode + // "worktree" without changing the persisted (sticky) mode. Seeding the base + // branch must not bake that overlay into persistence, or switching to a + // non-worktree provider could never flip the draft back to local. + expect( + resolveDraftEnvModeAfterBranchChange({ + nextWorktreePath: null, + currentWorktreePath: null, + stickyEnvMode: "local", + }), + ).toBe("local"); + }); }); describe("resolveBranchToolbarValue", () => { @@ -474,6 +489,92 @@ describe("resolveEffectiveEnvMode", () => { }), ).toBe("worktree"); }); + + it("defaults an untouched draft to worktree when the driver prefers one (Aether)", () => { + expect( + resolveEffectiveEnvMode({ + activeWorktreePath: null, + hasServerThread: false, + draftThreadEnvMode: "local", + providerDefaultsToWorktree: true, + }), + ).toBe("worktree"); + }); + + it("keeps a fresh draft local for drivers that do not prefer a worktree", () => { + expect( + resolveEffectiveEnvMode({ + activeWorktreePath: null, + hasServerThread: false, + draftThreadEnvMode: "local", + providerDefaultsToWorktree: false, + }), + ).toBe("local"); + }); + + it("honors an explicit local pick even when the driver prefers a worktree", () => { + // Once the user has picked a mode the caller stops passing + // providerDefaultsToWorktree, so the seeded/explicit value wins. + expect( + resolveEffectiveEnvMode({ + activeWorktreePath: null, + hasServerThread: false, + draftThreadEnvMode: "local", + }), + ).toBe("local"); + }); + + it("is byte-identical for non-worktree callers that omit the driver hint", () => { + expect( + resolveEffectiveEnvMode({ + activeWorktreePath: null, + hasServerThread: false, + draftThreadEnvMode: undefined, + }), + ).toBe("local"); + }); +}); + +describe("resolveProviderDefaultsToWorktree", () => { + it("defaults an untouched local Aether draft to a worktree", () => { + expect( + resolveProviderDefaultsToWorktree({ + isLocalDraftThread: true, + envModeUserSet: false, + isAetherProvider: true, + }), + ).toBe(true); + }); + + it("does not override a draft whose mode was explicitly set (reviewer #2: explicit-local stays local)", () => { + expect( + resolveProviderDefaultsToWorktree({ + isLocalDraftThread: true, + envModeUserSet: true, + isAetherProvider: true, + }), + ).toBe(false); + }); + + it("does not fire for a non-Aether provider (bidirectional flip back to current checkout)", () => { + expect( + resolveProviderDefaultsToWorktree({ + isLocalDraftThread: true, + envModeUserSet: false, + isAetherProvider: false, + }), + ).toBe(false); + }); + + it("never fires for a started server thread", () => { + expect( + resolveProviderDefaultsToWorktree({ + isLocalDraftThread: false, + envModeUserSet: false, + isAetherProvider: true, + }), + ).toBe(false); + }); }); describe("resolveEnvModeLabel", () => { diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 485ffbf8d37f..76b9640763ed 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -125,27 +125,65 @@ export function resolveEffectiveEnvMode(input: { activeWorktreePath: string | null; hasServerThread: boolean; draftThreadEnvMode: EnvMode | undefined; + /** + * Force a fresh draft to default to "worktree" when the selected provider's + * driver prefers an isolated worktree (currently the Aether fork driver) and + * the user has not explicitly chosen a workspace mode. Non-driver-aware + * callers omit this, preserving the prior behavior byte-for-byte. + */ + providerDefaultsToWorktree?: boolean; }): EnvMode { - const { activeWorktreePath, hasServerThread, draftThreadEnvMode } = input; + const { activeWorktreePath, hasServerThread, draftThreadEnvMode, providerDefaultsToWorktree } = + input; if (!hasServerThread) { if (activeWorktreePath) { return "local"; } - return draftThreadEnvMode === "worktree" ? "worktree" : "local"; + if (draftThreadEnvMode === "worktree") { + return "worktree"; + } + if (providerDefaultsToWorktree) { + return "worktree"; + } + return "local"; } return activeWorktreePath ? "worktree" : "local"; } +/** + * Whether a fresh, un-touched local draft should default its workspace to a new + * worktree because the selected provider's driver prefers isolation (the Aether + * fork). It is deliberately narrow so nothing else changes: + * - only local drafts (never a started server thread), + * - only while the mode is still the auto/default value (`!envModeUserSet`); + * any explicit pick or a legacy migrated draft is user-set and wins, + * - only for the Aether provider; every other provider keeps its prior default. + */ +export function resolveProviderDefaultsToWorktree(input: { + isLocalDraftThread: boolean; + envModeUserSet: boolean; + isAetherProvider: boolean; +}): boolean { + return input.isLocalDraftThread && !input.envModeUserSet && input.isAetherProvider; +} + export function resolveDraftEnvModeAfterBranchChange(input: { nextWorktreePath: string | null; currentWorktreePath: string | null; - effectiveEnvMode: EnvMode; + /** + * The draft's persisted (sticky) workspace mode — NOT the render-time + * effective mode. A branch change persists a workspace mode, so it must use + * the sticky value; feeding it the provider-default overlay (e.g. the Aether + * worktree default) would bake that transient overlay into persistence and + * break switching back to a non-worktree provider. + */ + stickyEnvMode: EnvMode; }): EnvMode { - const { nextWorktreePath, currentWorktreePath, effectiveEnvMode } = input; + const { nextWorktreePath, currentWorktreePath, stickyEnvMode } = input; if (nextWorktreePath) { return "worktree"; } - if (effectiveEnvMode === "worktree" && !currentWorktreePath) { + if (stickyEnvMode === "worktree" && !currentWorktreePath) { return "worktree"; } return "local"; diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index bbd27f65ab0d..8fcdd7c78886 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -176,7 +176,11 @@ export function BranchToolbarBranchSelector({ const nextDraftEnvMode = resolveDraftEnvModeAfterBranchChange({ nextWorktreePath: worktreePath, currentWorktreePath: activeWorktreePath, - effectiveEnvMode, + // Use the persisted (sticky) mode, not `effectiveEnvMode`: the latter + // carries the Aether provider-default worktree overlay, which must + // never be written into persistence (it would stick after switching + // back to a non-worktree provider). + stickyEnvMode: draftThread?.envMode ?? "local", }); setDraftThreadContext(draftId ?? threadRef, { branch, @@ -196,7 +200,7 @@ export function BranchToolbarBranchSelector({ draftId, threadRef, environmentId, - effectiveEnvMode, + draftThread?.envMode, stopThreadSession, updateThreadMetadata, ], diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8b510d457fda..03981315db4e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -252,6 +252,7 @@ import { NoActiveThreadState } from "./NoActiveThreadState"; import { resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch, + resolveProviderDefaultsToWorktree, shouldShowComposerContextStrip, shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; @@ -473,6 +474,10 @@ function shouldTypeToFocusComposer(event: KeyboardEvent): boolean { return true; } +// The Aether fork driver defaults a fresh composer draft to an isolated +// worktree so the mirror never collides with the user's working checkout. +const AETHER_DRIVER_KIND = ProviderDriverKind.make("aether"); + function formatOutgoingPrompt(params: { provider: ProviderDriverKind; model: string | null; @@ -1841,7 +1846,12 @@ function ChatViewContent(props: ChatViewProps) { }, []); const openOrReuseProjectDraftThread = useCallback( - async (input: { branch: string; worktreePath: string | null; envMode: DraftThreadEnvMode }) => { + async (input: { + branch: string; + worktreePath: string | null; + envMode: DraftThreadEnvMode; + envModeUserSet: boolean; + }) => { if (!activeProject) { throw new Error("No active project is available for this pull request."); } @@ -1923,6 +1933,9 @@ function ChatViewContent(props: ChatViewProps) { branch: input.branch, worktreePath: input.worktreePath, envMode: input.worktreePath ? "worktree" : "local", + // Checking out a PR is a deliberate workspace choice: mark it user-set + // so the Aether provider default cannot later override it. + envModeUserSet: true, }); }, [openOrReuseProjectDraftThread], @@ -3980,10 +3993,18 @@ function ChatViewContent(props: ChatViewProps) { }, []); const activeWorktreePath = activeThread?.worktreePath ?? null; + // Aether-only: a fresh, un-touched draft with an Aether model selected + // defaults its workspace to a new worktree (see resolveProviderDefaultsToWorktree). + const providerDefaultsToWorktree = resolveProviderDefaultsToWorktree({ + isLocalDraftThread, + envModeUserSet: draftThread?.envModeUserSet ?? false, + isAetherProvider: selectedProvider === AETHER_DRIVER_KIND, + }); const derivedEnvMode: DraftThreadEnvMode = resolveEffectiveEnvMode({ activeWorktreePath, hasServerThread: isServerThread, draftThreadEnvMode: isLocalDraftThread ? draftThread?.envMode : undefined, + providerDefaultsToWorktree, }); const canOverrideServerThreadEnvMode = Boolean( isServerThread && @@ -4009,6 +4030,31 @@ function ChatViewContent(props: ChatViewProps) { requestedEnvMode: envMode, isGitRepo, }); + // The base branch for a fresh Aether worktree draft is seeded by the branch + // toolbar's own auto-seed effect, which fires whenever the effective mode is + // "worktree" (the Aether provider default is forwarded to it as an override). + // startFromOrigin is independent of the branch, so seed it here: the draft was + // created with the "local" default (startFromOrigin false), but the Aether + // overlay makes the effective mode "worktree", which must honor the user's + // newWorktreesStartFromOrigin preference. Scoped to the auto-default so an + // explicit pick (which sets startFromOrigin itself) is untouched. + useEffect(() => { + if (!providerDefaultsToWorktree) return; + if (envMode !== "worktree") return; + const desiredStartFromOrigin = resolveNewDraftStartFromOrigin({ + envMode: "worktree", + newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, + }); + if (draftThread?.startFromOrigin === desiredStartFromOrigin) return; + setDraftThreadContext(composerDraftTarget, { startFromOrigin: desiredStartFromOrigin }); + }, [ + providerDefaultsToWorktree, + envMode, + draftThread?.startFromOrigin, + primaryServerSettings.newWorktreesStartFromOrigin, + composerDraftTarget, + setDraftThreadContext, + ]); const localCheckoutBranchMismatch = useMemo( () => isServerThread @@ -5837,6 +5883,9 @@ function ChatViewContent(props: ChatViewProps) { if (isLocalDraftThread) { setDraftThreadContext(composerDraftTarget, { envMode: mode, + // An explicit pick freezes the mode so the Aether worktree default + // can no longer override it (invariant: user choice wins). + envModeUserSet: true, startFromOrigin: resolveNewDraftStartFromOrigin({ envMode: mode, newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, @@ -6284,7 +6333,7 @@ function ChatViewContent(props: ChatViewProps) { onEnvModeChange={onEnvModeChange} startFromOrigin={startFromOrigin} onStartFromOriginChange={onStartFromOriginChange} - {...(canOverrideServerThreadEnvMode + {...(canOverrideServerThreadEnvMode || isLocalDraftThread ? { effectiveEnvModeOverride: envMode } : {})} {...(canOverrideServerThreadEnvMode diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 19822b8b7eee..940a254f4fec 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1050,6 +1050,83 @@ describe("composerDraftStore project draft thread mapping", () => { expect(useComposerDraftStore.getState().getDraftThread(draftId)?.startFromOrigin).toBe(false); }); + it("defaults envModeUserSet to false and flips it when the user picks a mode", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { + threadId, + envMode: "local", + }); + + // Freshly seeded: the mode is still the auto/default value. + expect(useComposerDraftStore.getState().getDraftThread(draftId)?.envModeUserSet).toBe(false); + + store.setDraftThreadContext(draftId, { envMode: "worktree", envModeUserSet: true }); + + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({ + envMode: "worktree", + envModeUserSet: true, + }); + + // A later context update that omits the flag preserves the user-set value. + store.setDraftThreadContext(draftId, { startFromOrigin: true }); + expect(useComposerDraftStore.getState().getDraftThread(draftId)?.envModeUserSet).toBe(true); + + // Resurrecting a draft to defaults explicitly clears the flag (the mode is + // the auto/default value again, so a provider default may override it). + store.setDraftThreadContext(draftId, { envMode: "local", envModeUserSet: false }); + expect(useComposerDraftStore.getState().getDraftThread(draftId)?.envModeUserSet).toBe(false); + }); + + it("migrates a legacy persisted draft (absent envModeUserSet) to user-set, keeping an explicit false", () => { + const persistApi = useComposerDraftStore.persist as unknown as { + getOptions: () => { + merge: ( + persistedState: unknown, + currentState: ReturnType, + ) => ReturnType; + }; + }; + const legacyThreadId = ThreadId.make("thread-legacy-envmode"); + const explicitThreadId = ThreadId.make("thread-explicit-envmode"); + const baseDraft = { + environmentId: TEST_ENVIRONMENT_ID, + projectId, + logicalProjectKey: "github.com/acme/repo", + createdAt: "2026-03-13T12:00:00.000Z", + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + envMode: "local", + startFromOrigin: false, + promotedTo: null, + }; + const mergedState = persistApi.getOptions().merge( + { + draftThreadsByThreadKey: { + // Legacy draft: created before envModeUserSet existed → field absent. + [threadKeyFor(legacyThreadId, TEST_ENVIRONMENT_ID)]: { + threadId: legacyThreadId, + ...baseDraft, + }, + // Modern untouched draft: this build writes an explicit false. + [threadKeyFor(explicitThreadId, TEST_ENVIRONMENT_ID)]: { + threadId: explicitThreadId, + ...baseDraft, + envModeUserSet: false, + }, + }, + }, + useComposerDraftStore.getInitialState(), + ); + const byThread = (id: ThreadId) => + Object.values(mergedState.draftThreadsByThreadKey).find((draft) => draft.threadId === id); + // Legacy draft is treated as user-set so a provider default never flips it. + expect(byThread(legacyThreadId)?.envModeUserSet).toBe(true); + // An explicit false stays overlay-eligible. + expect(byThread(explicitThreadId)?.envModeUserSet).toBe(false); + }); + it("preserves existing branch and worktree when setProjectDraftThreadId receives undefined", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 95dde6187c82..3e77dface076 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -216,6 +216,11 @@ const PersistedDraftThreadState = Schema.Struct({ worktreePath: Schema.NullOr(Schema.String), envMode: DraftThreadEnvModeSchema, startFromOrigin: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + // True once the user explicitly picks a workspace mode. While false the mode + // is still the auto/default value, so a provider whose driver prefers an + // isolated worktree (Aether) may override it. Additive + defaults false, so + // pre-existing persisted drafts decode unchanged. + envModeUserSet: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), promotedTo: Schema.optionalKey( Schema.NullOr( Schema.Struct({ @@ -294,6 +299,8 @@ export interface DraftSessionState { branch: string | null; worktreePath: string | null; envMode: DraftThreadEnvMode; + /** True once the user explicitly picks a workspace mode (see persisted schema). */ + envModeUserSet: boolean; startFromOrigin: boolean; promotedTo?: ScopedThreadRef | null; } @@ -356,6 +363,7 @@ interface ComposerDraftStoreState { worktreePath?: string | null; createdAt?: string; envMode?: DraftThreadEnvMode; + envModeUserSet?: boolean; startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; @@ -371,6 +379,7 @@ interface ComposerDraftStoreState { worktreePath?: string | null; createdAt?: string; envMode?: DraftThreadEnvMode; + envModeUserSet?: boolean; startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; @@ -385,6 +394,7 @@ interface ComposerDraftStoreState { projectRef?: ScopedProjectRef; createdAt?: string; envMode?: DraftThreadEnvMode; + envModeUserSet?: boolean; startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; @@ -1335,6 +1345,7 @@ function createDraftThreadState( worktreePath?: string | null; createdAt?: string; envMode?: DraftThreadEnvMode; + envModeUserSet?: boolean; startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; @@ -1377,6 +1388,7 @@ function createDraftThreadState( worktreePath: nextWorktreePath, envMode: options?.envMode ?? (nextWorktreePath ? "worktree" : (existingThread?.envMode ?? "local")), + envModeUserSet: options?.envModeUserSet ?? existingThread?.envModeUserSet ?? false, startFromOrigin: nextStartFromOrigin, promotedTo: null, }; @@ -1409,6 +1421,7 @@ function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThrea left.branch === right.branch && left.worktreePath === right.worktreePath && left.envMode === right.envMode && + left.envModeUserSet === right.envModeUserSet && left.startFromOrigin === right.startFromOrigin && scopedThreadRefsEqual(left.promotedTo, right.promotedTo) ); @@ -1505,6 +1518,12 @@ function normalizePersistedDraftThreads( const branch = candidateDraftThread.branch; const worktreePath = candidateDraftThread.worktreePath; const startFromOrigin = candidateDraftThread.startFromOrigin === true; + // Legacy-safe migration: a draft persisted before this field existed has + // it absent. Treat absent as user-set (`!== false`) so an existing draft + // is never surprise-flipped by a provider default (e.g. Aether worktree) + // on first render after upgrade. Only an explicit `false` — written by + // this build for a genuinely untouched new draft — stays overlay-eligible. + const envModeUserSet = candidateDraftThread.envModeUserSet !== false; const normalizedWorktreePath = typeof worktreePath === "string" ? worktreePath : null; const promotedToCandidate = candidateDraftThread.promotedTo; const promotedToRecord = @@ -1552,6 +1571,7 @@ function normalizePersistedDraftThreads( branch: typeof branch === "string" ? branch : null, worktreePath: normalizedWorktreePath, envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), + envModeUserSet, startFromOrigin, promotedTo, }; @@ -1598,6 +1618,7 @@ function normalizePersistedDraftThreads( branch: null, worktreePath: null, envMode: "local", + envModeUserSet: false, startFromOrigin: false, promotedTo: null, }; @@ -2169,6 +2190,7 @@ function toHydratedDraftThreadState( branch: persistedDraftThread.branch, worktreePath: persistedDraftThread.worktreePath, envMode: persistedDraftThread.envMode, + envModeUserSet: persistedDraftThread.envModeUserSet, startFromOrigin: persistedDraftThread.startFromOrigin, promotedTo: persistedDraftThread.promotedTo ? scopeThreadRef( @@ -2362,6 +2384,10 @@ const composerDraftStore = create()( options.startFromOrigin === undefined ? existing.startFromOrigin : options.startFromOrigin; + const nextEnvModeUserSet = + options.envModeUserSet === undefined + ? existing.envModeUserSet + : options.envModeUserSet; const nextDraftThread: DraftThreadState = { threadId: existing.threadId, environmentId: nextProjectRef.environmentId, @@ -2377,6 +2403,7 @@ const composerDraftStore = create()( worktreePath: nextWorktreePath, envMode: options.envMode ?? (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")), + envModeUserSet: nextEnvModeUserSet, startFromOrigin: nextStartFromOrigin, promotedTo: existing.promotedTo ?? null, }; @@ -2390,6 +2417,7 @@ const composerDraftStore = create()( nextDraftThread.branch === existing.branch && nextDraftThread.worktreePath === existing.worktreePath && nextDraftThread.envMode === existing.envMode && + nextDraftThread.envModeUserSet === existing.envModeUserSet && nextDraftThread.startFromOrigin === existing.startFromOrigin && scopedThreadRefsEqual(nextDraftThread.promotedTo, existing.promotedTo); if (isUnchanged) { diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index 547d82870124..52838f04f523 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -151,7 +151,10 @@ export function useNewThreadHandler() { ? { ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), - ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), + // An explicit envMode is a deliberate workspace choice, so mark + // it user-set: a provider default (Aether worktree) must not + // later override it. + ...(hasEnvModeOption ? { envMode: options?.envMode, envModeUserSet: true } : {}), ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), } : isDraftAlreadyOpen @@ -160,6 +163,11 @@ export function useNewThreadHandler() { branch: null, worktreePath: null, envMode: defaultEnvMode, + // Resurrecting to defaults makes the mode the auto/default + // value again, so clear the user-set flag: otherwise a stale + // `true` (from a prior explicit pick) would suppress the + // Aether worktree default on a draft the user sees as fresh. + envModeUserSet: false, startFromOrigin: resolveNewDraftStartFromOrigin({ envMode: defaultEnvMode, newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, @@ -224,7 +232,7 @@ export function useNewThreadHandler() { setDraftThreadContext(currentRouteTarget.draftId, { ...(hasBranchOption ? { branch: options?.branch ?? null } : {}), ...(hasWorktreePathOption ? { worktreePath: options?.worktreePath ?? null } : {}), - ...(hasEnvModeOption ? { envMode: options?.envMode } : {}), + ...(hasEnvModeOption ? { envMode: options?.envMode, envModeUserSet: true } : {}), ...(hasStartFromOriginOption ? { startFromOrigin: options?.startFromOrigin } : {}), }); } @@ -252,6 +260,10 @@ export function useNewThreadHandler() { branch: options?.branch ?? null, worktreePath: options?.worktreePath ?? null, envMode: initialEnvMode, + // Only an explicitly-passed envMode is a deliberate choice; the + // server-default fallback stays overlay-eligible (envModeUserSet + // false) so a fresh Aether draft can still default to a worktree. + envModeUserSet: hasEnvModeOption, startFromOrigin: options?.startFromOrigin ?? resolveNewDraftStartFromOrigin({ From 89925de26f8c5bdf7340af69c749c8ca6466e7db Mon Sep 17 00:00:00 2001 From: Pranav Sharan Date: Sun, 9 Aug 2026 01:29:04 -0700 Subject: [PATCH 11/44] fix(aether): base managed-worktree cloud tasks on the fork branch, not the local scratch branch (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A driver-owned worktree sits on a local-only branch that is never pushed; sending it as the cloud task base_branch failed workspace startup with remote_ref_missing (404) — the common path once new Aether drafts default to a worktree. The adapter now bases new tasks on the fork branch recorded in branch..gh-merge-base (a real origin branch), and only for new-task starts (resume never sends base_branch). --- .../src/provider/Layers/AetherAdapter.test.ts | 157 +++++++++++++++++- .../src/provider/Layers/AetherAdapter.ts | 27 ++- 2 files changed, 174 insertions(+), 10 deletions(-) diff --git a/apps/server/src/provider/Layers/AetherAdapter.test.ts b/apps/server/src/provider/Layers/AetherAdapter.test.ts index bc6fbd8e73b9..e4e66c8838ac 100644 --- a/apps/server/src/provider/Layers/AetherAdapter.test.ts +++ b/apps/server/src/provider/Layers/AetherAdapter.test.ts @@ -91,9 +91,17 @@ const fakeGitExecute: AetherSessionGit["execute"] = ({ args }) => { const gitWith = ( status: GitStatusDetails, originUrl: string | null = "git@github.com:acme/aether.git", + ghMergeBase: string | null = null, ): AetherSessionGit => ({ statusDetails: () => Effect.succeed(status), - readConfigValue: (_cwd, key) => Effect.succeed(key === "remote.origin.url" ? originUrl : null), + readConfigValue: (_cwd, key) => + Effect.succeed( + key === "remote.origin.url" + ? originUrl + : status.branch !== null && key === `branch.${status.branch}.gh-merge-base` + ? ghMergeBase + : null, + ), execute: fakeGitExecute, }); @@ -370,13 +378,19 @@ describe("AetherAdapter startSession", () => { // The worktree's temp branch is dirty (mirror output from a prior // turn) and has no upstream (git worktree add -b makes a local-only // branch) — both would fail the shared-checkout preflight. - git: gitWith({ - ...cleanStatus, - hasWorkingTreeChanges: true, - hasUpstream: false, - upstreamRef: null, - aheadCount: 4, - }), + git: gitWith( + { + ...cleanStatus, + hasWorkingTreeChanges: true, + hasUpstream: false, + upstreamRef: null, + aheadCount: 4, + }, + "git@github.com:acme/aether.git", + // createWorktree records the fork base; the driver bases the cloud + // task on it instead of the local-only worktree branch. + "main", + ), restClient: { ...unusedRestClient, listProjects: () => Effect.succeed([project()]) }, }, (adapter) => @@ -390,6 +404,34 @@ describe("AetherAdapter startSession", () => { ), ); + it.effect( + "fails loudly when a managed worktree has no recorded base branch (gh-merge-base)", + () => + Effect.gen(function* () { + const error = yield* withAdapter( + { + // A managed worktree whose fork base was never recorded. Its branch + // is local-only, so sending it as base_branch is what 404s cloud + // startup — better to fail here with a clear message. + git: gitWith( + { ...cleanStatus, branch: "t3code/abc123", hasUpstream: false, upstreamRef: null }, + "git@github.com:acme/aether.git", + null, + ), + restClient: { ...unusedRestClient, listProjects: () => Effect.succeed([project()]) }, + }, + (adapter) => + Effect.flip( + adapter.startSession( + startInput({ cwd: "/worktrees/thread-1", managedWorktree: true }), + ), + ), + ); + expect(error._tag).toBe("ProviderAdapterValidationError"); + expect(error.message).toContain("no recorded base branch"); + }), + ); + it.effect( "still refuses the shared 'Current checkout' with uncommitted changes when not a managed worktree", () => @@ -431,7 +473,11 @@ describe("AetherAdapter startSession", () => { const registeredCwds: Array = []; return withAdapter( { - git: gitWith({ ...cleanStatus, hasUpstream: false, upstreamRef: null }), + git: gitWith( + { ...cleanStatus, hasUpstream: false, upstreamRef: null }, + "git@github.com:acme/aether.git", + "main", + ), restClient: { ...unusedRestClient, listProjects: () => Effect.succeed([project()]) }, mirrorRegistry: { register: (cwd) => @@ -578,6 +624,42 @@ describe("AetherAdapter startSession", () => { }), ); + it.effect( + "resumes a managed worktree whose recorded base branch is missing (base_branch is only for new tasks)", + () => + Effect.gen(function* () { + yield* withAdapter( + { + // A managed worktree with NO gh-merge-base — an old thread or a + // repaired checkout. Resume reattaches to an existing task and never + // sends base_branch, so the missing base must NOT block startSession. + git: gitWith( + { ...cleanStatus, branch: "t3code/abc123", hasUpstream: false, upstreamRef: null }, + "git@github.com:acme/aether.git", + null, + ), + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + getTask: () => Effect.succeed(processingTask), + getConversationMessages: () => Effect.succeed(messagesPage(processingTask)), + }, + }, + (adapter) => + Effect.gen(function* () { + const session = yield* adapter.startSession( + startInput({ + cwd: "/worktrees/thread-1", + managedWorktree: true, + resumeCursor: { schemaVersion: 1, taskId: "task-1", latestSequence: 7 }, + }), + ); + expect(session.resumeCursor).toMatchObject({ taskId: "task-1" }); + }), + ); + }), + ); + it.effect("fails with session-not-found when the resumed task is gone", () => Effect.gen(function* () { const error = yield* expectStartFailure({ @@ -2021,6 +2103,63 @@ describe("AetherAdapter turn lifecycle", () => { }), ); + it.effect( + "bases the managed-worktree task on the recorded fork branch, not the local scratch branch", + () => + Effect.gen(function* () { + const createRequests: Array = []; + const deltas = scriptedDeltas([ + delta({ + task: processingTask, + messages: [userRow("u1", 1)], + activeMessageId: "u1", + latestSequence: 1, + }), + delta({ + task: messageIdleTask, + messages: [userRow("u1", 1), assistantRow("a1", 2)], + latestSequence: 2, + }), + ]); + yield* withAdapter( + { + // Driver-owned worktree on a local-only scratch branch; its fork + // base ("main") is recorded in branch..gh-merge-base. + git: gitWith( + { ...cleanStatus, branch: "t3code/abc123", hasUpstream: false, upstreamRef: null }, + "git@github.com:acme/aether.git", + "main", + ), + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + createTask: (request) => + Effect.sync(() => { + createRequests.push(request); + }).pipe(Effect.as({ id: "task-9", name: "n" })), + getConversationDelta: deltas.getConversationDelta, + }, + }, + (adapter) => + Effect.gen(function* () { + yield* adapter.streamEvents.pipe( + Stream.take(3), + Stream.runCollect, + Effect.forkScoped, + ); + const session = yield* adapter.startSession( + startInput({ cwd: "/worktrees/thread-1", managedWorktree: true }), + ); + yield* adapter.sendTurn({ threadId: session.threadId, input: "fix the bug" }); + expect(createRequests).toHaveLength(1); + // base_branch is the recorded fork base — NOT "t3code/abc123", + // which exists only locally and would 404 cloud startup. + expect(createRequests[0]).toMatchObject({ base_branch: "main" }); + }), + ); + }), + ); + it.effect( "a first turn already settled in the attach reconcile still starts before it completes", () => diff --git a/apps/server/src/provider/Layers/AetherAdapter.ts b/apps/server/src/provider/Layers/AetherAdapter.ts index 04b022e3fee0..019bdd65161e 100644 --- a/apps/server/src/provider/Layers/AetherAdapter.ts +++ b/apps/server/src/provider/Layers/AetherAdapter.ts @@ -1482,6 +1482,31 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( issue, }); } + // (1b) Task base branch — the ref a NEW cloud task clones and fetches. Only + // the first send (non-resume) creates a task and sends base_branch; a resume + // reattaches to an existing task and never sends it, so the requirement below + // is gated on !isResume (else a resumed managed worktree whose config is + // missing — an old thread or a repaired checkout — would fail to reattach). + // For "Current checkout" the local branch IS the pushed base (preflight + // enforces it), so status.branch is correct. A driver-owned worktree instead + // sits on a LOCAL scratch branch that was never pushed; the cloud must base + // on the branch it was FORKED from, which createWorktree recorded in + // `branch..gh-merge-base`. Passing the scratch branch is exactly what + // fails cloud startup with remote_ref_missing (404). + let baseBranch = status.branch ?? undefined; + if (!isResume && input.managedWorktree === true && status.branch !== null) { + const recordedBase = (yield* options.git + .readConfigValue(cwd, `branch.${status.branch}.gh-merge-base`) + .pipe(Effect.mapError(toGitRequestError("startSession"))))?.trim(); + if (!recordedBase) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: `The Aether worktree branch '${status.branch}' has no recorded base branch (branch.${status.branch}.gh-merge-base). The cloud task needs a base that exists on the remote — recreate the thread so its worktree records a fork base.`, + }); + } + baseBranch = recordedBase; + } // The mirror baseline: for a never-synced thread the expected pre-sync // state is "clean tree at this HEAD". const baselineHeadSha = yield* options.git @@ -1598,7 +1623,7 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( }, cwd, projectId: project.id, - baseBranch: status.branch ?? undefined, + baseBranch, taskId, latestSequence, turnLedger, From 9a28299db0b181a4832a077f5db1709510819802 Mon Sep 17 00:00:00 2001 From: Pranav Sharan Date: Sun, 9 Aug 2026 05:13:49 -0700 Subject: [PATCH 12/44] feat(aether): cloud port previews in the composer (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the Aether cloud VM opens a port (agent runs a dev server), a 'Port N is live — Open preview' CTA appears in the thread and opens the workspace preview URL. The driver parses the workspace ports channel, builds the preview URL from the connect transport's preview_token ({port}-{workspaceId8}-{token}.preview.runaether.dev), and emits a port.opened runtime event → thread activity → web + mobile timeline. Verified end-to-end in the desktop app (chip URL returns the VM's app, HTTP 200). --- .../src/features/threads/thread-work-log.tsx | 27 ++++++-- apps/mobile/src/lib/threadActivity.test.ts | 34 ++++++++++ apps/mobile/src/lib/threadActivity.ts | 13 ++++ .../Layers/ProviderRuntimeIngestion.ts | 14 ++++ .../src/provider/Layers/AetherAdapter.test.ts | 56 ++++++++++++++++ .../src/provider/Layers/AetherAdapter.ts | 47 ++++++++++++++ .../Layers/aether/portPreview.test.ts | 64 +++++++++++++++++++ .../src/provider/Layers/aether/portPreview.ts | 50 +++++++++++++++ .../src/provider/Layers/aether/wireEvents.ts | 49 ++++++++++++++ .../Layers/aether/workspaceSocket.test.ts | 34 +++++++++- .../provider/Layers/aether/workspaceSocket.ts | 23 ++++++- .../src/components/chat/MessagesTimeline.tsx | 33 +++++++++- apps/web/src/session-logic.ts | 9 +++ packages/contracts/src/providerRuntime.ts | 19 ++++++ 14 files changed, 464 insertions(+), 8 deletions(-) create mode 100644 apps/server/src/provider/Layers/aether/portPreview.test.ts create mode 100644 apps/server/src/provider/Layers/aether/portPreview.ts diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 529adac1db33..a5c8a928dcf1 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -1,6 +1,13 @@ import * as Haptics from "expo-haptics"; import { type AppSymbolName, SymbolView } from "../../components/AppSymbol"; -import { LayoutAnimation, Pressable, ScrollView, useColorScheme, View } from "react-native"; +import { + LayoutAnimation, + Linking, + Pressable, + ScrollView, + useColorScheme, + View, +} from "react-native"; import { AppText as Text } from "../../components/AppText"; import { scaledTypographyLineHeight } from "../../lib/appearancePreferences"; @@ -162,16 +169,22 @@ export function ThreadWorkLog(props: { {...(isFreshRow(row.createdAt) ? { entering: FadeIn.duration(200) } : {})} > { + if (row.portPreview) { + void Linking.openURL(row.portPreview.url); + return; + } if (canExpand) { triggerDisclosureFeedback(); props.onToggleRow(row.id); @@ -213,6 +226,10 @@ export function ThreadWorkLog(props: { Copied + ) : row.portPreview ? ( + + Open preview › + ) : null} {canExpand ? ( diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 68f44a0a942d..b693a06c1046 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -154,6 +154,40 @@ describe("buildThreadFeed", () => { ]); }); + it("surfaces a port.opened activity as a clickable preview row", () => { + const url = `https://3000-ws-1-${"t".repeat(32)}.preview.runaether.dev`; + const thread = makeThread({ + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Port preview thread", + activities: [ + makeActivity({ + id: EventId.make("activity-port"), + kind: "port.opened", + summary: "Port 3000 is live", + createdAt: "2026-04-01T00:00:02.000Z", + turnId: TurnId.make("turn-1"), + payload: { port: 3000, url }, + }), + ], + }); + + const feed = buildThreadFeed(thread); + expect(feed).toMatchObject([ + { + type: "activity-group", + activities: [ + { + id: "activity-port", + summary: "Port 3000 is live", + icon: "globe", + portPreview: { port: 3000, url }, + }, + ], + }, + ]); + }); + it("collapses matching tool lifecycle rows like desktop", () => { const thread = makeThread({ id: ThreadId.make("thread-2"), diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index fc2e8753f3f6..efc5ec7185fb 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -54,6 +54,8 @@ export interface ThreadFeedActivity { | "zap"; readonly toolLike: boolean; readonly status: "success" | "failure" | "neutral" | null; + /** Present on a `port.opened` row: a live workspace port + its preview URL. */ + readonly portPreview?: { readonly port: number; readonly url: string }; } const MAX_VISIBLE_WORK_LOG_ENTRIES = 1; @@ -75,6 +77,8 @@ interface WorkLogEntry { requestKind?: PendingApproval["requestKind"]; toolLifecycleStatus?: WorkLogToolLifecycleStatus; toolData?: unknown; + /** Present on a `port.opened` row: a live workspace port + its preview URL. */ + portPreview?: { port: number; url: string }; } interface DerivedWorkLogEntry extends WorkLogEntry { @@ -421,6 +425,13 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (toolLifecycleStatus) { entry.toolLifecycleStatus = toolLifecycleStatus; } + if (activity.kind === "port.opened" && payload) { + const port = payload.port; + const url = payload.url; + if (typeof port === "number" && typeof url === "string" && url.length > 0) { + entry.portPreview = { port, url }; + } + } const collapseKey = deriveToolLifecycleCollapseKey(entry); if (collapseKey) { entry.collapseKey = collapseKey; @@ -615,6 +626,7 @@ function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { return "message"; } if (entry.activityKind === "runtime.warning") return "warning"; + if (entry.activityKind === "port.opened") return "globe"; if (entry.requestKind === "command") return "command"; if (entry.requestKind === "file-read") return "eye"; if (entry.requestKind === "file-change") return "edit"; @@ -1512,6 +1524,7 @@ export function buildThreadFeed( icon: workEntryIcon(entry), toolLike: workLogEntryIsToolLike(entry), status: workEntryStatus(entry), + ...(entry.portPreview ? { portPreview: entry.portPreview } : {}), }, }; }), diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 40307cd9f25e..6742ce2f2207 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -460,6 +460,20 @@ export function runtimeEventToActivities( ]; } + case "port.opened": { + return [ + { + id: event.eventId, + createdAt: event.createdAt, + tone: "info", + kind: "port.opened", + summary: `Port ${event.payload.port} is live`, + payload: { port: event.payload.port, url: event.payload.url }, + turnId: toTurnId(event.turnId) ?? null, + ...maybeSequence, + }, + ]; + } case "runtime.warning": { return [ { diff --git a/apps/server/src/provider/Layers/AetherAdapter.test.ts b/apps/server/src/provider/Layers/AetherAdapter.test.ts index e4e66c8838ac..c1e3478860dd 100644 --- a/apps/server/src/provider/Layers/AetherAdapter.test.ts +++ b/apps/server/src/provider/Layers/AetherAdapter.test.ts @@ -1352,6 +1352,62 @@ describe("AetherAdapter event pipeline", () => { }), ); + it.effect("emits port.opened with the workspace preview URL, deduping snapshot re-syncs", () => + Effect.gen(function* () { + const sockets: Array = []; + const deltaSequences: Array = []; + const TOKEN = "t".repeat(32); + yield* withAdapter( + { + restClient: streamingRestClient(deltaSequences), + socket: { + apiBaseUrl: "https://api.runaether.dev", + apiKey: "aether_test_key", + timing: { ...zeroSocketTiming, requestTimeoutMs: 60_000 }, + webSocketFactory: () => { + const socket = diffAnsweringSocket(); + sockets.push(socket); + return socket; + }, + }, + }, + (adapter) => + Effect.gen(function* () { + const collector = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "port.opened"), + Stream.take(2), + Stream.runCollect, + Effect.forkScoped, + ); + yield* adapter.startSession( + startInput({ + resumeCursor: { schemaVersion: 1, taskId: "task-1", latestSequence: 7 }, + }), + ); + yield* settleAdapterPump; + + // Snapshot surfaces each port once; a re-snapshot dedupes; a + // distinct open adds exactly one more. + sockets[0]!.message({ channel: "ports", type: "snapshot", ports: [3000] }); + sockets[0]!.message({ channel: "ports", type: "snapshot", ports: [3000] }); + sockets[0]!.message({ channel: "ports", type: "change", action: "open", port: 5173 }); + yield* settleAdapterPump; + + const events = [...(yield* Fiber.join(collector))]; + expect(events).toHaveLength(2); + expect(events[0]).toMatchObject({ + type: "port.opened", + payload: { port: 3000, url: `https://3000-ws-1-${TOKEN}.preview.runaether.dev` }, + }); + expect(events[1]).toMatchObject({ + type: "port.opened", + payload: { port: 5173, url: `https://5173-ws-1-${TOKEN}.preview.runaether.dev` }, + }); + }), + ); + }), + ); + it.effect("resume onto an in-flight task adopts the turn and arms the settle backstop", () => Effect.gen(function* () { const sockets: Array = []; diff --git a/apps/server/src/provider/Layers/AetherAdapter.ts b/apps/server/src/provider/Layers/AetherAdapter.ts index 019bdd65161e..a235c8aaa2c1 100644 --- a/apps/server/src/provider/Layers/AetherAdapter.ts +++ b/apps/server/src/provider/Layers/AetherAdapter.ts @@ -100,6 +100,7 @@ import { type AetherEventMapper, } from "./aether/eventMapper.ts"; import { makeAetherMirrorSync, type AetherMirrorSyncEngine } from "./aether/mirrorSync.ts"; +import { buildAetherPreviewUrl } from "./aether/portPreview.ts"; import type { AetherRestClient } from "./aether/restClient.ts"; import type { AetherPromptAttachment, @@ -359,6 +360,12 @@ interface AetherSessionContext { mirror: AetherMirrorSyncEngine | undefined; /** Live WS connection handle (undefined while detached). */ connection: AetherAgentConnection | undefined; + /** Cloud port-preview token (from the connect transport); set on attach. */ + previewToken: string | undefined; + /** The workspace id backing this session (port-preview subdomain prefix). */ + workspaceId: string | undefined; + /** Ports already surfaced as `port.opened`, to dedupe snapshot re-syncs. */ + readonly emittedPorts: Set; /** The durable reconciliation — the settle backstop the turn poll drives. */ reconcile: Effect.Effect | undefined; /** True while an attach pump fiber runs for this session. */ @@ -1241,6 +1248,8 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( Effect.gen(function* () { connectRetryWarned = false; context.connection = connection; + context.previewToken = connection.previewToken; + context.workspaceId = connection.workspaceId; if (!sessionStartedEmitted) { sessionStartedEmitted = true; context.sessionStartedEmitted = true; @@ -1252,6 +1261,41 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( } yield* reconcile; }), + onPortsMessage: (message) => + Effect.gen(function* () { + // Best-effort cloud port previews. A port only surfaces once (deduped + // across snapshot re-syncs); a close re-arms it so a re-open re-emits. + const previewToken = context.previewToken; + const workspaceId = context.workspaceId; + if (previewToken === undefined || workspaceId === undefined) { + return; + } + if (message._tag === "change" && message.action === "close") { + context.emittedPorts.delete(message.port); + return; + } + const ports = message._tag === "snapshot" ? message.ports : [message.port]; + for (const port of ports) { + if (context.emittedPorts.has(port)) { + continue; + } + const url = buildAetherPreviewUrl({ + apiBaseUrl: socket.apiBaseUrl, + workspaceId, + port, + previewToken, + }); + if (url === undefined) { + continue; + } + context.emittedPorts.add(port); + yield* emit({ + ...(yield* baseEvent(context)), + type: "port.opened", + payload: { port, url }, + }); + } + }), onDisconnected: () => Effect.sync(() => { context.connection = undefined; @@ -1642,6 +1686,9 @@ export const makeAetherAdapter = Effect.fn("makeAetherAdapter")(function* ( getTaskId: () => context.taskId, }), connection: undefined, + previewToken: undefined, + workspaceId: undefined, + emittedPorts: new Set(), reconcile: undefined, pumpRunning: false, sessionStartedEmitted: false, diff --git a/apps/server/src/provider/Layers/aether/portPreview.test.ts b/apps/server/src/provider/Layers/aether/portPreview.test.ts new file mode 100644 index 000000000000..1a4d89e6cc65 --- /dev/null +++ b/apps/server/src/provider/Layers/aether/portPreview.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { buildAetherPreviewUrl, deriveAetherPreviewDomain } from "./portPreview.ts"; + +// A valid gateway token: 32 lowercase-alphanumeric chars. +const TOKEN = "abcdef0123456789abcdef0123456789"; + +describe("deriveAetherPreviewDomain", () => { + it("swaps a leading api. label for preview.", () => { + expect(deriveAetherPreviewDomain("https://api.runaether.dev")).toBe("preview.runaether.dev"); + expect(deriveAetherPreviewDomain("https://api.staging.runaether.dev")).toBe( + "preview.staging.runaether.dev", + ); + }); + + it("returns a non-api host unchanged and falls back on an unparseable URL", () => { + expect(deriveAetherPreviewDomain("http://localhost:8080")).toBe("localhost:8080"); + expect(deriveAetherPreviewDomain("not a url")).toBe("preview.runaether.dev"); + }); +}); + +describe("buildAetherPreviewUrl", () => { + it("builds {port}-{workspaceId8}-{token}.preview.runaether.dev for the prod api base", () => { + expect( + buildAetherPreviewUrl({ + apiBaseUrl: "https://api.runaether.dev", + workspaceId: "1a2b3c4d5e6f7890", + port: 3000, + previewToken: TOKEN, + }), + ).toBe(`https://3000-1a2b3c4d-${TOKEN}.preview.runaether.dev`); + }); + + it("uses http for a localhost preview domain", () => { + expect( + buildAetherPreviewUrl({ + apiBaseUrl: "http://localhost:8080", + workspaceId: "abcdefgh1234", + port: 5173, + previewToken: TOKEN, + }), + ).toBe(`http://5173-abcdefgh-${TOKEN}.localhost:8080`); + }); + + it("returns undefined for a malformed token (best-effort, never throws)", () => { + expect( + buildAetherPreviewUrl({ + apiBaseUrl: "https://api.runaether.dev", + workspaceId: "1a2b3c4d", + port: 3000, + previewToken: "short", + }), + ).toBeUndefined(); + // Uppercase is not allowed by the gateway pattern. + expect( + buildAetherPreviewUrl({ + apiBaseUrl: "https://api.runaether.dev", + workspaceId: "1a2b3c4d", + port: 3000, + previewToken: "ABCDEF0123456789abcdef0123456789", + }), + ).toBeUndefined(); + }); +}); diff --git a/apps/server/src/provider/Layers/aether/portPreview.ts b/apps/server/src/provider/Layers/aether/portPreview.ts new file mode 100644 index 000000000000..54a86662abc7 --- /dev/null +++ b/apps/server/src/provider/Layers/aether/portPreview.ts @@ -0,0 +1,50 @@ +/** + * Cloud port-preview URL builder for the Aether provider driver. + * + * Mirrors the Aether platform contract (`@aether/domain-types` + * `buildWorkspacePreviewUrl`, which this fork cannot import): the preview + * token rides the SUBDOMAIN — `{port}-{workspaceId prefix}-{token}` — so the + * URL opens in any browser with no cookies or app session; the preview + * gateway routes on the subdomain token. + * + * @module provider/Layers/aether/portPreview + */ + +/** The gateway contract: a 32-char lowercase-alnum preview token. */ +const PREVIEW_TOKEN_PATTERN = /^[a-z0-9]{32}$/; + +/** + * Derive the preview domain from the instance API base URL by swapping a + * leading `api.` host label for `preview.` (`api.runaether.dev` → + * `preview.runaether.dev`). A host without an `api.` prefix is returned + * unchanged (best-effort for staging / self-hosted); an unparseable URL falls + * back to the production preview domain. + */ +export function deriveAetherPreviewDomain(apiBaseUrl: string): string { + try { + const host = new URL(apiBaseUrl).host; + return host.startsWith("api.") ? `preview.${host.slice("api.".length)}` : host; + } catch { + return "preview.runaether.dev"; + } +} + +/** + * Build a workspace port-preview URL, or `undefined` when the preview token is + * malformed or absent. Port previews are best-effort — the caller skips + * surfacing the port rather than failing a turn. + */ +export function buildAetherPreviewUrl(input: { + readonly apiBaseUrl: string; + readonly workspaceId: string; + readonly port: number; + readonly previewToken: string; +}): string | undefined { + if (!PREVIEW_TOKEN_PATTERN.test(input.previewToken)) { + return undefined; + } + const domain = deriveAetherPreviewDomain(input.apiBaseUrl); + const subdomain = `${input.port}-${input.workspaceId.slice(0, 8)}-${input.previewToken}`; + const protocol = domain.startsWith("localhost") ? "http" : "https"; + return `${protocol}://${subdomain}.${domain}`; +} diff --git a/apps/server/src/provider/Layers/aether/wireEvents.ts b/apps/server/src/provider/Layers/aether/wireEvents.ts index f0b6d28c0a45..059f9091555e 100644 --- a/apps/server/src/provider/Layers/aether/wireEvents.ts +++ b/apps/server/src/provider/Layers/aether/wireEvents.ts @@ -295,9 +295,34 @@ export type AetherAgentEvent = // Frame parsing // --------------------------------------------------------------------------- +// Ports channel — VM port-open/-close notifications powering cloud port +// previews. Two frame shapes the workspace-service emits: +// {channel:"ports", type:"snapshot", ports:number[]} +// {channel:"ports", type:"change", action:"open"|"close", port:number} +const AetherWsPortsSnapshotFrame = Schema.Struct({ + channel: Schema.Literal("ports"), + type: Schema.Literal("snapshot"), + ports: Schema.Array(Schema.Number), +}); +const AetherWsPortsChangeFrame = Schema.Struct({ + channel: Schema.Literal("ports"), + type: Schema.Literal("change"), + action: Schema.Literals(["open", "close"]), + port: Schema.Number, +}); +const decodePortsSnapshot = Schema.decodeUnknownResult(AetherWsPortsSnapshotFrame); +const decodePortsChange = Schema.decodeUnknownResult(AetherWsPortsChangeFrame); + +/** A parsed ports-channel message — the source of cloud port previews. */ +export type AetherPortsMessage = + | { readonly _tag: "snapshot"; readonly ports: ReadonlyArray } + | { readonly _tag: "change"; readonly action: "open" | "close"; readonly port: number }; + export type AetherFrameParseResult = /** A fully parsed agent task event. */ | { readonly _tag: "event"; readonly event: AetherAgentEvent } + /** A ports-channel notification (port opened/closed) for cloud previews. */ + | { readonly _tag: "ports"; readonly message: AetherPortsMessage } /** A frame for another channel / message type — not ours, silently skipped. */ | { readonly _tag: "ignored"; readonly channel: string; readonly type: string } /** @@ -413,6 +438,30 @@ export function parseAetherAgentFrame(raw: string): AetherFrameParseResult { : "server sent an error frame carrying no string `error` field", }; } + if (envelope.success.channel === "ports") { + // Best-effort: a ports frame we cannot parse is ignored (not an error), + // like any other multiplexed traffic — a stale preview is never worth a + // dropped-frame diagnostic. + if (envelope.success.type === "snapshot") { + const decoded = decodePortsSnapshot(frame); + if (Result.isSuccess(decoded)) { + return { _tag: "ports", message: { _tag: "snapshot", ports: decoded.success.ports } }; + } + } else if (envelope.success.type === "change") { + const decoded = decodePortsChange(frame); + if (Result.isSuccess(decoded)) { + return { + _tag: "ports", + message: { + _tag: "change", + action: decoded.success.action, + port: decoded.success.port, + }, + }; + } + } + return { _tag: "ignored", channel: "ports", type: envelope.success.type }; + } if (envelope.success.channel === "git" || envelope.success.channel === "files") { // Correlated request-response traffic for the mirror sync engine. Frames // WITHOUT a requestId (files change broadcasts, git checkpoint diff --git a/apps/server/src/provider/Layers/aether/workspaceSocket.test.ts b/apps/server/src/provider/Layers/aether/workspaceSocket.test.ts index d93059b034df..169c4fe2991d 100644 --- a/apps/server/src/provider/Layers/aether/workspaceSocket.test.ts +++ b/apps/server/src/provider/Layers/aether/workspaceSocket.test.ts @@ -16,7 +16,7 @@ import { type AetherAgentStreamOptions, type AetherWebSocketLike, } from "./workspaceSocket.ts"; -import type { AetherAgentEvent } from "./wireEvents.ts"; +import type { AetherAgentEvent, AetherPortsMessage } from "./wireEvents.ts"; import { taskProcessing, wsAssistantDelta, wsUnknownKindFrame } from "./eventMapper.fixtures.ts"; const taskQueued: AetherTask = { @@ -322,6 +322,7 @@ class FakeSocket implements AetherWebSocketLike { interface StreamHarness { readonly sockets: Array; readonly events: Array; + readonly ports: Array; readonly dropped: Array<{ key: string; detail: string }>; readonly durableOnly: Array; readonly connects: Array; @@ -335,6 +336,7 @@ function makeHarness( ): StreamHarness { const sockets: Array = []; const events: Array = []; + const ports: Array = []; const dropped: Array<{ key: string; detail: string }> = []; const durableOnly: Array = []; const connects: Array = []; @@ -342,6 +344,7 @@ function makeHarness( return { sockets, events, + ports, dropped, durableOnly, connects, @@ -365,6 +368,7 @@ function makeHarness( }, onConnected: () => Effect.sync(() => void connects.push(sockets.length)), onEvent: (event) => Effect.sync(() => void events.push(event)), + onPortsMessage: (message) => Effect.sync(() => void ports.push(message)), onFrameDropped: (problem) => Effect.sync(() => void dropped.push({ ...problem })), onConnectRetry: (failure) => Effect.sync(() => void connectRetries.push({ ...failure })), onDurableOnly: (reason) => Effect.sync(() => void durableOnly.push(reason)), @@ -419,6 +423,34 @@ describe("runAetherAgentStream", () => { }), ); + it.effect("routes ports-channel frames (snapshot + open/close) to onPortsMessage", () => + Effect.gen(function* () { + const harness = makeHarness({ + getTask: scriptedGetTask([taskProcessing]).getTask, + connectWorkspace: scriptedConnect([runningOutcome]).connectWorkspace, + }); + const fiber = yield* Effect.forkChild(runAetherAgentStream(harness.options)); + yield* settlePump; + const socket = harness.sockets[0]!; + + socket.message({ channel: "ports", type: "snapshot", ports: [3000, 5173] }); + socket.message({ channel: "ports", type: "change", action: "open", port: 8080 }); + socket.message({ channel: "ports", type: "change", action: "close", port: 3000 }); + // A malformed ports frame is ignored, not routed. + socket.message({ channel: "ports", type: "change", action: "open" }); + yield* settlePump; + + expect(harness.ports).toEqual([ + { _tag: "snapshot", ports: [3000, 5173] }, + { _tag: "change", action: "open", port: 8080 }, + { _tag: "change", action: "close", port: 3000 }, + ]); + expect(socket.closed).toBe(false); + + yield* Fiber.interrupt(fiber); + }), + ); + it.effect("reconnects after a server close: full re-attach + resubscribe + onConnected", () => Effect.gen(function* () { const harness = makeHarness({ diff --git a/apps/server/src/provider/Layers/aether/workspaceSocket.ts b/apps/server/src/provider/Layers/aether/workspaceSocket.ts index 014cb6181b18..3f485f03f396 100644 --- a/apps/server/src/provider/Layers/aether/workspaceSocket.ts +++ b/apps/server/src/provider/Layers/aether/workspaceSocket.ts @@ -50,6 +50,7 @@ import { parseAetherFileReadResponse, parseAetherGitDiffResponse, type AetherAgentEvent, + type AetherPortsMessage, type AetherFrameParseResult, type AetherWsFileReadSuccessResponse, type AetherWsGitDiffResult, @@ -433,6 +434,14 @@ export interface AetherAgentConnection { readonly readWorkspaceFile: ( path: string, ) => Effect.Effect; + /** + * The workspace's preview-gateway token (32-char), from the connect + * transport — authorizes cloud port previews for every port of this + * workspace. Used to build `{port}-{workspaceId8}-{token}.{previewDomain}`. + */ + readonly previewToken: string; + /** The workspace id, used as the port-preview subdomain prefix. */ + readonly workspaceId: string; } export interface AetherAgentStreamOptions { @@ -461,6 +470,8 @@ export interface AetherAgentStreamOptions { readonly onConnected: (connection: AetherAgentConnection) => Effect.Effect; /** One parsed agent event. */ readonly onEvent: (event: AetherAgentEvent) => Effect.Effect; + /** A ports-channel notification (port opened/closed) for cloud previews. */ + readonly onPortsMessage: (message: AetherPortsMessage) => Effect.Effect; /** * A dropped frame (unknown kind, malformed known kind, or a server * error-channel frame). Called once per distinct key per stream — the @@ -639,7 +650,12 @@ export const runAetherAgentStream = Effect.fn("runAetherAgentStream")(function* if (transport._tag === "unavailable") { return { _tag: "unavailable", reason: transport.reason } as const; } - return { _tag: "transport", websocketPath: transport.websocketPath } as const; + return { + _tag: "transport", + websocketPath: transport.websocketPath, + previewToken: transport.previewToken, + workspaceId: resolution.workspaceId, + } as const; }); // Once the stream has subscribed at least once, a transport-class REST @@ -833,6 +849,8 @@ export const runAetherAgentStream = Effect.fn("runAetherAgentStream")(function* JSON.stringify({ channel: "files", type: "read", requestId, path }), parse: parseAetherFileReadResponse, }), + previewToken: attached.previewToken, + workspaceId: attached.workspaceId, }); while (true) { @@ -861,6 +879,9 @@ export const runAetherAgentStream = Effect.fn("runAetherAgentStream")(function* yield* options.onEvent(parsed.event); break; } + case "ports": + yield* options.onPortsMessage(parsed.message); + break; case "ignored": // Another channel multiplexed on the same socket — not ours. break; diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c6e28dcef5c5..353930623623 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -103,6 +103,7 @@ import { type ParsedPreviewAnnotation, } from "~/lib/previewAnnotation"; import { cn } from "~/lib/utils"; +import { readLocalApi } from "~/localApi"; import { useUiStateStore } from "~/uiStateStore"; import { type TimestampFormat } from "@t3tools/contracts/settings"; import { formatChatTimestampTooltip, formatShortTimestamp } from "../../timestampFormat"; @@ -2211,15 +2212,45 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time ); }); +const PortPreviewCtaRow = memo(function PortPreviewCtaRow(props: { workEntry: TimelineWorkEntry }) { + const preview = props.workEntry.portPreview; + if (!preview) { + return null; + } + return ( + + ); +}); + const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { workEntry: TimelineWorkEntry; workspaceRoot: string | undefined; }) { const { workEntry, workspaceRoot } = props; - // Before any hooks: spawn CTA rows render their own component. + // Before any hooks: spawn CTA and port-preview rows render their own component. if (workEntry.agentSpawn) { return ; } + if (workEntry.portPreview) { + return ; + } return ; }); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index b8611d6575c6..dc728bea118e 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -87,6 +87,8 @@ export interface WorkLogEntry { toolLifecycleStatus?: WorkLogToolLifecycleStatus; /** Originating orchestration activity kind (e.g. `user-input.requested`) for row chrome. */ sourceActivityKind?: OrchestrationThreadActivity["kind"]; + /** Present on a `port.opened` row: a live workspace port + its preview URL. */ + portPreview?: { port: number; url: string }; /** Grouping key for subagent lifecycle rows (one row per agent). */ taskId?: string; /** Agent role (subagent_type) for labeled timeline rows. */ @@ -908,6 +910,13 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (isTaskActivity && payload && isBackgroundTaskActivity(payload)) { entry.isBackgroundTask = true; } + if (activity.kind === "port.opened" && payload) { + const port = payload.port; + const url = payload.url; + if (typeof port === "number" && typeof url === "string" && url.length > 0) { + entry.portPreview = { port, url }; + } + } const collapseKey = deriveToolLifecycleCollapseKey(entry); if (collapseKey) { entry.collapseKey = collapseKey; diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index bd525e6542e2..96173f485de3 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -192,6 +192,7 @@ const ProviderRuntimeEventType = Schema.Literals([ "config.warning", "deprecation.notice", "files.persisted", + "port.opened", "runtime.warning", "runtime.error", ]); @@ -243,6 +244,7 @@ const ModelReroutedType = Schema.Literal("model.rerouted"); const ConfigWarningType = Schema.Literal("config.warning"); const DeprecationNoticeType = Schema.Literal("deprecation.notice"); const FilesPersistedType = Schema.Literal("files.persisted"); +const PortOpenedType = Schema.Literal("port.opened"); const ToolDeniedType = Schema.Literal("tool.denied"); const RuntimeWarningType = Schema.Literal("runtime.warning"); const RuntimeErrorType = Schema.Literal("runtime.error"); @@ -1115,6 +1117,22 @@ const ProviderRuntimeFilesPersistedEvent = Schema.Struct({ }); export type ProviderRuntimeFilesPersistedEvent = typeof ProviderRuntimeFilesPersistedEvent.Type; +/** + * A network port opened inside the provider's workspace (e.g. an Aether cloud + * VM running a dev server). `url` is the ready-to-open preview URL. + */ +const PortOpenedPayload = Schema.Struct({ + port: Schema.Number, + url: TrimmedNonEmptyStringSchema, +}); +export type PortOpenedPayload = typeof PortOpenedPayload.Type; +const ProviderRuntimePortOpenedEvent = Schema.Struct({ + ...ProviderRuntimeEventBase.fields, + type: PortOpenedType, + payload: PortOpenedPayload, +}); +export type ProviderRuntimePortOpenedEvent = typeof ProviderRuntimePortOpenedEvent.Type; + const ProviderRuntimeToolDeniedEvent = Schema.Struct({ ...ProviderRuntimeEventBase.fields, type: ToolDeniedType, @@ -1183,6 +1201,7 @@ export const ProviderRuntimeEventV2 = Schema.Union([ ProviderRuntimeConfigWarningEvent, ProviderRuntimeDeprecationNoticeEvent, ProviderRuntimeFilesPersistedEvent, + ProviderRuntimePortOpenedEvent, ProviderRuntimeToolDeniedEvent, ProviderRuntimeWarningEvent, ProviderRuntimeErrorEvent, From 2a06dc421ee78b3719591acbf2ab00ebe783d60f Mon Sep 17 00:00:00 2001 From: Pranav Sharan Date: Sun, 9 Aug 2026 08:39:16 -0700 Subject: [PATCH 13/44] feat(aether): open cloud port preview in the in-app embedded browser (#14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Port N is live — Open preview' CTA now opens the workspace preview in the desktop embedded browser (right panel) via openPreviewSession + openBrowser, matching how discovered local ports open; falls back to the system browser / new tab on web or if the embedded session fails. Verified live: clicking the chip loads the microVM's app in an in-app webview. --- .../src/components/chat/MessagesTimeline.tsx | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 353930623623..b676a3a2d6d8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -104,7 +104,12 @@ import { } from "~/lib/previewAnnotation"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; +import { isPreviewSupportedInRuntime } from "~/previewStateStore"; +import { useRightPanelStore } from "~/rightPanelStore"; import { useUiStateStore } from "~/uiStateStore"; +import { previewEnvironment } from "../../state/preview"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { openPreviewSession } from "../preview/openPreviewSession"; import { type TimestampFormat } from "@t3tools/contracts/settings"; import { formatChatTimestampTooltip, formatShortTimestamp } from "../../timestampFormat"; @@ -2213,18 +2218,37 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time }); const PortPreviewCtaRow = memo(function PortPreviewCtaRow(props: { workEntry: TimelineWorkEntry }) { + const ctx = use(TimelineRowCtx); + const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); const preview = props.workEntry.portPreview; if (!preview) { return null; } + const threadRef = ctx.threadRef; + const openPortPreview = () => { + // Desktop: open the workspace preview in the in-app embedded browser (right + // panel), matching how discovered local ports open. Web (or a missing + // thread ref): fall back to the system browser / a new tab. + if (isPreviewSupportedInRuntime() && threadRef) { + void (async () => { + const result = await openPreviewSession({ openPreview, threadRef, url: preview.url }); + if (result._tag === "Failure") { + // Embedded preview failed (disconnected environment, unsupported + // server, invalid URL) — preserve the CTA's always-open behavior by + // opening the preview externally instead of silently no-opping. + void readLocalApi()?.shell.openExternal(preview.url); + return; + } + useRightPanelStore.getState().openBrowser(threadRef, result.value.tabId); + })(); + return; + } + void readLocalApi()?.shell.openExternal(preview.url); + }; return (