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 81ef25effc8e..c20fe3370c68 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/.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 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/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 7d79e9ecead9..39e8f4233570 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -14,6 +14,7 @@ import { DEFAULT_PROVIDER_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, MessageId, + ProviderDriverKind, T3_PROJECT_FILE_NAME, ThreadId, } from "@t3tools/contracts"; @@ -75,6 +76,9 @@ import { } from "../home/homeThreadList"; import { useMobileProjectGroupingSettings } from "../../state/project-grouping"; +/** Mirrors the web composer's own declaration (ChatView).*/ +const AETHER_DRIVER_KIND = ProviderDriverKind.make("aether"); + type WorkspaceMode = "local" | "worktree"; const EMPTY_BRANCH_REFS: ReadonlyArray = []; @@ -370,20 +374,6 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { if (t3ProjectFileData === null || t3ProjectFileData.truncated) return null; return parseT3ProjectFile(t3ProjectFileData.contents)?.defaultThreadEnvMode ?? null; }, [t3ProjectFileData]); - const defaultWorkspaceMode: WorkspaceMode = resolveDefaultThreadEnvMode({ - projectSetting: selectedProject?.defaultThreadEnvMode, - projectFile: t3ProjectFileDefaultMode, - globalDefault: selectedEnvironmentServerConfig?.settings.defaultThreadEnvMode ?? "local", - }); - // While unsettled the resolved default is provisional. Nothing may write - // it into the draft during that window (the auto-branch effect does), or - // the frozen interim value beats the t3.json default once it loads. - const defaultWorkspaceModeSettled = isDefaultThreadEnvModeSettled({ - explicitMode: selectedProjectDraft.workspaceSelection?.mode, - projectSetting: selectedProject?.defaultThreadEnvMode, - projectFilePending: t3ProjectFileQuery.isPending, - }); - const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; // Keep the user's explicit choice separate from the resolved display value: @@ -428,6 +418,44 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const selectedModelKey = selectedModel ? `${selectedModel.instanceId}:${selectedModel.model}` : null; + // Resolved AFTER the model selection on purpose: the Aether driver turns the + // thread's checkout into a one-way MIRROR of its cloud VM — every settle + // resets it and re-applies the cloud tree. Pointed at the SHARED project + // checkout that would discard the user's own work, so an untouched draft + // with an Aether model selected defaults to an isolated worktree, the same + // safety default the web composer applies. An explicit pick still wins: + // `workspaceSelection.mode` is read ahead of this default below. + const selectedProviderIsAether = + modelOptions.find((option) => option.key === selectedModelKey)?.providerDriver === + AETHER_DRIVER_KIND; + const defaultWorkspaceMode: WorkspaceMode = selectedProviderIsAether + ? "worktree" + : resolveDefaultThreadEnvMode({ + projectSetting: selectedProject?.defaultThreadEnvMode, + projectFile: t3ProjectFileDefaultMode, + globalDefault: selectedEnvironmentServerConfig?.settings.defaultThreadEnvMode ?? "local", + }); + // While unsettled the resolved default is provisional. Nothing may write + // it into the draft during that window (the auto-branch effect does), or + // the frozen interim value beats the t3.json default once it loads. + const defaultWorkspaceModeSettled = isDefaultThreadEnvModeSettled({ + explicitMode: selectedProjectDraft.workspaceSelection?.mode, + projectSetting: selectedProject?.defaultThreadEnvMode, + projectFilePending: t3ProjectFileQuery.isPending, + }); + // Only an EXPLICIT pick pins the mode. Incidental writes (selectBranch, + // setStartFromOrigin) carry the resolved mode along so the queued-task + // snapshot stays right, which would otherwise freeze the Aether worktree + // default into the draft as if the user had chosen it — and keep worktree + // after switching to a provider that never wanted it. Absent flag = a draft + // from before this field, treated as user-set so an existing pick stands. + const storedWorkspaceSelection = selectedProjectDraft.workspaceSelection; + const workspaceModeUserSet = + storedWorkspaceSelection !== undefined && storedWorkspaceSelection.modeUserSet !== false; + const workspaceMode = + workspaceModeUserSet && storedWorkspaceSelection !== undefined + ? storedWorkspaceSelection.mode + : defaultWorkspaceMode; const selectedModelOption = modelOptions.find( @@ -599,6 +627,8 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { updateComposerDraftSettings(selectedProjectDraftKey, { workspaceSelection: { mode, + // The one write that means "the user chose this". + modeUserSet: true, branch: selectedBranchName, worktreePath: selectedWorktreePath, ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), @@ -616,13 +646,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { updateComposerDraftSettings(selectedProjectDraftKey, { workspaceSelection: { mode: workspaceMode, + modeUserSet: workspaceModeUserSet, branch: branch.name, worktreePath: normalizeSelectedWorktreePath(selectedProject, branch), ...(draftStartFromOrigin !== undefined ? { startFromOrigin: draftStartFromOrigin } : {}), }, }); }, - [draftStartFromOrigin, selectedProject, selectedProjectDraftKey, workspaceMode], + [ + draftStartFromOrigin, + selectedProject, + selectedProjectDraftKey, + workspaceMode, + workspaceModeUserSet, + ], ); const setStartFromOrigin = useCallback( @@ -633,13 +670,20 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { updateComposerDraftSettings(selectedProjectDraftKey, { workspaceSelection: { mode: workspaceMode, + modeUserSet: workspaceModeUserSet, branch: selectedBranchName, worktreePath: selectedWorktreePath, startFromOrigin: value, }, }); }, - [selectedBranchName, selectedProjectDraftKey, selectedWorktreePath, workspaceMode], + [ + selectedBranchName, + selectedProjectDraftKey, + selectedWorktreePath, + workspaceMode, + workspaceModeUserSet, + ], ); const refreshBranches = branchState.refresh; @@ -709,7 +753,11 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { runtimeMode: message.runtimeMode, interactionMode: message.interactionMode, workspaceSelection: { + // The queued task's mode is a decision already made for it, so + // reopening it for editing keeps that mode pinned rather than + // re-deriving a default over the top of it. mode: message.creation.workspaceMode, + modeUserSet: true, branch: message.creation.branch, worktreePath: message.creation.worktreePath, startFromOrigin: message.creation.startFromOrigin ?? false, @@ -744,9 +792,17 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { return null; } const workspaceSelection = draft.workspaceSelection; - // Fall back to the resolved mode (server default) so queued tasks drain - // with the same mode the composer displayed. - const mode = workspaceSelection?.mode ?? workspaceMode; + // The stored mode outranks the resolved one ONLY when the user picked + // it. A mode that is merely a carried-along default must not win here: + // selecting a branch under a local provider stores `local`, and + // switching to Aether afterwards has to queue the task in the isolated + // worktree its one-way mirror requires — the composer already displays + // that, and the queue has to agree or the mirror claims the shared + // checkout on drain. + const mode = + workspaceSelection === undefined || workspaceSelection.modeUserSet === false + ? workspaceMode + : workspaceSelection.mode; // When the selection is the stand-in built from the queued snapshot, // persist the original (possibly absent) snapshot values — the // stand-in's placeholder title/workspaceRoot must never be written back 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 e1d46fd858e9..41e9b4f006fc 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -14,6 +14,7 @@ import { import { buildPendingUserInputAnswers, buildThreadFeed, + derivePendingUserInputs, deriveThreadFeedPresentation, isPendingUserInputOptionSelected, setPendingUserInputCustomAnswer, @@ -150,6 +151,52 @@ 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: { selectedOptionLabels: ["All"] }, + }), + ).toBeNull(); + expect( + buildPendingUserInputAnswers(pending[0]!.questions, { + q1: { selectedOptionLabels: ["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({ @@ -203,6 +250,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 fbcb2e1c7e2a..8d07faed3176 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 { @@ -203,7 +207,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 { @@ -433,6 +443,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; @@ -629,6 +646,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"; @@ -1566,6 +1584,7 @@ export function buildThreadFeed( icon: workEntryIcon(entry), toolLike: workLogEntryIsToolLike(entry), status: workEntryStatus(entry), + ...(entry.portPreview ? { portPreview: entry.portPreview } : {}), }, }; }), diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index fed97e81e08f..70f05d927c53 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -66,6 +66,48 @@ describe("mobile composer drafts", () => { }); }); + it("round-trips modeUserSet:false, which the Aether worktree default depends on", () => { + // The decoder strips undeclared keys, so an omission here would drop the + // flag on every app restart — and absence reads as user-set, pinning the + // mode and silently disabling the provider-derived worktree default. + const persisted = { + schemaVersion: 1, + drafts: { + "new-task:environment-1:project-1": { + text: "", + attachments: [], + workspaceSelection: { + mode: "local" as const, + modeUserSet: false, + branch: "main", + worktreePath: null, + }, + }, + }, + }; + const decoded = decodePersistedComposerDrafts(persisted); + expect(decoded["new-task:environment-1:project-1"]?.workspaceSelection).toEqual({ + mode: "local", + modeUserSet: false, + branch: "main", + worktreePath: null, + }); + // An explicit pick round-trips too. + const pinned = decodePersistedComposerDrafts({ + ...persisted, + drafts: { + "new-task:environment-1:project-1": { + ...persisted.drafts["new-task:environment-1:project-1"], + workspaceSelection: { + ...persisted.drafts["new-task:environment-1:project-1"].workspaceSelection, + modeUserSet: true, + }, + }, + }, + }); + expect(pinned["new-task:environment-1:project-1"]?.workspaceSelection?.modeUserSet).toBe(true); + }); + it("keeps legacy content-only drafts and rejects invalid selector state", () => { expect( decodePersistedComposerDrafts({ diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 24fa547e2728..dea257bbb70e 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -55,6 +55,15 @@ export interface ComposerDraftContent { export interface ComposerDraftWorkspaceSelection { readonly mode: "local" | "worktree"; + /** + * True once the user PICKED the mode. While false the stored `mode` is only + * a resolved default that an incidental write (a branch pick, a + * start-from-origin toggle) carried along, so a provider-derived default may + * still override it — the Aether one-way-mirror default does. Absent means a + * draft written before this field existed: treated as user-set, so an + * existing pick is never surprise-flipped. + */ + readonly modeUserSet?: boolean; readonly branch: string | null; readonly worktreePath: string | null; readonly startFromOrigin?: boolean; @@ -67,6 +76,11 @@ export type ComposerDraftSettingsUpdate = Pick< const ComposerDraftWorkspaceSelectionSchema = Schema.Struct({ mode: Schema.Literals(["local", "worktree"]), + // MUST stay declared: the decoder strips undeclared keys, so omitting it + // here would drop a persisted `false` on hydrate — and absence reads as + // user-set, which would pin the mode and silently disable the Aether + // worktree safety default after every app restart. + modeUserSet: Schema.optional(Schema.Boolean), branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), startFromOrigin: Schema.optional(Schema.Boolean), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index e9a625dd91cf..f1a8d632f149 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -611,6 +611,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, @@ -796,8 +799,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 be596b36b850..0445df313f6a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -304,6 +304,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runtimeMode: "full-access", branch: null, worktreePath: null, + worktreeManaged: false, latestTurn: { turnId: asTurnId("turn-1"), state: "completed", @@ -423,6 +424,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 3e77f9cf875a..06a68d41b8a6 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -418,6 +418,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", @@ -454,6 +455,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", @@ -492,6 +494,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", @@ -934,6 +937,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", @@ -1567,6 +1571,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, @@ -1774,6 +1779,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, @@ -1910,6 +1916,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, @@ -2055,6 +2062,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, @@ -2334,6 +2342,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, @@ -2455,6 +2464,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..0125c57400f4 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,279 @@ 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", + expectedBranch: null, + expectedWorktreePath: null, + }), + ); + + 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", + expectedBranch: null, + expectedWorktreePath: null, + }), + ); + + // 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", + expectedBranch: null, + expectedWorktreePath: null, + }), + ); + + 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/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 03253797242e..17e367b3cbe2 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/orchestration/Layers/ThreadDeletionReactor.ts b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts index a026f5ad81bd..82e21ec9cf36 100644 --- a/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts +++ b/apps/server/src/orchestration/Layers/ThreadDeletionReactor.ts @@ -6,6 +6,7 @@ import * as Layer from "effect/Layer"; import * as Stream from "effect/Stream"; import { ProviderService } from "../../provider/Services/ProviderService.ts"; +import * as AetherTerminalManager from "../../terminal/AetherTerminalManager.ts"; import * as TerminalManager from "../../terminal/Manager.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { @@ -41,6 +42,7 @@ const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const providerService = yield* ProviderService; const terminalManager = yield* TerminalManager.TerminalManager; + const aetherTerminalManager = yield* AetherTerminalManager.AetherTerminalManager; const stopProviderSession = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => logCleanupCauseUnlessInterrupted({ @@ -56,12 +58,29 @@ const make = Effect.gen(function* () { threadId, }); + /** + * A cloud terminal outlives its thread unless something closes it: the + * workspace socket and its 30s keep-alive hold the (paid) VM warm + * indefinitely. This belongs HERE rather than at the dispatch site because + * `project.delete --force` has the decider emit child `thread.deleted` + * events internally — the dispatched command stays `project.delete`, so a + * command-keyed cleanup never runs for those threads. Every source of + * `thread.deleted` reaches this reactor. + */ + const closeCloudTerminals = (threadId: ThreadDeletedEvent["payload"]["threadId"]) => + logCleanupCauseUnlessInterrupted({ + effect: aetherTerminalManager.close({ threadId }), + message: "thread deletion cleanup skipped cloud terminal close", + threadId, + }); + const processThreadDeleted = Effect.fn("processThreadDeleted")(function* ( event: ThreadDeletedEvent, ) { const { threadId } = event.payload; yield* stopProviderSession(threadId); yield* closeThreadTerminals(threadId); + yield* closeCloudTerminals(threadId); }); const processThreadDeletedSafely = (event: ThreadDeletedEvent) => diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index a48bb29e154b..ee9ccb4639c0 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -834,12 +834,67 @@ 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": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + // The attach describes a worktree whose creation took seconds; a + // `thread.meta.update` repointing the thread can have landed since. An + // unconditional apply would revert that newer pick AND mark the user's + // own worktree driver-owned — the marker is exactly what makes drivers + // drop their clean-tree guards, so it must never land on a path the + // bootstrap did not create. When the expectation no longer holds this + // projects as a no-op: the fresh worktree stays unmarked (drivers keep + // guarding it) and the user's selection stands. + const attachIsCurrent = + thread.branch === command.expectedBranch && + thread.worktreePath === command.expectedWorktreePath; + 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: attachIsCurrent + ? { + threadId: command.threadId, + branch: command.branch, + worktreePath: command.worktreePath, + worktreeManaged: true, + updatedAt: occurredAt, + } + : { threadId: command.threadId, updatedAt: thread.updatedAt }, + }; + } + case "thread.title.regeneration.complete": { const thread = yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/decider.worktreeAttach.test.ts b/apps/server/src/orchestration/decider.worktreeAttach.test.ts new file mode 100644 index 000000000000..fb3477a7250a --- /dev/null +++ b/apps/server/src/orchestration/decider.worktreeAttach.test.ts @@ -0,0 +1,123 @@ +import { + CommandId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; +const THREAD_UPDATED_AT = "2026-01-01T00:00:05.000Z"; + +function makeReadModel(input: { + readonly branch: string | null; + readonly worktreePath: string | null; +}): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("aether"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: input.branch, + worktreePath: input.worktreePath, + latestTurn: null, + createdAt: NOW, + updatedAt: THREAD_UPDATED_AT, + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + pinnedAt: null, + pinOrderKey: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + ], + updatedAt: NOW, + }; +} + +const attachCommand = { + type: "thread.worktree.attach-managed", + commandId: CommandId.make("cmd-attach-managed"), + threadId: ThreadId.make("thread-1"), + branch: "t3code/1234abcd", + worktreePath: "/tmp/worktrees/thread-1", + expectedBranch: null, + expectedWorktreePath: null, +} as const; + +it.layer(NodeServices.layer)("thread.worktree.attach-managed decider", (it) => { + it.effect("marks the worktree managed when the thread is still where the bootstrap left it", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: attachCommand, + readModel: makeReadModel({ branch: null, worktreePath: null }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe("thread.meta-updated"); + if (events[0]?.type === "thread.meta-updated") { + expect(events[0].payload.worktreePath).toBe("/tmp/worktrees/thread-1"); + expect(events[0].payload.branch).toBe("t3code/1234abcd"); + expect(events[0].payload.worktreeManaged).toBe(true); + } + }), + ); + + it.effect("no-ops when the user repointed the thread while the worktree was being created", () => + Effect.gen(function* () { + // The stale attach must neither revert the newer selection nor mark the + // user's own worktree driver-owned — that marker is what makes drivers + // drop their clean-tree guards. + const event = yield* decideOrchestrationCommand({ + command: attachCommand, + readModel: makeReadModel({ + branch: "feature/user-pick", + worktreePath: "/tmp/user-worktrees/feature-user-pick", + }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe("thread.meta-updated"); + if (events[0]?.type === "thread.meta-updated") { + expect(events[0].payload.worktreePath).toBeUndefined(); + expect(events[0].payload.branch).toBeUndefined(); + expect(events[0].payload.worktreeManaged).toBeUndefined(); + // A projected no-op: the thread's own updatedAt is carried forward. + expect(events[0].payload.updatedAt).toBe(THREAD_UPDATED_AT); + } + }), + ); + + it.effect("no-ops when only the branch moved under the bootstrap", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: attachCommand, + readModel: makeReadModel({ branch: "feature/renamed", worktreePath: null }), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events[0]?.type).toBe("thread.meta-updated"); + if (events[0]?.type === "thread.meta-updated") { + expect(events[0].payload.worktreeManaged).toBeUndefined(); + expect(events[0].payload.updatedAt).toBe(THREAD_UPDATED_AT); + } + }), + ); +}); 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 f486dcb2bcbc..0284250b60f1 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -297,6 +297,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, @@ -455,7 +458,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 bebd8fbb4a7d..e74ae8d1ead8 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -88,6 +88,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", @@ -151,6 +152,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 b137cedfbedd..034a7d8658d9 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -53,6 +53,7 @@ import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; +import Migration0041 from "./Migrations/041_ProjectionThreadsWorktreeManaged.ts"; /** * Migration loader with all migrations defined inline. @@ -105,6 +106,7 @@ export const migrationEntries = [ [38, "ProjectionThreadsPinOrderKey", Migration0038], [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039], [40, "ProjectionProjectFaviconPath", Migration0040], + [41, "ProjectionThreadsWorktreeManaged", Migration0041], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/041_ProjectionThreadsWorktreeManaged.ts b/apps/server/src/persistence/Migrations/041_ProjectionThreadsWorktreeManaged.ts new file mode 100644 index 000000000000..c073c09cc076 --- /dev/null +++ b/apps/server/src/persistence/Migrations/041_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/AetherMirrorGuards.test.ts b/apps/server/src/provider/AetherMirrorGuards.test.ts new file mode 100644 index 000000000000..0fcb890ade3d --- /dev/null +++ b/apps/server/src/provider/AetherMirrorGuards.test.ts @@ -0,0 +1,360 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; + +import { + AETHER_MIRROR_REFUSAL, + aetherMirrorWriteFileError, + guardAetherQueuedMutation, + guardAetherRemoveWorktree, + guardAetherVcsMutation, + guardAetherWriteFile, +} 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.effect("guardAetherWriteFile refuses a write that descends INTO a mirror", () => + Effect.gen(function* () { + const registry = yield* make; + yield* registry.register("/repos/parent/.worktrees/aether-mirror", "aether:thread-1"); + + const refused = yield* Effect.flip( + guardAetherWriteFile( + registry, + { cwd: "/repos/parent", relativePath: ".worktrees/aether-mirror/app.ts" }, + Effect.succeed("written"), + ), + ); + expect(refused._tag).toBe("ProjectWriteFileError"); + expect(refused.message).toBe(AETHER_MIRROR_REFUSAL); + + expect( + yield* guardAetherWriteFile( + registry, + { cwd: "/repos/parent", relativePath: "src/app.ts" }, + Effect.succeed("written"), + ), + ).toBe("written"); + }), + ); + + it.effect("blocks a registration from landing between the check and the mutation", () => + // The race the frozen region exists for: the guard reads "not owned", an + // Aether session claims the checkout, and THEN the local write lands in + // what is by now a one-way mirror — silently corrupting the next + // reset-and-apply. + Effect.gen(function* () { + const order: Array = []; + const registry = yield* make; + const mutationStarted = yield* Deferred.make(); + const releaseMutation = yield* Deferred.make(); + + const guarded = yield* Effect.forkChild( + guardAetherVcsMutation( + registry, + "vcs.pull", + "/repos/mirror", + Effect.gen(function* () { + yield* Deferred.succeed(mutationStarted, undefined); + yield* Deferred.await(releaseMutation); + order.push("mutation"); + return "ran"; + }), + ), + ); + // The guard has passed its check and is inside the mutation. + yield* Deferred.await(mutationStarted); + + const registering = yield* Effect.forkChild( + registry + .register("/repos/mirror", "aether:thread-1") + .pipe(Effect.tap(() => Effect.sync(() => order.push("register")))), + ); + // Real elapsed time, deliberately: the registration's own async work + // (realpath) must have had every chance to finish, which a virtual clock + // would not give it. + // @effect-diagnostics-next-line globalTimers:off + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 100))); + // Still nothing: the claim cannot appear underneath an in-flight mutation. + expect(order).toEqual([]); + + yield* Deferred.succeed(releaseMutation, undefined); + expect(yield* Fiber.join(guarded)).toBe("ran"); + yield* Fiber.join(registering); + expect(order).toEqual(["mutation", "register"]); + + // …and once registered, the next mutation is refused. + const refused = yield* Effect.flip( + guardAetherVcsMutation(registry, "vcs.pull", "/repos/mirror", Effect.succeed("ran")), + ); + expect(refused.detail).toContain(AETHER_MIRROR_REFUSAL); + }), + ); + + it.effect("guardAetherQueuedMutation refuses through its queue, not its error channel", () => + Effect.gen(function* () { + const registry = yield* make; + yield* registry.register("/repos/mirror", "aether:thread-1"); + const refusals: Array = []; + const ran: Array = []; + + yield* guardAetherQueuedMutation( + registry, + "/repos/mirror", + Effect.sync(() => void refusals.push("refused")), + Effect.sync(() => void ran.push("ran")), + ); + expect(refusals).toEqual(["refused"]); + expect(ran).toEqual([]); + + yield* guardAetherQueuedMutation( + registry, + "/repos/elsewhere", + Effect.sync(() => void refusals.push("refused")), + Effect.sync(() => void ran.push("ran")), + ); + expect(ran).toEqual(["ran"]); + }), + ); + + it.effect("a registration cannot land between a QUEUED mutation's check and its run", () => + // The gap the round-5 lock left open: `git.runStackedAction` checked + // ownership outside any frozen region, so it held no reader permit and the + // exclusive registration never waited — its commit/branch/push could land + // in a checkout that had just become a one-way mirror. + Effect.gen(function* () { + const order: Array = []; + const registry = yield* make; + const runStarted = yield* Deferred.make(); + const releaseRun = yield* Deferred.make(); + + const guarded = yield* Effect.forkChild( + guardAetherQueuedMutation( + registry, + "/repos/mirror", + Effect.sync(() => void order.push("refused")), + Effect.gen(function* () { + yield* Deferred.succeed(runStarted, undefined); + yield* Deferred.await(releaseRun); + order.push("stacked-action"); + }), + ), + ); + yield* Deferred.await(runStarted); + + const registering = yield* Effect.forkChild( + registry + .register("/repos/mirror", "aether:thread-1") + .pipe(Effect.tap(() => Effect.sync(() => order.push("register")))), + ); + // Real elapsed time so the registration's own realpath can resolve. + // @effect-diagnostics-next-line globalTimers:off + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 100))); + expect(order).toEqual([]); + + yield* Deferred.succeed(releaseRun, undefined); + yield* Fiber.join(guarded); + yield* Fiber.join(registering); + expect(order).toEqual(["stacked-action", "register"]); + }), + ); + + it.effect("a register on an UNRELATED checkout does not wait for an in-flight mutation", () => + // The freeze is scoped to what a mutation writes, not process-global. A + // stacked action holds it across its network push, so a global freeze + // would let one slow (or hung) push on project A stall every Aether + // session start — including project B, which it can never touch. + Effect.gen(function* () { + const order: Array = []; + const registry = yield* make; + const pushStarted = yield* Deferred.make(); + const releasePush = yield* Deferred.make(); + + const guarded = yield* Effect.forkChild( + guardAetherQueuedMutation( + registry, + "/repos/projectA", + Effect.sync(() => void order.push("refused")), + Effect.gen(function* () { + yield* Deferred.succeed(pushStarted, undefined); + yield* Deferred.await(releasePush); + order.push("projectA-push"); + }), + ), + ); + yield* Deferred.await(pushStarted); + + // Project B's session starts WHILE project A's push is still running. + yield* registry.register("/repos/projectB", "aether:thread-b"); + order.push("projectB-register"); + expect(order).toEqual(["projectB-register"]); + // …and it really did claim B. + expect(yield* registry.ownsCwd("/repos/projectB")).toBe(true); + + yield* Deferred.succeed(releasePush, undefined); + yield* Fiber.join(guarded); + expect(order).toEqual(["projectB-register", "projectA-push"]); + }), + ); + + it.effect("a register on a checkout the mutation writes INTO still waits", () => + // The other half: scoping must not lose same-checkout serialization, nor + // the descend-into-a-mirror case a file write can reach. + Effect.gen(function* () { + const order: Array = []; + const registry = yield* make; + const writeStarted = yield* Deferred.make(); + const releaseWrite = yield* Deferred.make(); + + const guarded = yield* Effect.forkChild( + guardAetherWriteFile( + registry, + { cwd: "/repos/parent", relativePath: ".worktrees/mirror/app.ts" }, + Effect.gen(function* () { + yield* Deferred.succeed(writeStarted, undefined); + yield* Deferred.await(releaseWrite); + order.push("write"); + }), + ), + ); + yield* Deferred.await(writeStarted); + + const registering = yield* Effect.forkChild( + registry + .register("/repos/parent/.worktrees/mirror", "aether:thread-1") + .pipe(Effect.tap(() => Effect.sync(() => order.push("register")))), + ); + // @effect-diagnostics-next-line globalTimers:off + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 100))); + expect(order).toEqual([]); + + yield* Deferred.succeed(releaseWrite, undefined); + yield* Fiber.join(guarded); + yield* Fiber.join(registering); + expect(order).toEqual(["write", "register"]); + }), + ); + + it.effect("a removeWorktree blocks a same-basename registration ANYWHERE on disk", () => + // `ownsTargetPath` also refuses on a bare basename match, because + // `git worktree remove ` resolves a bare component to a worktree the + // request never spells out. Freezing only the paths the request names + // would leave that worktree registerable elsewhere between the check and + // the delete — and git would then remove the mirror it had just claimed. + Effect.gen(function* () { + const order: Array = []; + const registry = yield* make; + const removalStarted = yield* Deferred.make(); + const releaseRemoval = yield* Deferred.make(); + + const guarded = yield* Effect.forkChild( + guardAetherRemoveWorktree( + registry, + { cwd: "/repos/projA", path: "feature-x" }, + Effect.gen(function* () { + yield* Deferred.succeed(removalStarted, undefined); + yield* Deferred.await(releaseRemoval); + order.push("removed"); + return "removed"; + }), + ), + ); + yield* Deferred.await(removalStarted); + + // The worktree git would actually delete lives nowhere near the request: + // only the BASENAME ties them together. + const registering = yield* Effect.forkChild( + registry + .register("/var/worktrees/projA/feature-x", "aether:thread-1") + .pipe(Effect.tap(() => Effect.sync(() => order.push("register")))), + ); + // @effect-diagnostics-next-line globalTimers:off + yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 100))); + expect(order).toEqual([]); + + yield* Deferred.succeed(releaseRemoval, undefined); + yield* Fiber.join(guarded); + yield* Fiber.join(registering); + expect(order).toEqual(["removed", "register"]); + }), + ); + + 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("aether_mirror_read_only"); + // The message is derived from the aether_mirror_read_only failure literal — + // 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..82f80e76134d --- /dev/null +++ b/apps/server/src/provider/AetherMirrorGuards.ts @@ -0,0 +1,156 @@ +/** + * 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 { AETHER_MIRROR_REFUSAL, GitCommandError, ProjectWriteFileError } from "@t3tools/contracts"; +// @effect-diagnostics nodeBuiltinImport:off +import * as NodePath from "node:path"; + +import * as Effect from "effect/Effect"; + +export { AETHER_MIRROR_REFUSAL }; + +/** The registry surface the guards need — structurally AetherMirrorRegistry. */ +export interface AetherMirrorOwnership { + /** + * The check and the mutation it authorises run INSIDE this, so a session + * cannot register the checkout in between and turn an allowed write into a + * write onto a live one-way mirror. + */ + readonly whileClaimsFrozen: ( + writes: ReadonlyArray, + effect: Effect.Effect, + ) => Effect.Effect; + /** For a check that consults every claim regardless of where it lives. */ + readonly whileAllClaimsFrozen: ( + effect: Effect.Effect, + ) => Effect.Effect; + readonly ownsCwd: (cwd: string) => Effect.Effect; + readonly ownsTargetPath: (cwd: string, target: string) => Effect.Effect; + readonly ownsPathWithin: (cwd: string, target: string) => Effect.Effect; +} + +/** 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 => + registry.whileClaimsFrozen( + [cwd], + 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 => + // ALL claims, not just the paths this request names: `ownsTargetPath` also + // refuses on a bare basename match, because `git worktree remove ` + // resolves a bare component to a worktree the request never spells out. A + // path-scoped freeze would leave a same-named mirror registerable elsewhere + // between the check and the delete, and git would then remove it. + registry.whileAllClaimsFrozen( + 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 guard for a mutation that reports through a QUEUE rather than its own + * error channel (`git.runStackedAction` streams progress events). The refusal + * is handed to `onRefused` instead of failing the returned effect, but the + * check and the run still share ONE frozen region — this site used to check + * ownership outside any region at all, so it took no reader permit and an + * exclusive registration never waited for the commit/branch/push it was about + * to race. + */ +export const guardAetherQueuedMutation = ( + registry: AetherMirrorOwnership, + cwd: string, + onRefused: Effect.Effect, + run: Effect.Effect, +): Effect.Effect => + registry.whileClaimsFrozen( + [cwd], + Effect.gen(function* () { + if (yield* registry.ownsCwd(cwd)) { + return yield* onRefused; + } + return yield* run; + }), + ); + +/** + * `projects.writeFile` resolves `relativePath` under `cwd`, so a PARENT + * project cwd can descend into an active mirror — hence `ownsPathWithin` + * rather than `ownsCwd`. Same frozen region as the VCS guards: the check and + * the write are one step as far as registration is concerned. + */ +export const guardAetherWriteFile = ( + registry: AetherMirrorOwnership, + input: { readonly cwd: string; readonly relativePath: string }, + effect: Effect.Effect, +): Effect.Effect => + registry.whileClaimsFrozen( + // The resolved FILE, which may sit under a mirror this cwd merely + // descends into — that descendant is what the claim would cover. + [NodePath.resolve(input.cwd, input.relativePath)], + Effect.gen(function* () { + if (yield* registry.ownsPathWithin(input.cwd, input.relativePath)) { + return yield* aetherMirrorWriteFileError(input); + } + 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: "aether_mirror_read_only", + }); diff --git a/apps/server/src/provider/AetherMirrorRegistry.test.ts b/apps/server/src/provider/AetherMirrorRegistry.test.ts new file mode 100644 index 000000000000..c2808fcaf9c5 --- /dev/null +++ b/apps/server/src/provider/AetherMirrorRegistry.test.ts @@ -0,0 +1,115 @@ +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 Path from "effect/Path"; + +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); + }), + ); + + it.effect("symlink bypass: a symlinked path to a registered mirror still hits its claim", () => + Effect.gen(function* () { + // Real directories: `NodePath.resolve` collapses `..`/`.` but not + // symlinks, so a mirror registered by its real path was invisible to + // every guard when the RPC arrived through a link to the same checkout. + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const base = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-mirror-registry-" }); + const real = yield* fileSystem.realPath(base); + const mirror = path.join(real, "checkout"); + const link = path.join(real, "linked-checkout"); + yield* fileSystem.makeDirectory(mirror, { recursive: true }); + yield* fileSystem.symlink(mirror, link); + + const registry = yield* make; + yield* registry.register(mirror, "aether:thread-1"); + + expect(yield* registry.ownsCwd(link)).toBe(true); + expect(yield* registry.ownsTargetPath(real, "linked-checkout")).toBe(true); + expect(yield* registry.ownsPathWithin(link, "app.ts")).toBe(true); + // Deregistering through the link releases the same claim. + yield* registry.deregister(link, "aether:thread-1"); + expect(yield* registry.ownsCwd(mirror)).toBe(false); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/provider/AetherMirrorRegistry.ts b/apps/server/src/provider/AetherMirrorRegistry.ts new file mode 100644 index 000000000000..e30c8c35ddc7 --- /dev/null +++ b/apps/server/src/provider/AetherMirrorRegistry.ts @@ -0,0 +1,285 @@ +/** + * 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 NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Semaphore from "effect/Semaphore"; + +/** + * `realpath`, or `undefined` when the path is simply not on disk yet. ENOENT + * and ENOTDIR are the only two errnos that mean that; every other failure is + * real and rethrown rather than normalized into a wrong key. + */ +const realpathIfExists = async (candidate: string): Promise => { + try { + return await NodeFSP.realpath(candidate); + } catch (cause) { + const code = (cause as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENOTDIR") { + return undefined; + } + throw cause; + } +}; + +/** + * The claim key for a checkout path: the REAL path of its nearest existing + * ancestor plus the segments below it. `NodePath.resolve` alone collapses + * `..`/`.` but does NOT resolve symlinks, so a mirror registered by its real + * path and looked up through a symlinked path to the same checkout (macOS + * `/tmp` → `/private/tmp`, a symlinked worktree root) produced two different + * keys — and every `owns*` guard answered `false` for the very checkout it + * exists to protect. Deregistration canonicalizes the same way and stays + * stable after the worktree is removed, because the walk resumes at the + * surviving parent. + */ +const canonicalize = (target: string): Effect.Effect => + Effect.promise(async () => { + const resolved = NodePath.resolve(target); + const trailing: Array = []; + let probe = resolved; + for (;;) { + const real = await realpathIfExists(probe); + if (real !== undefined) { + return trailing.length === 0 ? real : NodePath.join(real, ...trailing); + } + const parent = NodePath.dirname(probe); + if (parent === probe) { + // Not even the filesystem root resolved — nothing left to canonicalize. + return resolved; + } + trailing.unshift(NodePath.basename(probe)); + probe = parent; + } + }); + +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; + /** + * Run `effect` while no claim can appear over the paths it WRITES. The + * write guards need this because an ownership check and the mutation it + * authorises are two steps — a session registering in between would let a + * local write reach a checkout that is an active one-way mirror by the + * time it hits disk, breaking the next reset-and-apply. + * + * `writes` names every path the effect may write: the mutation's cwd, plus + * the specific descendant a guard can reach (a file write, a worktree + * removal). The freeze is SCOPED to those paths and their ancestors, so a + * mutation on one checkout never blocks a registration on an unrelated + * one — which matters because a guarded mutation holds this across slow + * network I/O (a stacked action's push, a pull), and a process-global + * freeze would let one hung push stall every Aether session start. + * + * A registration conflicts with exactly the mutations writing AT or UNDER + * it. A mutation in an ANCESTOR of the claim does not: git operations in a + * parent repository do not write its linked worktrees, and the two guards + * that CAN reach a descendant declare that descendant in `writes`. + */ + readonly whileClaimsFrozen: ( + writes: ReadonlyArray, + effect: Effect.Effect, + ) => Effect.Effect; + /** + * Run `effect` while NO claim anywhere can appear or vanish. Required by + * the one guard whose answer does not depend on a path: `ownsTargetPath` + * also refuses on a bare BASENAME match against every active claim, + * because `git worktree remove ` resolves a bare component to a + * worktree whose real location the request never names. Freezing only the + * paths that request mentions would leave a same-named mirror registerable + * elsewhere on disk between the check and the delete — and git would then + * remove the live mirror it just claimed. + * + * Deliberately NOT used by the path-scoped guards: this blocks every + * registration for its duration, so putting a slow network mutation (a + * stacked action's push, a pull) under it would recreate the process-wide + * stall the path scoping exists to prevent. Registrations are short, so a + * removal waits only on them. + */ + readonly whileAllClaimsFrozen: ( + effect: Effect.Effect, + ) => 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") {} + +/** + * Permits on a path's claim lock: a frozen region takes one, a + * register/deregister of that exact path takes them all. The count only bounds + * how many guarded mutations may touch one path at once before they queue — + * far above anything a user-driven RPC surface produces. + */ +const CLAIM_LOCK_PERMITS = 1024; + +/** Every path from the filesystem root down to `path`, root first. */ +const ancestorChain = (path: string): ReadonlyArray => { + const chain: Array = []; + let current = path; + for (;;) { + chain.unshift(current); + const parent = NodePath.dirname(current); + if (parent === current) { + return chain; + } + current = parent; + } +}; + +export const make = Effect.sync(() => { + const claims = new Map>(); + // One lock per canonical path. A frozen region holds a reader on every + // ancestor of what it writes; a registration takes its OWN path exclusively. + // So a claim conflicts with exactly the mutations writing at or under it, + // and unrelated checkouts share no key. `makeUnsafe` so handing a lock out + // cannot yield between the map's get and set. + // Guards every claim REGARDLESS of location, for the checks that consult + // the whole table rather than a path. Registrations take it as readers, so + // they never block each other; only a location-independent guard takes it + // exclusively. Acquired OUTSIDE any path lock, which is what keeps the two + // lock families from ever forming a cycle. + const claimSentinel = Semaphore.makeUnsafe(CLAIM_LOCK_PERMITS); + const pathLocks = new Map(); + const lockFor = (path: string): Semaphore.Semaphore => { + const existing = pathLocks.get(path); + if (existing !== undefined) { + return existing; + } + const created = Semaphore.makeUnsafe(CLAIM_LOCK_PERMITS); + pathLocks.set(path, created); + return created; + }; + + const whileClaimsFrozen = ( + writes: ReadonlyArray, + effect: Effect.Effect, + ): Effect.Effect => + Effect.gen(function* () { + const canonical = yield* Effect.forEach(writes, canonicalize); + // Sorted and de-duplicated: a deterministic acquisition order is what + // keeps two overlapping mutations from deadlocking each other. + const keys = [...new Set(canonical.flatMap(ancestorChain))].sort(); + return yield* keys.reduce((guarded, key) => lockFor(key).withPermits(1)(guarded), effect); + }); + + const whileAllClaimsFrozen = (effect: Effect.Effect) => + claimSentinel.withPermits(CLAIM_LOCK_PERMITS)(effect); + + // Sentinel FIRST, then the path lock: every caller that takes both acquires + // them in this order, so a registration blocked on a path lock is never + // holding one a location-independent guard needs. + const withClaimsExclusive = (cwd: string, effect: Effect.Effect) => + claimSentinel.withPermits(1)( + canonicalize(cwd).pipe( + Effect.flatMap((path) => lockFor(path).withPermits(CLAIM_LOCK_PERMITS)(effect)), + ), + ); + return AetherMirrorRegistry.of({ + whileClaimsFrozen, + whileAllClaimsFrozen, + register: (cwd, key) => + withClaimsExclusive( + cwd, + Effect.gen(function* () { + const normalized = yield* canonicalize(cwd); + const keys = claims.get(normalized) ?? new Set(); + keys.add(key); + claims.set(normalized, keys); + }), + ), + deregister: (cwd, key) => + withClaimsExclusive( + cwd, + Effect.gen(function* () { + const normalized = yield* canonicalize(cwd); + const keys = claims.get(normalized); + if (keys === undefined) { + return; + } + keys.delete(key); + if (keys.size === 0) { + claims.delete(normalized); + } + }), + ), + ownsCwd: (cwd) => canonicalize(cwd).pipe(Effect.map((normalized) => claims.has(normalized))), + ownsTargetPath: (cwd, target) => + Effect.gen(function* () { + const resolved = yield* canonicalize( + NodePath.isAbsolute(target) ? target : 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.gen(function* () { + const resolved = yield* canonicalize( + NodePath.isAbsolute(target) ? target : 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/CloudTerminalConnector.ts b/apps/server/src/provider/CloudTerminalConnector.ts new file mode 100644 index 000000000000..8c72ed9c8588 --- /dev/null +++ b/apps/server/src/provider/CloudTerminalConnector.ts @@ -0,0 +1,83 @@ +/** + * CloudTerminalConnector — the optional adapter capability for attaching an + * interactive shell running INSIDE a provider's remote compute (not the local + * machine). Only cloud providers implement it; the generic + * `ProviderAdapterShape` carries it as an optional field so the terminal + * router can ask "does this thread's provider offer a remote shell?". + * + * The contract is deliberately neutral (no provider-specific types leak into + * the generic adapter shape): the connector opens a scoped, bidirectional + * byte pipe to one remote PTY session and reports output/close through + * callbacks. Provider-specific failures are mapped into the generic error + * union below at the implementation boundary. + * + * @module provider/CloudTerminalConnector + */ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import type * as Scope from "effect/Scope"; + +/** The remote compute has no attachable session (e.g. torn down / never started). */ +export class CloudTerminalUnavailableError extends Schema.TaggedErrorClass()( + "CloudTerminalUnavailableError", + { + reason: Schema.String, + }, +) { + override get message(): string { + return `Cloud terminal is unavailable: ${this.reason}`; + } +} + +/** The connect handshake or socket upgrade to the remote PTY failed. */ +export class CloudTerminalTransportError extends Schema.TaggedErrorClass()( + "CloudTerminalTransportError", + { + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Cloud terminal transport failed: ${this.detail}`; + } +} + +/** Writing to / resizing an attached session failed (usually a dropped socket). */ +export class CloudTerminalWriteError extends Schema.TaggedErrorClass()( + "CloudTerminalWriteError", + { + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Cloud terminal write failed: ${this.detail}`; + } +} + +export type CloudTerminalConnectError = CloudTerminalUnavailableError | CloudTerminalTransportError; + +/** A live handle to one attached remote PTY session; closed by its Scope. */ +export interface CloudTerminalConnection { + readonly write: (data: string) => Effect.Effect; + readonly resize: (cols: number, rows: number) => Effect.Effect; +} + +export interface CloudTerminalConnector { + /** + * Open a scoped connection to one remote PTY session. The session lives for + * the duration of the caller's Scope — closing the Scope tears down the + * remote shell and the socket. `onOutput` receives shell bytes; `onClosed` + * fires exactly once when the shell exits or the socket drops. + */ + readonly openConnection: (input: { + /** The provider's opaque remote task/session reference for the thread. */ + readonly taskId: string; + /** Client-chosen id, unique per remote PTY session on the connection. */ + readonly sessionId: string; + readonly cols: number; + readonly rows: number; + readonly onOutput: (data: string) => void; + readonly onClosed: (reason: string) => void; + }) => Effect.Effect; +} 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..d51ef8cdd31d --- /dev/null +++ b/apps/server/src/provider/Drivers/AetherDriver.ts @@ -0,0 +1,183 @@ +/** + * AetherDriver — `ProviderDriver` for Aether cloud tasks. + * + * A real snapshot (probe = authenticated `GET /profile`, models from the + * vendored platform catalog) over the session-core adapter (REST task client + * + 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 + */ +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"; + +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 { AetherMirrorRegistry } from "../AetherMirrorRegistry.ts"; +import { ProviderDriverError } from "../Errors.ts"; +import { + AetherMirrorRegistrationService, + AetherSessionGitService, + makeAetherAdapter, +} from "../Layers/AetherAdapter.ts"; +import { makeAetherRestClient } from "../Layers/aether/restClient.ts"; +import { + checkAetherProviderStatus, + makePendingAetherProvider, + readAetherApiKey, +} 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 = + | AetherMirrorRegistry + | BackgroundPolicy.BackgroundPolicy + | Crypto.Crypto + | FileSystem.FileSystem + | GitVcsDriver + | HttpClient.HttpClient + | ServerConfig + | 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 gitVcsDriver = yield* GitVcsDriver; + const serverConfig = yield* ServerConfig; + 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; + + // 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 mirrorRegistry = yield* AetherMirrorRegistry; + const adapter = yield* makeAetherAdapter({ + instanceId, + defaultCwd: serverConfig.cwd, + attachmentsDir: serverConfig.attachmentsDir, + restClient, + socket: + apiKey === undefined ? undefined : { apiBaseUrl: effectiveConfig.apiBaseUrl, apiKey }, + }).pipe( + Effect.provideService(AetherSessionGitService, gitVcsDriver), + Effect.provideService(AetherMirrorRegistrationService, mirrorRegistry), + ); + 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, + // Stable structural phrase; the dynamic cause is preserved below, not folded + // into the caller-visible message (Effect service conventions). + detail: "Failed to build the Aether provider snapshot.", + cause, + }), + ), + ); + + return { + instanceId, + driverKind: DRIVER_KIND, + continuationIdentity, + displayName, + accentColor, + enabled, + snapshot, + adapter, + textGeneration, + } satisfies ProviderInstance; + }), +}; 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..1d896ee83628 --- /dev/null +++ b/apps/server/src/provider/Layers/AetherAdapter.test.ts @@ -0,0 +1,4013 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { + ApprovalRequestId, + ProviderInstanceId, + ThreadId, + TurnId, + 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"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; +import type { ChildProcessSpawner } from "effect/unstable/process"; + +import type { ExecuteGitResult, GitStatusDetails } from "../../vcs/GitVcsDriver.ts"; +import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; +import type { ProviderAdapterError } from "../Errors.ts"; +import { + AetherMirrorRegistrationService, + AetherSessionGitService, + deterministicClientMessageId, + makeAetherAdapter, + parseAetherResume, + type AetherAdapterSocketOptions, + type AetherMirrorRegistration, + type AetherSessionGit, + type AetherTurnTiming, +} from "./AetherAdapter.ts"; +import { + AetherApiConflictError, + AetherApiNotFoundError, + AetherApiTransportError, + type AetherRestClient, +} from "./aether/restClient.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"); + +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 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", + ghMergeBase: string | null = null, +): AetherSessionGit => ({ + statusDetails: () => Effect.succeed(status), + readConfigValue: (_cwd, key) => + Effect.succeed( + key === "remote.origin.url" + ? originUrl + : status.branch !== null && key === `branch.${status.branch}.gh-merge-base` + ? ghMergeBase + : 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"), + 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"), + 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"), + 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", + 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 }; + readonly threadId?: ThreadId; + readonly cwd?: string; + readonly managedWorktree?: boolean; +}) => ({ + threadId: overrides?.threadId ?? ThreadId.make("thread-1"), + 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 = ( + options: { + readonly git?: AetherSessionGit; + readonly restClient?: AetherRestClient | undefined; + readonly hasRestClient?: boolean; + readonly socket?: AetherAdapterSocketOptions; + readonly mirrorRegistry?: AetherMirrorRegistration; + readonly turnTiming?: Partial; + }, + use: (adapter: ProviderAdapterShape) => Effect.Effect, +) => + Effect.gen(function* () { + const adapter = yield* makeAetherAdapter({ + instanceId, + defaultCwd: "/default-cwd", + attachmentsDir: "/nonexistent-attachments-dir", + restClient: + options.hasRestClient === false ? undefined : (options.restClient ?? unusedRestClient), + socket: options.socket, + turnTiming: options.turnTiming ?? zeroTurnTiming, + }).pipe( + Effect.provideService(AetherSessionGitService, options.git ?? gitWith(cleanStatus)), + Effect.provideService( + AetherMirrorRegistrationService, + options.mirrorRegistry ?? noopMirrorRegistry, + ), + ); + return yield* use(adapter); + }).pipe( + Effect.scoped, + Effect.provideService(Crypto.Crypto, testCrypto), + Effect.provide(NodeServices.layer), + ); + +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 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: [{ turnId: "aether-turn-u1", messageId: "u1" }, { turn: 1 }], + }), + ).toEqual({ + schemaVersion: 1, + taskId: "task-1", + latestSequence: 12, + }); + }); + + 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"); + }), + ); + + // 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, + }, + "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) => + 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( + "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", + () => + 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 }, + "git@github.com:acme/aether.git", + "main", + ), + 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( + { + // 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)), + getConversationMessages: () => Effect.succeed(messagesPage(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( + "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({ + 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("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) => + Effect.gen(function* () { + const session = yield* adapter.startSession( + startInput({ + resumeCursor: { + schemaVersion: 1, + taskId: "task-1", + latestSequence: 7, + // 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" }], + }, + }), + ); + expect(session.resumeCursor).toEqual({ + schemaVersion: 1, + taskId: "task-1", + latestSequence: 7, + turnLedger: [ + { turnId: "aether-turn-u1", messageId: "u1" }, + { turnId: "aether-turn-m2", messageId: "m2" }, + ], + }); + }), + ), + ); + + 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), + getConversationMessages: () => Effect.succeed(messagesPage(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 methods fail session-not-found for unknown threads; refusals 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("ProviderAdapterSessionNotFoundError"); + const interrupt = yield* Effect.flip(adapter.interruptTurn(threadId)); + expect(interrupt._tag).toBe("ProviderAdapterSessionNotFoundError"); + // 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("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"); + }), + ), + ); +}); + +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"); + }), + ); + // 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", () => + 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"); + }), + ); + }), + ); +}); + +// --------------------------------------------------------------------------- +// 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; + } +}); + +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 idleMessageTask: AetherTask = { + ...processingTask, + status: "awaiting_input", + awaiting_input: { kind: "message" }, + }; + + const streamingRestClient = (deltaSequences: Array): 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(() => { + deltaSequences.push(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", () => + Effect.gen(function* () { + const sockets: Array = []; + const deltaSequences: Array = []; + yield* withAdapter( + { + restClient: streamingRestClient(deltaSequences), + socket: { + apiBaseUrl: "https://api.runaether.dev", + apiKey: "aether_test_key", + // The diff request must resolve, never time out — the workspace + // side (diffAnsweringSocket) answers it synchronously. + 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(7), + 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", + // 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" } }); + 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("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 = []; + let deltaCalls = 0; + let taskIdle = false; + 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++; + 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(); + }), + ); + }), + ); + + 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( + "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 = []; + yield* withAdapter( + { + restClient: { ...unusedRestClient, listProjects: () => Effect.succeed([project()]) }, + socket: { + apiBaseUrl: "https://api.runaether.dev", + apiKey: "aether_test_key", + timing: zeroSocketTiming, + 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); + }), + ); + }), + ); +}); + +// --------------------------------------------------------------------------- +// 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; + readonly removedMessageIds?: ReadonlyArray; + }): 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: input.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( + "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", + () => + 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), + getConversationMessages: () => Effect.succeed(messagesPage(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), + getConversationMessages: () => Effect.succeed(messagesPage(processingTask)), + 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), + getConversationMessages: () => Effect.succeed(messagesPage(processingTask)), + 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), + getConversationMessages: () => Effect.succeed(messagesPage(processingTask)), + 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("does not accept an unknown task status as proof the stop landed", () => + Effect.gen(function* () { + // `unknown-status` is the forward-compat carrier for a status this build + // does not know — NOT evidence the turn stopped. Accepting it let Stop + // report success and mark the session ready with no terminal turn event. + const unknownStatusTask = { + ...processingTask, + status: "unknown-status" as const, + rawStatus: "some_future_status", + }; + let getTaskCalls = 0; + const deltas = scriptedDeltas([ + delta({ task: processingTask, activeMessageId: "m2", latestSequence: 3 }), + ]); + yield* withAdapter( + { + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + getTask: () => + Effect.sync(() => { + getTaskCalls++; + return unknownStatusTask; + }), + getConversationMessages: () => Effect.succeed(messagesPage(processingTask)), + respondToTask: () => Effect.succeed({ message_id: "m2" }), + stopTask: () => Effect.void, + getConversationDelta: deltas.getConversationDelta, + }, + }, + (adapter) => + Effect.gen(function* () { + const session = yield* adapter.startSession( + startInput({ + resumeCursor: { schemaVersion: 1, taskId: "task-1", latestSequence: 2 }, + }), + ); + yield* adapter.sendTurn({ threadId: session.threadId, input: "turn two" }); + const beforeConfirm = getTaskCalls; + + const failure = yield* Effect.flip(adapter.interruptTurn(session.threadId)); + expect(failure._tag).toBe("ProviderAdapterRequestError"); + expect(failure.message).toContain("could not be confirmed"); + // It kept polling rather than accepting the first unknown answer. + expect(getTaskCalls - beforeConfirm).toBeGreaterThan(1); + }), + ); + }), + ); + + it.effect("settles the discarded queued turn even when the stop cannot be confirmed", () => + Effect.gen(function* () { + // The stop SUCCEEDS — Aether has already dropped the queued message — + // and only the read-side confirmation fails. The local settle for that + // discarded turn is owed from the stop, so a transient getTask failure + // must not strand it: nothing else ever settles a turn the remote + // dropped before picking it up. + const deltas = scriptedDeltas([ + delta({ task: processingTask, activeMessageId: "m2", latestSequence: 3 }), + delta({ task: messageIdleTask, latestSequence: 4 }), + ]); + let respondCount = 0; + let stopped = false; + yield* withAdapter( + { + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + getTask: () => + stopped + ? Effect.fail( + new AetherApiNotFoundError({ endpoint: "/tasks/task-1", detail: "flaky" }), + ) + : Effect.succeed(messageIdleTask), + getConversationMessages: () => Effect.succeed(messagesPage(processingTask)), + respondToTask: () => + Effect.sync(() => { + respondCount++; + }).pipe(Effect.map(() => ({ message_id: respondCount === 1 ? "m2" : "m3" }))), + stopTask: () => + Effect.sync(() => { + stopped = true; + }), + 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" }); + yield* drainPoll; + expect((yield* adapter.listSessions())[0]!.activeTurnId).toBe("aether-turn-m3"); + + // The interrupt still FAILS LOUDLY — the confirmation is real. + const failure = yield* Effect.flip(adapter.interruptTurn(session.threadId)); + expect(failure._tag).toBe("ProviderAdapterRequestError"); + + const events = yield* Fiber.join(collector); + expect(events.map((event) => event.type)).toEqual([ + "turn.started", + "session.state.changed", + "turn.completed", + "runtime.warning", + "turn.completed", + ]); + expect(events[4]).toMatchObject({ + turnId: "aether-turn-m3", + payload: { state: "interrupted" }, + }); + // …and the session is not wedged on a turn that no longer exists. + expect((yield* adapter.listSessions())[0]!.activeTurnId).toBeUndefined(); + }), + ); + }), + ); + + it.effect("a definitive PRE-dispatch rejection leaves no pin behind", () => + Effect.gen(function* () { + // A model switch is validated BEFORE anything reaches /respond. When + // that validation rejects definitively, no message was ever dispatched — + // so nothing may be pinned, or a corrected prompt (necessarily a + // different fingerprint) would be refused forever as an ambiguous retry. + let respondCalls = 0; + let getTaskCalls = 0; + const deltas = scriptedDeltas([ + delta({ task: processingTask, activeMessageId: "m2", latestSequence: 3 }), + ]); + yield* withAdapter( + { + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + // startSession validates the resume cursor with its own getTask; + // the model switch's PRE-dispatch read is the one that rejects. + getTask: () => + Effect.suspend(() => { + getTaskCalls++; + return getTaskCalls === 1 + ? Effect.succeed(messageIdleTask) + : Effect.fail( + new AetherApiNotFoundError({ + endpoint: "GET /tasks/{id}", + detail: "gone", + }), + ); + }), + getConversationMessages: () => Effect.succeed(messagesPage(processingTask)), + respondToTask: () => + Effect.sync(() => { + respondCalls++; + }).pipe(Effect.map(() => ({ message_id: "m9" }))), + getConversationDelta: deltas.getConversationDelta, + }, + }, + (adapter) => + Effect.gen(function* () { + const session = yield* adapter.startSession( + startInput({ + resumeCursor: { schemaVersion: 1, taskId: "task-1", latestSequence: 2 }, + }), + ); + const rejected = yield* Effect.flip( + adapter.sendTurn({ + threadId: session.threadId, + input: "with a different model", + modelSelection: { instanceId, model: "codex/gpt-5.6-sol-high" }, + }), + ); + expect(rejected._tag).toBe("ProviderAdapterRequestError"); + expect(respondCalls).toBe(0); + + // Nothing was pinned, so a CORRECTED prompt dispatches normally. + yield* adapter.sendTurn({ threadId: session.threadId, input: "a corrected prompt" }); + expect(respondCalls).toBe(1); + }), + ); + }), + ); + + it.effect("a definitive API rejection releases the pin so a corrected prompt can go out", () => + Effect.gen(function* () { + // The server ANSWERED with a 4xx: it committed nothing, so the ordinal's + // client_message_id is unspent. Holding the pin here would refuse every + // corrected prompt forever with no way back. + let respondCalls = 0; + const deltas = scriptedDeltas([ + delta({ task: processingTask, activeMessageId: "m2", latestSequence: 3 }), + ]); + yield* withAdapter( + { + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + getTask: () => Effect.succeed(messageIdleTask), + getConversationMessages: () => Effect.succeed(messagesPage(processingTask)), + respondToTask: () => + Effect.sync(() => { + respondCalls++; + }).pipe( + Effect.andThen( + Effect.fail( + new AetherApiNotFoundError({ + endpoint: "/tasks/task-1/respond", + detail: "task not found", + }), + ), + ), + ), + getConversationDelta: deltas.getConversationDelta, + }, + }, + (adapter) => + Effect.gen(function* () { + const session = yield* adapter.startSession( + startInput({ + resumeCursor: { schemaVersion: 1, taskId: "task-1", latestSequence: 2 }, + }), + ); + yield* Effect.flip( + adapter.sendTurn({ threadId: session.threadId, input: "original prompt" }), + ); + expect(respondCalls).toBe(1); + + // A CHANGED prompt reaches the wire — it is not refused locally. + const corrected = yield* Effect.flip( + adapter.sendTurn({ threadId: session.threadId, input: "a corrected prompt" }), + ); + expect(corrected._tag).toBe("ProviderAdapterRequestError"); + expect(respondCalls).toBe(2); + }), + ); + }), + ); + + it.effect("reconcile observing the committed row releases the pin and burns its ordinal", () => + Effect.gen(function* () { + // The steer's 202 was lost but Aether DID commit the row. Once it shows + // up in the durable feed the pin must go and the ordinal must advance — + // otherwise a changed follow-up stays refused forever, and the next send + // would reuse a client_message_id the server has already spent. + let respondCalls = 0; + let sessionEpoch = ""; + let steerRowVisible = false; + const sentClientMessageIds: Array = []; + const restClient: AetherRestClient = { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + getTask: () => Effect.succeed(processingTask), + getConversationMessages: () => Effect.succeed(messagesPage(processingTask)), + respondToTask: (_taskId, request) => + Effect.suspend(() => { + respondCalls++; + sentClientMessageIds.push(request.client_message_id); + // Turn 1 lands; the steer's answer is lost in transit. + return respondCalls === 1 + ? Effect.succeed({ message_id: "m2" }) + : Effect.fail( + new AetherApiTransportError({ + endpoint: "/tasks/task-1/respond", + detail: "The request did not complete before the deadline.", + }), + ); + }), + getConversationDelta: (_taskId, _after) => + Effect.sync(() => + steerRowVisible + ? delta({ + task: processingTask, + activeMessageId: "m2", + messages: [ + { + id: "m3", + role: "user", + content: "steer it", + deliveryStatus: "queued", + timestamp: "t5", + sequence: 5, + // The row Aether committed for the steer whose 202 never + // came back, carrying the driver's own pinned id. + clientMessageId: deterministicClientMessageId({ + taskId: "task-1", + sessionEpoch, + sendOrdinal: 1, + }), + }, + ], + latestSequence: 5, + }) + : delta({ task: processingTask, activeMessageId: "m2", latestSequence: 3 }), + ), + }; + yield* withAdapter({ restClient }, (adapter) => + Effect.gen(function* () { + 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" }); + // The steer's answer is lost: the pin holds at this ordinal. + yield* Effect.flip(adapter.sendTurn({ threadId: session.threadId, input: "steer it" })); + expect(respondCalls).toBe(2); + const pinnedId = sentClientMessageIds[1]; + + // Still pinned: a CHANGED follow-up is refused before the wire. + const refused = yield* Effect.flip( + adapter.sendTurn({ threadId: session.threadId, input: "a changed follow-up" }), + ); + expect(refused._tag).toBe("ProviderAdapterValidationError"); + expect(respondCalls).toBe(2); + + // …until the committed row shows up in the durable feed. + steerRowVisible = true; + yield* drainPoll; + + // Now it goes out, under a FRESH id — reusing the spent one would + // dedupe the new prompt away. + yield* Effect.flip( + adapter.sendTurn({ threadId: session.threadId, input: "a changed follow-up" }), + ); + expect(respondCalls).toBe(3); + expect(sentClientMessageIds[2]).not.toBe(pinnedId); + }), + ); + }), + ); + + it.effect("rejects an interrupt naming a turn that already settled", () => + Effect.gen(function* () { + // `stopTask` stops the TASK, so an interrupt that raced its target's + // settle would kill the SUCCESSOR turn — work nobody asked to stop. + const stops: Array = []; + const deltas = scriptedDeltas([ + delta({ task: processingTask, activeMessageId: "m2", latestSequence: 3 }), + ]); + yield* withAdapter( + { + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + getTask: () => Effect.succeed(messageIdleTask), + getConversationMessages: () => Effect.succeed(messagesPage(processingTask)), + respondToTask: () => Effect.succeed({ message_id: "m2" }), + stopTask: (taskId) => + Effect.sync(() => { + stops.push(taskId); + }), + getConversationDelta: deltas.getConversationDelta, + }, + }, + (adapter) => + Effect.gen(function* () { + const session = yield* adapter.startSession( + startInput({ + resumeCursor: { schemaVersion: 1, taskId: "task-1", latestSequence: 2 }, + }), + ); + const sent = yield* adapter.sendTurn({ + threadId: session.threadId, + input: "turn two", + }); + + // A stale id (an earlier turn) is refused and never reaches Aether. + const stale = yield* Effect.flip( + adapter.interruptTurn(session.threadId, TurnId.make("aether-turn-m1")), + ); + expect(stale._tag).toBe("ProviderAdapterRequestError"); + expect(stale.message).toContain("no longer running"); + expect(stops).toHaveLength(0); + + // The turn actually running still stops. + yield* adapter.interruptTurn(session.threadId, sent.turnId); + expect(stops).toEqual(["task-1"]); + }), + ); + }), + ); + + it.effect("refuses a retry of an unconfirmed send whose content changed", () => + Effect.gen(function* () { + // The 202 is lost, so the ordinal — and the client_message_id — do not + // advance and the retry reuses them on purpose. A retry with DIFFERENT + // text would resolve to the row already committed and be discarded. + let respondCalls = 0; + const deltas = scriptedDeltas([ + delta({ task: processingTask, activeMessageId: "m2", latestSequence: 3 }), + ]); + yield* withAdapter( + { + restClient: { + ...unusedRestClient, + listProjects: () => Effect.succeed([project()]), + getTask: () => Effect.succeed(messageIdleTask), + getConversationMessages: () => Effect.succeed(messagesPage(processingTask)), + respondToTask: () => + Effect.sync(() => { + respondCalls++; + }).pipe( + Effect.andThen( + // A TRANSPORT failure: the outcome is genuinely unknown, so + // the row may or may not have committed and the pin holds. + // (A 4xx would PROVE nothing committed and release it.) + Effect.fail( + new AetherApiTransportError({ + endpoint: "/tasks/task-1/respond", + detail: "The request did not complete before the deadline.", + }), + ), + ), + ), + getConversationDelta: deltas.getConversationDelta, + }, + }, + (adapter) => + Effect.gen(function* () { + const session = yield* adapter.startSession( + startInput({ + resumeCursor: { schemaVersion: 1, taskId: "task-1", latestSequence: 2 }, + }), + ); + const lost = yield* Effect.flip( + adapter.sendTurn({ threadId: session.threadId, input: "original prompt" }), + ); + expect(lost._tag).toBe("ProviderAdapterRequestError"); + expect(respondCalls).toBe(1); + + const changed = yield* Effect.flip( + adapter.sendTurn({ threadId: session.threadId, input: "a different prompt" }), + ); + expect(changed._tag).toBe("ProviderAdapterValidationError"); + // Refused BEFORE the wire: the changed prompt never went out under + // an id the server would dedupe away. + expect(respondCalls).toBe(1); + + // The identical retry is still allowed through. + yield* Effect.flip( + adapter.sendTurn({ threadId: session.threadId, input: "original prompt" }), + ); + expect(respondCalls).toBe(2); + }), + ); + }), + ); + + 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), + getConversationMessages: () => Effect.succeed(messagesPage(processingTask)), + 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"); + }), + ), + ); + + // -- 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 = []; + 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 new file mode 100644 index 000000000000..4ff9e6d60f30 --- /dev/null +++ b/apps/server/src/provider/Layers/AetherAdapter.ts @@ -0,0 +1,2837 @@ +/** + * AetherAdapter — session core for the Aether cloud-task driver. + * + * 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. + * 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 + * sendTurn. It preflights the local checkout (clean tree on a pushed, + * 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 there. + * - 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 + */ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeCrypto from "node:crypto"; + +import { + EventId, + ProviderDriverKind, + TurnId, + type ChatAttachment, + type ProviderInstanceId, + type ProviderRuntimeEvent, + type ProviderSession, + type ProviderUserInputAnswers, + type ThreadId, +} from "@t3tools/contracts"; +import { normalizeGitRemoteUrl } from "@t3tools/shared/git"; +import * as Context from "effect/Context"; +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 { resolveAttachmentPath } from "../../attachmentStore.ts"; +import type { + ExecuteGitInput, + ExecuteGitResult, + GitStatusDetails, +} from "../../vcs/GitVcsDriver.ts"; +import { + ProviderAdapterRequestError, + ProviderAdapterSessionNotFoundError, + ProviderAdapterValidationError, + type ProviderAdapterError, +} from "../Errors.ts"; +import type { + ProviderAdapterShape, + ProviderThreadSnapshot, + ProviderThreadTurnSnapshot, +} from "../Services/ProviderAdapter.ts"; +import { + CloudTerminalTransportError, + CloudTerminalUnavailableError, + type CloudTerminalConnectError, + type CloudTerminalConnector, +} from "../CloudTerminalConnector.ts"; +import { AETHER_API_KEY_ENV_VAR } from "./AetherProvider.ts"; +import { + makeAetherEventMapper, + type AetherAnswerableQuestion, + type AetherEventMapper, +} from "./aether/eventMapper.ts"; +import { makeAetherMirrorSync, type AetherMirrorSyncEngine } from "./aether/mirrorSync.ts"; +import { buildAetherPreviewUrl } from "./aether/portPreview.ts"; +import type { AetherRestClient, AetherRestError } from "./aether/restClient.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 { + openAetherTerminalConnection, + type AetherTerminalConnectError, +} from "./aether/terminalConnection.ts"; +import { + runAetherAgentStream, + type AetherAgentConnection, + type AetherStreamTiming, + type AetherWebSocketFactory, +} from "./aether/workspaceSocket.ts"; + +const PROVIDER = ProviderDriverKind.make("aether"); + +/** + * Version tag stamped into the Aether resume cursor. Bump if the cursor + * shape changes so stale-shaped cursors written by older builds are ignored + * rather than misread (mirrors OPENCODE_RESUME_VERSION). + */ +const AETHER_RESUME_VERSION = 1 as const; + +/** + * One driver-originated turn: the t3 TurnId and the wire message id that + * opened it (identical strings modulo the `aether-turn-` prefix — the wire + * turn id IS the opening user message id). Recorded per send/harvest so the + * future revert slice can map "N turns back" → the `git restore` messageId, + * and so build item 13 can classify user rows the driver never sent. + */ +export interface AetherTurnLedgerEntry { + readonly turnId: string; + readonly messageId: string; +} + +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; + /** The driver's own turn→messageId pairs, oldest first. */ + readonly turnLedger?: ReadonlyArray; +} + +/** + * 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; +} + +/** + * Decode a persisted resume cursor. Anything that isn't a current-version + * cursor with a non-empty taskId and a finite latestSequence means "no + * resume" rather than an error (t3 then starts a fresh session — the same + * contract every other adapter's cursor parser follows). + */ +export function parseAetherResume(raw: unknown): AetherResumeCursor | undefined { + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) { + return undefined; + } + const record = raw as Record; + 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; + } + const turnLedger = parseTurnLedger(record.turnLedger); + return { + schemaVersion: AETHER_RESUME_VERSION, + taskId: record.taskId.trim(), + latestSequence: record.latestSequence, + ...(typeof record.mirrorFingerprint === "string" && record.mirrorFingerprint.length > 0 + ? { mirrorFingerprint: record.mirrorFingerprint } + : {}), + ...(turnLedger !== undefined ? { 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; + /** Raw git executor — the mirror sync engine's only git surface. */ + readonly execute: (input: ExecuteGitInput) => Effect.Effect; +} + +/** + * The adapter's git dependency as an Effect requirement: the driver provides + * `GitVcsDriver` here, unit tests provide a narrowed fake. + */ +export class AetherSessionGitService extends Context.Service< + AetherSessionGitService, + AetherSessionGit +>()("t3/provider/Layers/AetherAdapter/AetherSessionGitService") {} + +/** 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; +} + +/** 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; +} + +/** + * The adapter's mirror-ownership dependency as an Effect requirement: the + * driver provides `AetherMirrorRegistry` here, unit tests provide a fake. + */ +export class AetherMirrorRegistrationService extends Context.Service< + AetherMirrorRegistrationService, + AetherMirrorRegistration +>()("t3/provider/Layers/AetherAdapter/AetherMirrorRegistrationService") {} + +/** 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; + /** Attachment blob store root (ServerConfig.attachmentsDir). */ + readonly attachmentsDir: string; + /** + * 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; + /** + * 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; + 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; + /** 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; + /** + * The later-turn twin of `firstTurnFingerprint`: the identity of the + * respond dispatched at `ordinal` while its outcome is still unknown. The + * client_message_id is deterministic per ordinal and the ordinal advances + * only on a confirmed 202, so a retry after a LOST 202 reuses the id on + * purpose — that is what lets the server's ON CONFLICT dedupe fire. This + * pins WHICH send is in flight so a retry carrying different content is + * refused instead of silently resolving to the committed row. + */ + pendingSend: + | { + readonly ordinal: number; + readonly clientMessageId: string; + readonly fingerprint: string; + } + | undefined; + latestSequence: number; + /** 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. */ + 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; + /** 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. */ + 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.length > 0 ? { turnLedger: [...context.turnLedger] } : {}), + }; +} + +// --------------------------------------------------------------------------- +// Turn helpers (pure) +// --------------------------------------------------------------------------- + +const AETHER_TURN_ID_PREFIX = "aether-turn-"; + +/** + * Deterministic identity of ONE dispatch: prompt, model, effort, interaction + * mode and every attachment. Length-prefixed and control-char delimited so it + * is unambiguous without JSON (repo lint prefers Schema codecs for real + * serialization; this string is only ever compared, never parsed). + */ +const dispatchFingerprintOf = (input: { + readonly message: string; + readonly model: string | undefined; + readonly effort: string | undefined; + readonly interactionMode: string | undefined; + readonly attachments: ReadonlyArray | undefined; +}): string => + [ + `${input.message.length}:${input.message}`, + input.model ?? "", + input.effort ?? "", + input.interactionMode ?? "default", + ...(input.attachments ?? []).map( + (attachment) => + `${attachment.filename}\u0000${attachment.mediaType}\u0000${attachment.data.length}:${attachment.data}`, + ), + ].join("\u0001"); + +/** + * Did the server ANSWER this dispatch with a rejection? Then it committed + * nothing, the client_message_id at that ordinal is unspent, and the pending + * pin must be released so a corrected prompt can go out — otherwise the thread + * refuses every changed follow-up forever with no way back. + * + * A 409 counts: on respond it carries the task-state `code` / + * `awaiting_input_kind` — the request was refused, not deduped (the + * client_message_id dedupe answers 2xx with the committed row, which is + * exactly why callers may retry). + * + * Only genuinely ambiguous outcomes stay pinned, because the row MAY exist: a + * transport failure never learned the outcome, and a decode failure means the + * server answered 2xx with a body we could not read. Over-retaining is + * recoverable — an identical retry is still allowed, and the reconcile + * releases the pin for real once the committed row appears — while + * under-retaining silently loses the user's message. + */ +const isDefinitiveRejection = (error: AetherRestError): boolean => { + switch (error._tag) { + case "AetherApiAuthError": + case "AetherApiPaymentRequiredError": + case "AetherApiNotFoundError": + case "AetherApiConflictError": + case "AetherApiRequestError": + return true; + case "AetherApiTransportError": + case "AetherApiDecodeError": + return false; + } +}; + +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 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", + "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 + * 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; +} + +/** + * 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; +} + +/** + * 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") { + 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 + | FileSystem.FileSystem + | Scope.Scope + | AetherSessionGitService + | AetherMirrorRegistrationService +> { + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const git = yield* AetherSessionGitService; + const mirrorRegistry = yield* AetherMirrorRegistrationService; + 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(), + Queue.shutdown, + ); + const sessions = new Map(); + + const registryKey = (threadId: ThreadId) => `${options.instanceId}:${threadId}`; + + 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, + }); + + /** + * Release the pin when the failure PROVES nothing was committed. An error + * the server answered leaves the ordinal's client_message_id unspent, so the + * next (possibly corrected) send may reuse it; an ambiguous outcome keeps + * the pin until either an identical retry succeeds or the reconcile sees the + * row land. + */ + const releasePendingSendIfRejected = ( + context: AetherSessionContext, + error: AetherRestError, + ): void => { + if (isDefinitiveRejection(error)) { + context.pendingSend = undefined; + } + }; + + 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 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) 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 [threadId, context] of sessions.entries()) { + yield* closeSessionScope(context); + yield* mirrorRegistry.deregister(context.cwd, registryKey(threadId)); + } + }), + ); + + // 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, + }; + }); + + /** + * 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 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({ + ...(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: `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 }; + } + }); + + /** + * 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") { + continue; + } + // The durable proof a pinned send DID commit even though its 202 never + // arrived. Releasing the pin here — and burning the ordinal, since the + // id it names is now spent — is what lets a CHANGED follow-up through: + // without it the thread refuses every corrected prompt forever, even + // after the message it is waiting on is visible in the transcript. + const pinned = context.pendingSend; + if ( + pinned !== undefined && + row.clientMessageId !== undefined && + row.clientMessageId === pinned.clientMessageId + ) { + context.pendingSend = undefined; + if (context.sentCount === pinned.ordinal) { + context.sentCount = pinned.ordinal + 1; + } + } + if (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. + * + * 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* () { + 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 || + mapper.isOwnLiveTurnId(wireTurnId) || + 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 + * 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 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); + }).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 }; + }); + + /** + * 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) { + // No transport configured. Reachable ONLY from the unit harness: the + // driver derives `restClient` and `socket` from the same API key + // (AetherDriver.create), and startSession fails loudly without a + // restClient — so any session that reaches here in the product has a + // socket. Nothing to fork, and deliberately no reconcile: a session + // with no transport also has no feed to reconcile against. + 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; + + yield* runAetherAgentStream({ + restClient, + apiBaseUrl: socket.apiBaseUrl, + apiKey: socket.apiKey, + taskId, + ...(socket.webSocketFactory !== undefined + ? { webSocketFactory: socket.webSocketFactory } + : {}), + ...(socket.timing !== undefined ? { timing: socket.timing } : {}), + ...(input.allowStart ? { startOnFirstAttach: true } : {}), + onConnected: (connection) => + Effect.gen(function* () { + connectRetryWarned = false; + context.connection = connection; + context.previewToken = connection.previewToken; + context.workspaceId = connection.workspaceId; + if (!sessionStartedEmitted) { + sessionStartedEmitted = true; + context.sessionStartedEmitted = true; + yield* emit({ + ...(yield* baseEvent(context)), + type: "session.started", + payload: { message: "Attached to the Aether workspace stream." }, + }); + } + 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; + }), + 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 }); + } + // STRICTLY before the frame is mapped — see eagerRemoteTurnReconcile. + 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* () { + yield* Effect.logWarning("aether.frame.dropped", { taskId, ...problem }); + yield* emit({ + ...(yield* baseEvent(context)), + 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({ + ...(yield* baseEvent(context)), + 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. + ...(yield* baseEvent(context)), + ...(isTaskErrored ? { eventId: EventId.make(`aether:${taskId}:errored`) } : {}), + type: "runtime.error", + payload: { + message: error.message, + class: isTaskErrored ? "provider_error" : "transport_error", + }, + }); + }), + ), + 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), + ); + }); + + /** + * 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) { + 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. 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* git + .statusDetails(cwd) + .pipe(Effect.mapError(toGitRequestError("startSession"))); + const issue = + input.managedWorktree === true + ? managedWorktreePreflightIssue(status, cwd) + : isResume + ? resumePreflightIssue(status, cwd) + : preflightIssue(status, cwd); + if (issue !== undefined) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + 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* 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* 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* 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. + let taskId: string | undefined; + let latestSequence = 0; + let turnLedger: Array = []; + let resumedTask: AetherTask | undefined; + 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; + 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; + // 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 + // 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, + baseBranch, + taskId, + latestSequence, + turnLedger, + issuedClientMessageIds: new Set(), + warnedRemoteRows: new Set(), + remoteTurnSyncs: new Set(), + sessionScope: undefined, + mapper: undefined, + mirror: makeAetherMirrorSync({ + cwd, + 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, + previewToken: undefined, + workspaceId: undefined, + emittedPorts: new Set(), + reconcile: undefined, + pumpRunning: false, + sessionStartedEmitted: false, + activeTurn: undefined, + adoptActiveTurn: resumedTask?.status === "processing", + deferredTurns: [], + pollRunning: false, + sentCount: 0, + firstTurnPending: false, + firstTurnFingerprint: undefined, + pendingSend: 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); + yield* 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* mirrorRegistry.register(cwd, registryKey(input.threadId)); + + // (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; + }); + + // 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. + // 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); + // Release the fork-side write guard: the cwd is an ordinary local + // checkout again the moment the Aether thread lets go of it. + yield* mirrorRegistry.deregister(context.cwd, registryKey(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", + 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"); + // 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), + } 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 } : {}), + }, + }); + }); + + /** + * 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); + 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); + const dispatchFingerprint = dispatchFingerprintOf({ + message, + model: slug, + effort, + interactionMode: input.interactionMode, + attachments, + }); + 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) }; + // 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 + // 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; + + // 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), + ); + + const clientMessageId = deterministicClientMessageId({ + taskId, + sessionEpoch: context.session.createdAt, + sendOrdinal: context.sentCount, + }); + // A respond whose 202 was lost leaves the ordinal — and therefore the + // client_message_id — unchanged, which is deliberate: the retry must + // reuse it for the server's ON CONFLICT dedupe to fire. The cost is that + // a retry carrying DIFFERENT text, attachments, effort or mode resolves + // to the row already committed and the user's change is silently + // dropped. Pin the in-flight dispatch and refuse a retry that is not the + // same send, exactly as the first-turn create path does. + // + // The CHECK runs before the model switch below on purpose: that switch + // is a remote `PUT` plus a `context.session.model` mutation, so letting + // it go first would change remote settings for a send that is about to + // be rejected AND move the session model the fingerprint is computed + // from — invalidating the pin the user is trying to retry into. + // + // The pin is INSTALLED only at the dispatch itself (just before each + // `/respond` below), never here: everything between is pre-dispatch and + // can reject definitively — an invalid model 4xx, a task that turns out + // to be running remotely — without any message reaching Aether. Pinning + // here would leave those exits holding a pin nothing can release, and a + // corrected prompt would be refused forever as an ambiguous retry. + const dispatchFingerprint = dispatchFingerprintOf({ + message, + model: input.modelSelection?.model ?? context.session.model, + effort: selectionEffort, + interactionMode: input.interactionMode, + attachments, + }); + const pendingSend = context.pendingSend; + if ( + pendingSend !== undefined && + pendingSend.ordinal === context.sentCount && + pendingSend.fingerprint !== dispatchFingerprint + ) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "sendTurn", + issue: + "The previous message was already dispatched to Aether but its result never came back. Retry with exactly the same input, or wait for it to appear and send the change as a follow-up.", + }); + } + // 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 + ) { + 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, + }; + } + + // 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); + context.pendingSend = { + ordinal: context.sentCount, + clientMessageId, + fingerprint: dispatchFingerprint, + }; + 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.tapError((cause) => + Effect.sync(() => releasePendingSendIfRejected(context, cause)), + ), + Effect.mapError(toRestRequestError("sendTurn")), + ); + context.sentCount++; + context.pendingSend = undefined; + 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); + context.pendingSend = { + ordinal: context.sentCount, + clientMessageId, + fingerprint: dispatchFingerprint, + }; + const responded = yield* restClient + .respondToTask(taskId, { + message, + ...(promptContext !== undefined ? { context: promptContext } : {}), + ...(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.tapError((cause) => + Effect.sync(() => releasePendingSendIfRejected(context, cause)), + ), + 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++; + context.pendingSend = undefined; + // 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; + + 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, + 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. 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, + 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.", + }); + } + // `stopTask` stops the TASK, so it hits whatever is running now. When the + // caller named a turn, that name is the only thing tying the request to + // what the user actually pressed Stop on: an interrupt that raced its + // target's settle would otherwise kill the SUCCESSOR turn, discarding work + // nobody asked to stop. An unnamed interrupt still means "whatever is + // active" (the turnId is optional in the adapter contract). + if (turnId !== undefined) { + const requested = String(turnId); + const namesLiveTurn = + (active !== undefined && String(active.turnId) === requested) || + deferred.some((candidate) => String(candidate.turnId) === requested); + if (!namesLiveTurn) { + return yield* new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "interruptTurn", + detail: `Aether turn '${requested}' is no longer running on this thread; it settled before the interrupt arrived.`, + }); + } + } + // 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}`, + }, + }); + } + // 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 below 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. + const settleDiscarded = Effect.gen(function* () { + 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); + } + }); + + // Read-side confirmation: settle the ACTIVE turn ONLY once the task row + // has actually left processing — never optimistically on the 200, because + // an unconfirmed stop may still be running remotely. + const confirmInterrupt = Effect.gen(function* () { + let confirmed: AetherTask | undefined; + for (let attempt = 1; attempt <= turnTiming.interruptMaxAttempts; attempt++) { + const task = yield* restClient + .getTask(taskId) + .pipe(Effect.mapError(toRestRequestError("interruptTurn"))); + // `unknown-status` is the forward-compat carrier for a status this + // build does not know: it is NOT evidence the turn stopped. Treating + // it as settled let Stop report success, mark the session ready and + // run onTurnSettled with no terminal turn event and no proof the + // remote turn ended — keep polling instead. + if ( + task.status !== "processing" && + task.status !== "queued" && + task.status !== "unknown-status" + ) { + 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)); + } + }); + + // The deferred turns were discarded by the STOP, which already succeeded — + // their settle is owed from that moment, not from the confirmation. A + // transient getTask failure (or an exhausted confirm budget) must not + // strand them: nothing else will ever settle a turn the remote dropped + // before picking it up, so the thread would show it running forever. + yield* confirmInterrupt.pipe(Effect.onError(() => settleDiscarded)); + yield* settleDiscarded; + // 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); + } + }); + + // -- 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, + }); + // 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. + const answerPayload = JSON.stringify(built.data); + // Same at-most-once pin as sendTurn, and needed for the same reason: the + // id is deterministic per ordinal, so a retry after a lost 202 reuses it + // and Aether's ON CONFLICT dedupe resolves it to the ORIGINAL row. Left + // unguarded, a retry carrying DIFFERENT answers advanced the ordinal, + // resolved the panel with the NEW answers and announced the turn — the + // transcript claiming an answer set that was never sent. + const answerFingerprint = dispatchFingerprintOf({ + message: `${requestKey}\u0001${answerPayload}`, + model: undefined, + effort: undefined, + interactionMode: undefined, + attachments: undefined, + }); + const pinnedAnswer = context.pendingSend; + if ( + pinnedAnswer !== undefined && + pinnedAnswer.ordinal === context.sentCount && + pinnedAnswer.fingerprint !== answerFingerprint + ) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "respondToUserInput", + issue: + "The previous answer was already dispatched to Aether but its result never came back. Retry with exactly the same answers, or wait for it to appear and continue from there.", + }); + } + context.pendingSend = { + ordinal: context.sentCount, + clientMessageId, + fingerprint: answerFingerprint, + }; + // 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, { + message: answerPayload, + 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. + Effect.sync(() => releasePendingSendIfRejected(context, cause)).pipe( + Effect.andThen(context.reconcile ?? Effect.void), + 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.sync(() => releasePendingSendIfRejected(context, cause)).pipe( + Effect.andThen(Effect.fail(toRestRequestError("respondToUserInput")(cause))), + ), + ), + ); + context.sentCount++; + context.pendingSend = undefined; + 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); + }); + + // Cloud terminal: an interactive shell inside the task's VM, over its own + // tab-scoped workspace socket (independent of the turn engine's stream). + // Present only for a keyed instance — a keyless one fails loudly at the + // router instead of silently offering a broken shell. Captured into consts + // so the presence check narrows inside the closure. + const terminalRestClient = options.restClient; + const terminalSocket = options.socket; + const toCloudTerminalConnectError = ( + error: AetherTerminalConnectError, + ): CloudTerminalConnectError => + error._tag === "AetherTerminalWorkspaceUnavailableError" + ? new CloudTerminalUnavailableError({ reason: error.reason }) + : new CloudTerminalTransportError({ + detail: "workspace terminal connect failed", + cause: error, + }); + const cloudTerminal: CloudTerminalConnector | undefined = + terminalRestClient !== undefined && terminalSocket !== undefined + ? { + openConnection: (input) => + openAetherTerminalConnection({ + restClient: terminalRestClient, + apiBaseUrl: terminalSocket.apiBaseUrl, + apiKey: terminalSocket.apiKey, + taskId: input.taskId, + sessionId: input.sessionId, + cols: input.cols, + rows: input.rows, + onOutput: input.onOutput, + onClosed: input.onClosed, + }).pipe(Effect.mapError(toCloudTerminalConnectError)), + } + : undefined; + + return { + provider: PROVIDER, + ...(cloudTerminal ? { cloudTerminal } : {}), + capabilities: { + // "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, + // 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, + // 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* () { + // 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); + } + }), + 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..dd17a8e9157f --- /dev/null +++ b/apps/server/src/provider/Layers/AetherProvider.test.ts @@ -0,0 +1,253 @@ +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 { + 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); + // Cloud API: no binary, installed is unconditionally true. + expect(snapshot.installed).toBe(true); + expect(snapshot.message).toContain("disabled"); + 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 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); + }), + ); +}); + +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("error"); + 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("error"); + 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("error"); + 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("error"); + 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("error"); + 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("error"); + expect(snapshot.message).toContain("unexpected /profile payload"); + }), + ); + + it.effect("times out a 2xx response whose BODY never arrives", () => + Effect.gen(function* () { + // The probe deadline covered only `client.execute`, so a /profile that + // answered with 2xx headers and then stalled mid-body hung forever and + // never produced the error draft it promises. + const probe = yield* Effect.forkChild( + checkAetherProviderStatus(enabledSettings, keyedEnvironment).pipe( + Effect.provideService( + HttpClient.HttpClient, + respondingClient( + () => + new Response(new ReadableStream({ start: () => {} }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ), + ), + ); + yield* Effect.yieldNow; + yield* TestClock.adjust("10 seconds"); + const snapshot = yield* Fiber.join(probe); + expect(snapshot.status).toBe("error"); + expect(snapshot.message).toContain("Couldn't reach the Aether API"); + }), + ); + + 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.auth).toEqual({ + status: "authenticated", + type: "aether", + email: "dev@example.test", + }); + expect(snapshot.message).toBe("Connected to Aether as dev@example.test."); + // 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); + }), + ); + + 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("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 new file mode 100644 index 000000000000..bb20f5f80996 --- /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 Result from "effect/Result"; +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", + // 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; + +/** 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. + */ +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 { + 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.`; + +/** 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 buildServerProvider({ + presentation: AETHER_PRESENTATION, + enabled: false, + checkedAt, + models, + probe: { + // Cloud API — there is no binary to install, ever. + installed: true, + version: null, + status: "warning", + auth: { status: "unknown" }, + 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.", + }, + }); + }); + +/** + * 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 => + 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({ + 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}`), + ); + + // ONE deadline over the whole exchange, body included: a `/profile` that + // answers with 2xx headers and then stalls mid-body would hang the probe + // forever if only `execute` were timed. A malformed payload is a distinct + // answer, so it is caught INSIDE the deadline rather than folded into it. + const probeExit = yield* Effect.exit( + Effect.gen(function* () { + const response = yield* client.execute(request); + if (response.status === 401) { + return { _tag: "unauthenticated" } as const; + } + if (response.status < 200 || response.status >= 300) { + return { _tag: "http-status", status: response.status } as const; + } + const decoded = yield* Effect.result(response.json.pipe(Effect.flatMap(decodeAetherProfile))); + return Result.isSuccess(decoded) + ? ({ _tag: "profile", profile: decoded.success } as const) + : ({ _tag: "malformed-profile" } as const); + }).pipe(Effect.timeout(PROBE_TIMEOUT_MS)), + ); + if (Exit.isFailure(probeExit)) { + 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 probeResult = probeExit.value; + if (probeResult._tag === "unauthenticated") { + return draft({ + status: "error", + auth: { status: "unauthenticated" }, + message: + "Invalid Aether API key. Update the AETHER_API_KEY environment variable on this instance.", + }); + } + if (probeResult._tag === "http-status") { + return draft({ + status: "error", + auth: { status: "unknown" }, + message: `Aether API returned HTTP ${probeResult.status} from ${baseUrl}/profile.`, + }); + } + if (probeResult._tag === "malformed-profile") { + return draft({ + status: "error", + auth: { status: "unknown" }, + message: "Aether API returned an unexpected /profile payload.", + }); + } + + const profile = probeResult.profile; + 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/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index c78ecb3952a3..0bd43e6b7036 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -44,6 +44,8 @@ import { ProviderRegistryLive, 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"; @@ -1470,6 +1472,11 @@ 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(AetherMirrorRegistryModule.layer), Layer.provideMerge( Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), ), @@ -1563,6 +1570,11 @@ 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(AetherMirrorRegistryModule.layer), Layer.provideMerge( Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), ), @@ -1685,6 +1697,11 @@ 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(AetherMirrorRegistryModule.layer), Layer.provideMerge( Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), ), @@ -1747,6 +1764,12 @@ 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(AetherMirrorRegistryModule.layer), + Layer.provideMerge(AetherMirrorRegistryModule.layer), Layer.provideMerge( Layer.succeed(ServerSettingsModule.ServerSettingsService, serverSettings), ), 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..c8335abed721 --- /dev/null +++ b/apps/server/src/provider/Layers/aether/eventMapper.fixtures.ts @@ -0,0 +1,504 @@ +/** + * 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", + auto_fix_ci: false, + auto_fix_pr_comments: false, + auto_rebase: false, +} 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..f448db941010 --- /dev/null +++ b/apps/server/src/provider/Layers/aether/eventMapper.test.ts @@ -0,0 +1,1233 @@ +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("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({ + 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"]); + }); +}); + +describe("AetherEventMapper — interrupted turns (T6)", () => { + 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"); + // 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" }, + }); + expect(mapper.activeWireTurnId()).toBeUndefined(); + }); + + 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"); + // 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", () => { + 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"); + }); +}); + +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 new file mode 100644 index 000000000000..c520ebe8dff5 --- /dev/null +++ b/apps/server/src/provider/Layers/aether/eventMapper.ts @@ -0,0 +1,1604 @@ +/** + * 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; +} + +/** + * 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: ( + 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; + /** 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 + * 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; + /** + * 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; +} + +// --------------------------------------------------------------------------- +// 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; + /** + * 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; +} + +/** + * 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"], 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`); + 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 }> = []; + const answerableOptions: Array = []; + 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, + }); + 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, + header: trimmedOrUndefined(readString(raw, "header")) ?? `Question ${index + 1}`, + question, + options, + multiSelect, + }); + answerable.push({ id, rawIndex: index, multiSelect, options: answerableOptions }); + }); + return { questions, issues, answerable }; +} + +// --------------------------------------------------------------------------- +// 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 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 + * 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(); + /** + * 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 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; + 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 + * 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. 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 []; + } + settledTurns.add(input.wireTurnId); + 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 = 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, + ...(errorMessage !== undefined ? { errorMessage } : {}), + }, + }, + ]; + }; + + /** + * 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"; + 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 = []; + // 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, answerable } = 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); + openInput = { + pendingId: input.pendingId, + toolName: "ask_user", + wireTurnId: input.wireTurnId, + questions: answerable, + }; + 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) { + 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}`, + 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, + resolveLiveWireTurnId(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 wireTurnId = resolveLiveWireTurnId(event.turnId); + const turnEvents = trackTurn(wireTurnId, createdAt); + const counter = (deltaCounters.get(canonicalId) ?? 0) + 1; + deltaCounters.set(canonicalId, counter); + return [ + ...turnEvents, + { + ...base({ + eventId: `aether:${taskId}:stream:${canonicalId}:${counter}`, + createdAt, + wireTurnId, + 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 wireTurnId = resolveLiveWireTurnId(event.turnId); + const turnEvents = trackTurn(wireTurnId, createdAt); + const counter = (deltaCounters.get(canonicalId) ?? 0) + 1; + deltaCounters.set(canonicalId, counter); + return [ + ...turnEvents, + { + ...base({ + eventId: `aether:${taskId}:stream:${canonicalId}:${counter}`, + createdAt, + wireTurnId, + 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": { + const wireTurnId = resolveLiveWireTurnId(event.turnId); + return [ + ...trackTurn(wireTurnId, createdAt), + ...messageItemCompleted({ + canonicalId: canonicalMessageItemId(event.messageId), + itemType: "assistant_message", + content: event.payload.content, + wireTurnId, + createdAt, + }), + ]; + } + + case "thinking.completed": { + const wireTurnId = resolveLiveWireTurnId(event.turnId); + return [ + ...trackTurn(wireTurnId, createdAt), + ...messageItemCompleted({ + canonicalId: canonicalThinkingItemId(event.messageId), + itemType: "reasoning", + content: event.payload.content, + wireTurnId, + createdAt, + }), + ]; + } + + 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(wireTurnId, createdAt), + ...settleTurn({ wireTurnId, state: "completed", createdAt }), + ]; + } + + case "turn.failed": { + 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(wireTurnId, createdAt), + ...settleTurn({ wireTurnId, state: "failed", createdAt }), + ]; + } + return [ + ...trackTurn(wireTurnId, createdAt), + ...settleTurn({ + wireTurnId, + 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:${wireTurnId}: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 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(wireTurnId, createdAt), + ...settleTurn({ wireTurnId, state: "completed", createdAt }), + ]; + switch (event.payload.toolName) { + case "ask_user": + return [ + ...settle, + ...pendingInput({ + pendingId: event.pendingInputId, + toolName: "ask_user", + payload: event.payload.input, + wireTurnId, + createdAt, + }), + ]; + case "propose_plan": + return [ + ...settle, + ...pendingInput({ + pendingId: event.pendingInputId, + toolName: "propose_plan", + payload: event.payload.input, + wireTurnId, + 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. + // 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 openInput !== undefined + ? [] + : projectSessionState( + "starting", + "Aether queued the task; waiting for a workspace.", + createdAt, + ); + case "processing": + return openInput !== undefined ? [] : 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). 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 [ + ...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 = []; + // An errored task no longer waits on anything — clear a stale panel. + events.push(...resolveOpenInput({}, createdAt)); + 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; + // 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; + 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; + noteDurableTurn(runningWireTurnId); + 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, + activeWireTurnId: () => activeWireTurnId, + 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); + }, + 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/mirrorSync.test.ts b/apps/server/src/provider/Layers/aether/mirrorSync.test.ts new file mode 100644 index 000000000000..044f5d52620a --- /dev/null +++ b/apps/server/src/provider/Layers/aether/mirrorSync.test.ts @@ -0,0 +1,984 @@ +/** + * 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( + "refuses a binary path that reaches outside the checkout THROUGH a symlink", + () => + Effect.gen(function* () { + // The lexical check passes — `escape-link/payload.bin` has no '..', + // no leading '/', no '.git'. `writeFile`/`mkdir` FOLLOW the symlink, + // so before the real-path walk this wrote outside the checkout. + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repos = yield* setupRepos; + const outside = path.dirname(repos.mirror); + const outsideDir = path.join(outside, "outside-dir"); + yield* fileSystem.makeDirectory(outsideDir, { recursive: true }); + // Planted under the gitignored `build/`, so the link is invisible to + // the content fingerprint and survives `clean -fd` — exactly how a + // hostile artifact would sit in a real checkout. + yield* repos.makeMirrorDirectory("build"); + yield* fileSystem.symlink(outsideDir, path.join(repos.mirror, "build", "escape-link")); + + const engine = makeEngine(repos); + const outcome = yield* engine.syncAtSettle( + scriptedConnection([ + diffResult(repos.baseSha, [ + { + oldPath: "/dev/null", + newPath: "build/escape-link/payload.bin", + displayPath: "build/escape-link/payload.bin", + status: "added", + isBinary: true, + hunks: [], + }, + ]), + ]), + ); + expect(outcome).toMatchObject({ _tag: "paused", firstPause: true }); + if (outcome._tag === "paused") { + expect(outcome.reason).toContain("escapes the mirror checkout"); + expect(outcome.reason).toContain("symlink"); + } + expect(yield* fileSystem.exists(path.join(outsideDir, "payload.bin"))).toBe(false); + }).pipe(Effect.scoped), + { timeout: 60_000 }, + ); + + it.effect( + "refuses a binary path whose LEAF is a symlink into the checkout's own .git", + () => + Effect.gen(function* () { + // Containment alone would let this through: the link's target is + // inside the checkout. Following it corrupts git metadata, which the + // lexical '.git' refusal exists to prevent. + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const repos = yield* setupRepos; + yield* repos.makeMirrorDirectory("build"); + yield* fileSystem.symlink( + path.join(repos.mirror, ".git", "config"), + path.join(repos.mirror, "build", "innocent.bin"), + ); + + const engine = makeEngine(repos); + const outcome = yield* engine.syncAtSettle( + scriptedConnection([ + diffResult(repos.baseSha, [ + { + oldPath: "/dev/null", + newPath: "build/innocent.bin", + displayPath: "build/innocent.bin", + status: "added", + isBinary: true, + hunks: [], + }, + ]), + ]), + ); + expect(outcome).toMatchObject({ _tag: "paused", firstPause: true }); + if (outcome._tag === "paused") { + expect(outcome.reason).toContain("escapes the mirror checkout"); + expect(outcome.reason).toContain("symlink"); + } + expect(yield* repos.readMirrorFile(".git/config")).toContain("[core]"); + }).pipe(Effect.scoped), + { timeout: 60_000 }, + ); + + it.effect( + "removes every old binary path BEFORE writing any new one, so overlapping entries do not clobber", + () => + Effect.gen(function* () { + // `lib.txt` is BOTH the delete's old path and the rename's new path. + // Applied entry-by-entry the rename's write landed first and the + // later delete removed it — and the sync still reported success. + const repos = yield* setupRepos; + const engine = makeEngine(repos); + const outcome = yield* engine.syncAtSettle( + scriptedConnection([ + diffResult(repos.baseSha, [ + { + oldPath: "app.txt", + newPath: "lib.txt", + displayPath: "lib.txt", + status: "renamed", + isBinary: true, + hunks: [], + }, + { + oldPath: "lib.txt", + newPath: "/dev/null", + displayPath: "lib.txt", + status: "deleted", + isBinary: true, + hunks: [], + }, + ]), + ]), + ); + expect(outcome._tag).toBe("synced"); + // The rename's target survives: it is written after every removal. + expect(yield* repos.readMirrorFile("lib.txt")).toBe("binary:lib.txt"); + expect(yield* repos.mirrorFileExists("app.txt")).toBe(false); + }).pipe(Effect.scoped), + { timeout: 60_000 }, + ); + + it.effect( + "git-quotes header paths so a name with a quote or backslash still applies", + () => + Effect.gen(function* () { + // Raw interpolation produced a header `git apply` rejects, which + // pauses the mirror permanently for the rest of the thread. + const repos = yield* setupRepos; + const engine = makeEngine(repos); + const weird = 'we"ird\\name.txt'; + const outcome = yield* engine.syncAtSettle( + scriptedConnection([diffResult(repos.baseSha, [addedFile(weird, "quoted\n")])]), + ); + expect(outcome).toMatchObject({ _tag: "synced" }); + expect(yield* repos.readMirrorFile(weird)).toBe("quoted\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..2270820963ce --- /dev/null +++ b/apps/server/src/provider/Layers/aether/mirrorSync.ts @@ -0,0 +1,860 @@ +/** + * 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; + /** Canonical, symlink-free path; rejects when `path` does not exist. */ + readonly realpath: (path: string) => Promise; + /** Is `path` ITSELF a symlink? `false` when it does not exist. */ + readonly isSymlink: (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 }), + realpath: (path) => NodeFSP.realpath(path), + isSymlink: async (path) => { + try { + return (await NodeFSP.lstat(path)).isSymbolicLink(); + } catch (cause) { + // "It is not there" is an answer; every other errno is a real failure + // and must not be swallowed into a false "not a symlink". + if ((cause as NodeJS.ErrnoException).code === "ENOENT") { + return false; + } + throw cause; + } + }, +}; + +// --------------------------------------------------------------------------- +// 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: " ", +}; + +/** The named C escapes git writes; every other control byte becomes `\NNN`. */ +const C_STYLE_ESCAPES: Record = { + '"': '\\"', + "\\": "\\\\", + "\u0007": "\\a", + "\b": "\\b", + "\t": "\\t", + "\n": "\\n", + "\v": "\\v", + "\f": "\\f", + "\r": "\\r", +}; + +/** + * Git's C-style quoting for a diff header path token (`a/…`, `b/…`, or the + * bare path of a rename line). Interpolating a raw path that contains a + * quote, a backslash or a control character yields a header `git apply` + * REJECTS — which pauses the mirror permanently — so those three classes are + * escaped and the token wrapped in quotes, exactly as git writes them. + * Non-ASCII bytes are left raw: git quotes them for DISPLAY (core.quotePath) + * but `git apply` reads raw UTF-8, and inside a quoted token they pass + * through git's unquoting verbatim. + */ +function quoteGitDiffPath(token: string): string { + let escaped = ""; + let quotingRequired = false; + for (const character of token) { + const literal = C_STYLE_ESCAPES[character]; + if (literal !== undefined) { + escaped += literal; + quotingRequired = true; + continue; + } + const code = character.codePointAt(0)!; + if (code < 0x20 || code === 0x7f) { + escaped += `\\${code.toString(8).padStart(3, "0")}`; + quotingRequired = true; + continue; + } + escaped += character; + } + return quotingRequired ? `"${escaped}"` : token; +} + +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 ${quoteGitDiffPath(`a/${oldPath}`)} ${quoteGitDiffPath(`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 ${quoteGitDiffPath(file.oldPath)}`); + parts.push(`rename to ${quoteGitDiffPath(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" : `--- ${quoteGitDiffPath(`a/${oldPath}`)}`, + ); + parts.push( + file.status === "deleted" ? "+++ /dev/null" : `+++ ${quoteGitDiffPath(`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. 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. + * + * The KEY carries the task id because `--local` is repository-scoped, NOT + * worktree-scoped: every Aether thread gets its own worktree of the same + * repo, so a single shared key would have each thread's settle overwrite + * the others' record — the keyed lookup would then miss on every resume and + * fall back to the cursor, defeating the whole point of the record. + */ + const fingerprintConfigKey = (taskId: string) => `t3.${taskId}.aetherMirrorFingerprint`; + + /** + * Task ids reach us from the Aether API and end up in a git config key, whose + * grammar accepts far less than an arbitrary string. Anything outside the + * shape ids actually have is refused loudly rather than handed to git. + */ + const TASK_ID_PATTERN = /^[A-Za-z0-9._-]+$/; + + const requireTaskId: Effect.Effect = Effect.suspend(() => { + const taskId = options.getTaskId(); + if (taskId === undefined) { + return Effect.fail( + new PauseSync( + "The session has no Aether task id at settle time; refusing to sync the mirror without one.", + ), + ); + } + if (!TASK_ID_PATTERN.test(taskId)) { + return Effect.fail( + new PauseSync( + `The Aether task id '${taskId}' is not a valid mirror fingerprint record key; refusing to sync.`, + ), + ); + } + return Effect.succeed(taskId); + }); + + const persistFingerprint = (taskId: string, fingerprint: string) => + git("aether.mirror.fingerprint-store", [ + "config", + "--local", + fingerprintConfigKey(taskId), + `${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", fingerprintConfigKey(taskId)], + { 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 ${fingerprintConfigKey(taskId)}) 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.gen(function* () { + const refuse = (why: string) => + new PauseSync( + `The workspace diff names a binary path that escapes the mirror checkout (${why}): '${relative}'.`, + ); + if (relative.length === 0) { + return yield* Effect.fail(refuse("empty path")); + } + if (NodePath.isAbsolute(relative)) { + return yield* Effect.fail(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(/[/\\]/).filter((segment) => segment !== ""); + if (segments.includes("..")) { + return yield* Effect.fail(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 yield* Effect.fail(refuse("'.git' segment")); + } + if (segments.every((segment) => segment === ".")) { + return yield* Effect.fail(refuse("empty path")); + } + // LEXICAL validation is not enough: `writeFile` and `mkdir` FOLLOW + // symlinks, so a diff naming an existing symlink inside the checkout + // (or any path underneath one) writes wherever it points — outside the + // checkout, or into .git. Walk the segments down from the checkout's + // REAL root and refuse the first component that is itself a symlink; + // what comes back is then a real path inside the real root by + // construction, with no containment check left to get wrong. + const root = yield* Effect.tryPromise({ + try: () => fs.realpath(cwd), + catch: (cause) => + new PauseSync(`Could not resolve the mirror checkout '${cwd}': ${String(cause)}`), + }); + let resolved = root; + for (const segment of segments) { + if (segment === ".") { + continue; + } + resolved = NodePath.join(resolved, segment); + const symlink = yield* Effect.tryPromise({ + try: () => fs.isSymlink(resolved), + catch: (cause) => + new PauseSync( + `Could not inspect '${relative}' inside the mirror checkout: ${String(cause)}`, + ), + }); + if (symlink) { + return yield* Effect.fail(refuse(`'${segment}' is a symlink`)); + } + } + return resolved; + }); + + const applyBinaries = ( + connection: AetherMirrorConnection, + binaries: ReadonlyArray, + ) => + Effect.gen(function* () { + // PASS 1: 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)); + } + } + // PASS 2 then PASS 3 — every removal strictly before every write. One + // cumulative diff can name the same path as one entry's new target and + // another entry's old path (a delete of `a` alongside a rename of + // `b`→`a`); interleaving lets the later removal clobber the earlier + // write, and the sync then fingerprints and REPORTS a tree that is + // missing a file the diff says exists. + 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)}`), + }); + } + } + for (const file of binaries) { + 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/portPreview.test.ts b/apps/server/src/provider/Layers/aether/portPreview.test.ts new file mode 100644 index 000000000000..0a2a8939df26 --- /dev/null +++ b/apps/server/src/provider/Layers/aether/portPreview.test.ts @@ -0,0 +1,110 @@ +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("follows the api base protocol for an http-only self-hosted instance", () => { + // The preview gateway of an http-only instance is http too — emitting + // https for every non-localhost host produced an unreachable URL. + expect( + buildAetherPreviewUrl({ + apiBaseUrl: "http://api.aether.internal", + workspaceId: "abcdefgh1234", + port: 3000, + previewToken: TOKEN, + }), + ).toBe(`http://3000-abcdefgh-${TOKEN}.preview.aether.internal`); + // An unparseable base pairs with the production preview domain: https. + expect( + buildAetherPreviewUrl({ + apiBaseUrl: "not a url", + workspaceId: "abcdefgh1234", + port: 3000, + previewToken: TOKEN, + }), + ).toBe(`https://3000-abcdefgh-${TOKEN}.preview.runaether.dev`); + }); + + it("skips the preview for an IP-literal host, which cannot carry a subdomain", () => { + // Prefixing the token label yields a name that resolves nowhere for IPv4 + // and an invalid URL for IPv6 — offering no link beats offering a dead one. + for (const apiBaseUrl of ["http://127.0.0.1:8080", "http://[::1]:8080"]) { + expect( + buildAetherPreviewUrl({ + apiBaseUrl, + workspaceId: "abcdefgh1234", + port: 3000, + previewToken: TOKEN, + }), + ).toBeUndefined(); + } + // …but `*.localhost` resolves to loopback in every browser, so it stays. + expect( + buildAetherPreviewUrl({ + apiBaseUrl: "http://localhost:8080", + workspaceId: "abcdefgh1234", + port: 3000, + previewToken: TOKEN, + }), + ).toBe(`http://3000-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..e9ddf68e9c02 --- /dev/null +++ b/apps/server/src/provider/Layers/aether/portPreview.ts @@ -0,0 +1,83 @@ +/** + * 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"; + } +} + +/** + * The preview gateway is reachable over whatever scheme the instance's API + * is: an http-only self-hosted instance (`http://aether.internal`, a dev + * `http://127.0.0.1:8080`) serves previews over http too, and hardcoding + * https for every non-localhost host produced a URL that cannot connect. An + * unparseable URL pairs with the production preview domain, which is https. + */ +export function deriveAetherPreviewProtocol(apiBaseUrl: string): "http" | "https" { + try { + return new URL(apiBaseUrl).protocol === "http:" ? "http" : "https"; + } catch { + return "https"; + } +} + +/** + * An IP-literal host (`127.0.0.1:8080`, `[::1]`) cannot carry the preview + * subdomain: prefixing a label yields a name that resolves nowhere for IPv4 + * and an outright invalid URL for IPv6. `localhost` is exempt — browsers + * resolve every `*.localhost` label to loopback. + */ +function isIpLiteralHost(host: string): boolean { + if (host.startsWith("[")) { + return true; + } + const hostname = host.split(":", 1)[0] ?? ""; + return /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname); +} + +/** + * Build a workspace port-preview URL, or `undefined` when the preview token is + * malformed or absent, or when the instance's host cannot carry a preview + * subdomain. Port previews are best-effort — the caller skips surfacing the + * port rather than failing a turn, and skipping beats handing the user a link + * that cannot connect. + */ +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); + if (isIpLiteralHost(domain)) { + return undefined; + } + const subdomain = `${input.port}-${input.workspaceId.slice(0, 8)}-${input.previewToken}`; + return `${deriveAetherPreviewProtocol(input.apiBaseUrl)}://${subdomain}.${domain}`; +} 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..3e1443c5468c --- /dev/null +++ b/apps/server/src/provider/Layers/aether/restClient.test.ts @@ -0,0 +1,640 @@ +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", + 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", + 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..0b287d86e499 --- /dev/null +++ b/apps/server/src/provider/Layers/aether/restClient.ts @@ -0,0 +1,596 @@ +/** + * 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, + decodeAetherConnectConflict, + decodeAetherConnectResponse, + decodeAetherTask, + type AetherConversationDelta, + type AetherConversationMessagesPage, + type AetherCreateTaskRequest, + type AetherProject, + type AetherRespondToTaskRequest, + type AetherTask, + type AetherUpdateTaskRequest, + type AetherWorkspaceConnectOutcome, +} 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, + cause: Schema.optional(Schema.Defect()), + }, +) { + 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; + /** + * `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). */ + 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, + }); + } + }); + + /** + * ONE deadline over a COMPLETE exchange — request, response and body decode. + * `Effect.timeout` on the request alone expires when the response HEADERS + * arrive, so a server (or proxy) that answers and then stalls mid-body hung + * task create/respond/poll/project-lookup indefinitely despite the advertised + * per-request timeout. Every public method routes through this. + */ + const withExchangeDeadline = + (endpoint: string) => + (exchange: Effect.Effect): Effect.Effect => + exchange.pipe( + Effect.timeout(timeoutMs), + Effect.catchTags({ + TimeoutError: () => + new AetherApiTransportError({ + endpoint, + detail: "The request did not complete before the deadline.", + }), + }), + ); + + /** Execute, 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.mapError( + (cause) => + new AetherApiTransportError({ + endpoint, + detail: "Request failed before a response arrived.", + 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)), + withExchangeDeadline(endpoint), + ); + + 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.", + 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)), + withExchangeDeadline(endpoint), + ); + + const postJsonVoid = ( + endpoint: string, + url: string, + body: unknown, + ): Effect.Effect => + requestWithJsonBody(endpoint, HttpClientRequest.post(url), body).pipe( + Effect.asVoid, + withExchangeDeadline(endpoint), + ); + + // 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)), + withExchangeDeadline(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}")), + withExchangeDeadline("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)), + withExchangeDeadline(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)), + withExchangeDeadline(endpoint), + ); + }, + + 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.mapError( + (cause) => + new AetherApiTransportError({ + endpoint, + detail: "Request failed before a response arrived.", + 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); + }), + withExchangeDeadline(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..e4f7dcaeacff --- /dev/null +++ b/apps/server/src/provider/Layers/aether/restSchemas.ts @@ -0,0 +1,589 @@ +/** + * 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)), + // 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; + +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; + +// --------------------------------------------------------------------------- +// 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 +// --------------------------------------------------------------------------- + +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/provider/Layers/aether/terminalConnection.test.ts b/apps/server/src/provider/Layers/aether/terminalConnection.test.ts new file mode 100644 index 000000000000..aade3ad0cae1 --- /dev/null +++ b/apps/server/src/provider/Layers/aether/terminalConnection.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as TestClock from "effect/testing/TestClock"; + +import { taskProcessing } from "./eventMapper.fixtures.ts"; +import type { AetherRestClient } from "./restClient.ts"; +import type { AetherWorkspaceConnectOutcome } from "./restSchemas.ts"; +import { openAetherTerminalConnection, parseTerminalServerFrame } from "./terminalConnection.ts"; +import type { AetherWebSocketLike } from "./workspaceSocket.ts"; + +describe("parseTerminalServerFrame", () => { + it("parses an output frame on the terminal channel", () => { + expect( + parseTerminalServerFrame( + JSON.stringify({ channel: "terminal", type: "output", sessionId: "term-1", data: "hello" }), + ), + ).toEqual({ _tag: "output", sessionId: "term-1", data: "hello" }); + }); + + it("parses a close frame", () => { + expect( + parseTerminalServerFrame( + JSON.stringify({ channel: "terminal", type: "close", sessionId: "term-1" }), + ), + ).toEqual({ _tag: "close", sessionId: "term-1" }); + }); + + it("ignores frames on other channels", () => { + expect( + parseTerminalServerFrame( + JSON.stringify({ channel: "ports", type: "snapshot", ports: [3000] }), + ), + ).toEqual({ _tag: "ignored" }); + }); + + it("ignores a terminal output frame missing its data (never drops silently as output)", () => { + expect( + parseTerminalServerFrame( + JSON.stringify({ channel: "terminal", type: "output", sessionId: "x" }), + ), + ).toEqual({ _tag: "ignored" }); + }); + + it("ignores non-JSON and non-object payloads", () => { + expect(parseTerminalServerFrame("not json")).toEqual({ _tag: "ignored" }); + expect(parseTerminalServerFrame("null")).toEqual({ _tag: "ignored" }); + expect(parseTerminalServerFrame("42")).toEqual({ _tag: "ignored" }); + }); +}); + +// --------------------------------------------------------------------------- +// Reconnection harness (mirrors workspaceSocket.test.ts FakeSocket) +// --------------------------------------------------------------------------- + +type WsListener = (event: never) => void; + +class FakeSocket implements AetherWebSocketLike { + readonly sent: Array = []; + private opened = false; + private closed = false; + private readonly listeners = new Map void>>(); + + addEventListener(type: string, listener: WsListener): void { + const list = this.listeners.get(type) ?? []; + list.push(listener as (event: unknown) => void); + this.listeners.set(type, list); + // Model an already-open upgrade: the loop registers its open listener after + // the factory calls open(), so fire on registration. + 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); + } + + serverClose(code: number, reason: string): void { + if (this.closed) return; + 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); + } +} + +const ZERO_TIMING = { + pollInitialMs: 0, + pollMaxMs: 0, + reconnectInitialMs: 0, + reconnectMaxMs: 0, + connectDefaultRetryMs: 0, +} as const; + +const runningOutcome: AetherWorkspaceConnectOutcome = { + state: "running", + transport: { websocket_path: "/workspaces/ws-1/ws", preview_token: "t".repeat(32) }, +}; + +const restClient: Pick = { + getTask: () => Effect.succeed(taskProcessing), + connectWorkspace: () => Effect.succeed(runningOutcome), +}; + +// Drive the forked loop/pump through queued signals and the zero-duration +// backoff sleeps. +const settle = Effect.gen(function* () { + for (let i = 0; i < 8; i++) { + yield* TestClock.adjust("0 millis"); + yield* Effect.yieldNow; + } +}); + +const openHarness = (input: { + readonly sockets: Array; + readonly onOutput: (data: string) => void; + readonly onClosed: (reason: string) => void; + readonly cols: number; + readonly rows: number; +}) => + openAetherTerminalConnection({ + restClient, + apiBaseUrl: "https://api.runaether.dev", + apiKey: "aether_test_key", + taskId: "task-1", + sessionId: "term-1", + cols: input.cols, + rows: input.rows, + timing: ZERO_TIMING, + webSocketFactory: () => { + const socket = new FakeSocket(); + input.sockets.push(socket); + socket.open(); + return socket; + }, + onOutput: input.onOutput, + onClosed: input.onClosed, + }); + +describe("openAetherTerminalConnection", () => { + it.effect("returns a handle and applies the requested size on first attach", () => + Effect.gen(function* () { + const sockets: Array = []; + yield* Effect.scoped( + Effect.gen(function* () { + yield* openHarness({ + sockets, + onOutput: () => {}, + onClosed: () => {}, + cols: 120, + rows: 30, + }); + yield* settle; + expect(sockets.length).toBe(1); + expect(sockets[0]!.sent.some((s) => s.includes('"create"'))).toBe(true); + expect( + sockets[0]!.sent.some( + (s) => s.includes('"resize"') && s.includes("120") && s.includes("30"), + ), + ).toBe(true); + }), + ); + }), + ); + + it.effect("reconnects a fresh shell on a socket drop, and stops on shell exit", () => + Effect.gen(function* () { + const sockets: Array = []; + const outputs: Array = []; + const closes: Array = []; + yield* Effect.scoped( + Effect.gen(function* () { + yield* openHarness({ + sockets, + onOutput: (d) => outputs.push(d), + onClosed: (r) => closes.push(r), + cols: 80, + rows: 24, + }); + yield* settle; + expect(sockets.length).toBe(1); + + // Transient socket drop → transparent reconnect (a fresh socket). + sockets[0]!.serverClose(1006, "network blip"); + yield* settle; + expect(sockets.length).toBe(2); + expect(outputs.some((o) => o.includes("reconnecting"))).toBe(true); + expect(closes).toHaveLength(0); + + // The fresh shell produces output, then exits → connection ends, no reconnect. + sockets[1]!.message({ + channel: "terminal", + type: "output", + sessionId: "term-1", + data: "hi", + }); + sockets[1]!.message({ channel: "terminal", type: "close", sessionId: "term-1" }); + yield* settle; + expect(outputs).toContain("hi"); + expect(closes).toContain("shell exited"); + expect(sockets.length).toBe(2); + }), + ); + }), + ); + + it.effect("recreates the PTY at the resized dimensions after a drop", () => + Effect.gen(function* () { + const sockets: Array = []; + yield* Effect.scoped( + Effect.gen(function* () { + const connection = yield* openHarness({ + sockets, + onOutput: () => {}, + onClosed: () => {}, + cols: 80, + rows: 24, + }); + yield* settle; + // User resizes; the connector records the new size and forwards it. + yield* connection.resize(200, 50); + yield* settle; + expect( + sockets[0]!.sent.some((s) => s.includes('"cols":200') && s.includes('"rows":50')), + ).toBe(true); + + // A drop recreates the PTY at 200x50 — not the stale initial 80x24. + sockets[0]!.serverClose(1006, "blip"); + yield* settle; + expect(sockets.length).toBe(2); + const secondResize = sockets[1]!.sent.find((s) => s.includes('"resize"')); + expect(secondResize).toBeDefined(); + expect(secondResize!.includes('"cols":200') && secondResize!.includes('"rows":50')).toBe( + true, + ); + }), + ); + }), + ); + + it.effect("fails loudly when the first attach cannot send the create handshake", () => + Effect.gen(function* () { + // The socket closes in the same tick as `open`, so `send` THROWS. As a + // defect that throw slipped past runLifecycle's typed catch: the fork + // died without failing `ready` and the open blocked forever. It must + // surface as a typed failure instead. + class ThrowingSocket extends FakeSocket { + override send(): void { + throw new Error("WebSocket is already in CLOSING or CLOSED state"); + } + } + const exit = yield* Effect.exit( + Effect.scoped( + openAetherTerminalConnection({ + restClient, + apiBaseUrl: "https://api.runaether.dev", + apiKey: "aether_test_key", + taskId: "task-1", + sessionId: "term-1", + cols: 80, + rows: 24, + timing: ZERO_TIMING, + webSocketFactory: () => { + const socket = new ThrowingSocket(); + socket.open(); + return socket; + }, + onOutput: () => {}, + onClosed: () => {}, + }), + ), + ); + expect(exit._tag).toBe("Failure"); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/aether/terminalConnection.ts b/apps/server/src/provider/Layers/aether/terminalConnection.ts new file mode 100644 index 000000000000..ccce71968602 --- /dev/null +++ b/apps/server/src/provider/Layers/aether/terminalConnection.ts @@ -0,0 +1,442 @@ +/** + * Aether cloud terminal connection — a dedicated, tab-scoped workspace WS + * carrying ONLY the `terminal` channel (create/input/resize/close out; + * output/close in). It is deliberately independent of the turn engine's + * agent-stream connection (`runAetherAgentStream`): a shell stays attached for + * as long as the UI tab is open, a lifetime unrelated to any turn, so it owns + * its own socket under the caller's Scope. Multiple concurrent connections per + * workspace are fine — `POST /workspaces/{id}/connect` hands a transport to + * every caller and the VM tracks PTY state per connection. + * + * The wire twins are the terminal messages in aether's workspace-protocol + * (`packages/workspace-protocol/src/messages.ts`): create/input/resize/close + * from the client, output/close from the server. + * + * @module provider/Layers/aether/terminalConnection + */ +import * as Deferred from "effect/Deferred"; +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 * as Scope from "effect/Scope"; + +import type { CloudTerminalConnection } from "../../CloudTerminalConnector.ts"; +import { CloudTerminalWriteError } from "../../CloudTerminalConnector.ts"; +import type { AetherRestClient, AetherRestError } from "./restClient.ts"; +import { + aetherWorkspaceSocketUrl, + connectForTransport, + defaultWebSocketFactory, + DEFAULT_TIMING, + openSocket, + resolveTaskWorkspace, + type AetherSocketOpenError, + type AetherStreamTiming, + type AetherTaskErroredError, + type AetherTaskUnknownStatusError, + type AetherWebSocketFactory, + type AetherWebSocketLike, + type AetherWorkspaceConnectTimeoutError, +} from "./workspaceSocket.ts"; + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +/** The task has no attachable workspace (parked, or connect refused a boot). */ +export class AetherTerminalWorkspaceUnavailableError extends Schema.TaggedErrorClass()( + "AetherTerminalWorkspaceUnavailableError", + { + taskId: Schema.String, + reason: Schema.String, + }, +) { + override get message(): string { + return `Aether cloud terminal has no workspace for task '${this.taskId}': ${this.reason}`; + } +} + +/** + * The PTY create/resize handshake could not be written to a socket that had + * just opened — a `send` on a socket the server closed in the same tick. + */ +export class AetherTerminalAttachError extends Schema.TaggedErrorClass()( + "AetherTerminalAttachError", + { + taskId: Schema.String, + sessionId: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Aether cloud terminal could not start a shell for task '${this.taskId}': the socket closed before the create/resize handshake was sent.`; + } +} + +export type AetherTerminalConnectError = + | AetherTerminalWorkspaceUnavailableError + | AetherTerminalAttachError + | AetherTaskErroredError + | AetherTaskUnknownStatusError + | AetherWorkspaceConnectTimeoutError + | AetherSocketOpenError + | AetherRestError; + +// --------------------------------------------------------------------------- +// Wire (twins of aether workspace-protocol terminal messages) +// --------------------------------------------------------------------------- + +const encodeTerminalCreate = Schema.encodeSync( + Schema.fromJsonString( + Schema.Struct({ + channel: Schema.Literal("terminal"), + type: Schema.Literal("create"), + sessionId: Schema.String, + }), + ), +); +const encodeTerminalInput = Schema.encodeSync( + Schema.fromJsonString( + Schema.Struct({ + channel: Schema.Literal("terminal"), + type: Schema.Literal("input"), + sessionId: Schema.String, + data: Schema.String, + }), + ), +); +const encodeTerminalResize = Schema.encodeSync( + Schema.fromJsonString( + Schema.Struct({ + channel: Schema.Literal("terminal"), + type: Schema.Literal("resize"), + sessionId: Schema.String, + cols: Schema.Number, + rows: Schema.Number, + }), + ), +); +const encodeTerminalClose = Schema.encodeSync( + Schema.fromJsonString( + Schema.Struct({ + channel: Schema.Literal("terminal"), + type: Schema.Literal("close"), + sessionId: Schema.String, + }), + ), +); +// Keep-alive: the VM's interactive idle lease is only renewed by the `activity` +// channel — terminal I/O does NOT count. Without this the VM suspends ~15 min +// after connect (INTERACTIVE_INACTIVITY_TIMEOUT_MS) mid-session. Ping well +// under that window while the terminal connection is open. +const encodeUserActivity = Schema.encodeSync( + Schema.fromJsonString( + Schema.Struct({ + channel: Schema.Literal("activity"), + type: Schema.Literal("user_activity"), + }), + ), +); +const ACTIVITY_PING_INTERVAL = Duration.seconds(30); +// Give up after this many CONSECUTIVE reconnect attempts that never produced +// output (a working connection that later drops resets the counter). Bounds a +// genuinely gone workspace while surviving ordinary blips indefinitely. +const MAX_CONSECUTIVE_RECONNECTS = 6; + +const reconnectDelay = (timing: AetherStreamTiming, consecutive: number): Duration.Duration => + Duration.millis( + Math.min(timing.reconnectMaxMs, timing.reconnectInitialMs * 2 ** Math.min(consecutive, 20)), + ); + +type TerminalServerFrame = + | { readonly _tag: "output"; readonly sessionId: string; readonly data: string } + | { readonly _tag: "close"; readonly sessionId: string } + | { readonly _tag: "ignored" }; + +/** + * Parse one inbound frame. Loose by design (mirrors the agent-frame parser): + * a non-terminal channel, or a malformed terminal frame, is ignored rather + * than killing the socket — the pump lives on. + */ +export function parseTerminalServerFrame(raw: string): TerminalServerFrame { + let json: unknown; + try { + json = JSON.parse(raw); + } catch { + return { _tag: "ignored" }; + } + if (typeof json !== "object" || json === null) return { _tag: "ignored" }; + const frame = json as Record; + if (frame.channel !== "terminal") return { _tag: "ignored" }; + if ( + frame.type === "output" && + typeof frame.sessionId === "string" && + typeof frame.data === "string" + ) { + return { _tag: "output", sessionId: frame.sessionId, data: frame.data }; + } + if (frame.type === "close" && typeof frame.sessionId === "string") { + return { _tag: "close", sessionId: frame.sessionId }; + } + return { _tag: "ignored" }; +} + +// --------------------------------------------------------------------------- +// Connection +// --------------------------------------------------------------------------- + +export interface AetherTerminalConnectionOptions { + readonly restClient: Pick; + readonly apiBaseUrl: string; + readonly apiKey: string; + readonly taskId: string; + readonly sessionId: string; + readonly cols: number; + readonly rows: number; + readonly webSocketFactory?: AetherWebSocketFactory; + readonly timing?: Partial; + /** Shell bytes from the VM. */ + readonly onOutput: (data: string) => void; + /** Fires exactly once when the shell exits or the socket drops. */ + readonly onClosed: (reason: string) => void; +} + +/** + * Open a resilient cloud-terminal connection. The FIRST attach (resolve the + * task's workspace, connect with `start:true` to boot an idle VM, open the + * socket, create + size the PTY) either succeeds — returning a write/resize + * handle — or fails loudly. After that, a forked loop transparently reconnects + * on a socket drop: because the VM reaps the PTY when the socket closes, a + * reconnect is a FRESH shell (the client keeps its scrollback), preceded by a + * `[reconnecting…]` marker. A shell exit (terminal `close` frame) ends the + * connection; so does exhausting the reconnect budget. The caller's Scope owns + * teardown — closing it interrupts the loop and the current socket. + */ +export const openAetherTerminalConnection = ( + options: AetherTerminalConnectionOptions, +): Effect.Effect => + Effect.gen(function* () { + const timing = { ...DEFAULT_TIMING, ...options.timing }; + const factory = options.webSocketFactory ?? defaultWebSocketFactory; + // The live socket, followed by write/resize. Null between attaches (during + // a reconnect) — a plain closure var is safe: fibers here are cooperative. + let currentSocket: AetherWebSocketLike | null = null; + // Latest requested size, so a reconnect recreates the PTY at the size the + // user resized to — not the stale initial dimensions. + let currentCols = options.cols; + let currentRows = options.rows; + const ready = yield* Deferred.make(); + + // `socket.send`/`socket.close` THROW on a socket the server already + // closed. Inside `Effect.sync` that is a defect, and `Effect.ignore` only + // clears the error channel — a defect here kills the lifecycle fork + // without failing `ready`, which is exactly the hang this connection must + // never produce. Every best-effort socket call goes through `Effect.try`. + const bestEffort = (call: () => void): Effect.Effect => + Effect.try({ + try: call, + catch: (cause) => + new CloudTerminalWriteError({ detail: "best-effort socket call failed", cause }), + }).pipe(Effect.ignore); + + const heartbeat = (socket: AetherWebSocketLike): Effect.Effect => + Effect.sleep(ACTIVITY_PING_INTERVAL).pipe( + Effect.andThen( + bestEffort(() => { + socket.send(encodeUserActivity({ channel: "activity", type: "user_activity" })); + }), + ), + Effect.forever, + ); + + // One scoped attach: setup, then pump until the socket drops or the shell + // exits. `notifyReady` fires after setup so the handle is returned before + // the pump blocks. `produced` reports whether any shell output arrived (a + // productive connection resets the reconnect budget). + const attachOnce = (notifyReady: Effect.Effect) => + Effect.gen(function* () { + const resolution = yield* resolveTaskWorkspace({ + getTask: options.restClient.getTask, + taskId: options.taskId, + ...(options.timing !== undefined ? { timing: options.timing } : {}), + }); + if (resolution._tag === "parked") { + return yield* new AetherTerminalWorkspaceUnavailableError({ + taskId: options.taskId, + reason: "the task has no active workspace; run a turn first", + }); + } + const transport = yield* connectForTransport({ + connectWorkspace: options.restClient.connectWorkspace, + workspaceId: resolution.workspaceId, + start: true, + ...(options.timing !== undefined ? { timing: options.timing } : {}), + }); + if (transport._tag === "unavailable") { + return yield* new AetherTerminalWorkspaceUnavailableError({ + taskId: options.taskId, + reason: transport.reason, + }); + } + const url = aetherWorkspaceSocketUrl( + options.apiBaseUrl, + transport.websocketPath, + options.apiKey, + ); + const { socket, signals } = yield* Effect.acquireRelease( + openSocket(factory, url, timing.openTimeoutMs), + ({ socket }) => bestEffort(() => socket.close()), + ); + // Send an explicit terminal close before the socket close finalizer + // (LIFO) so this attach's VM PTY is reaped promptly. + yield* Effect.addFinalizer(() => + bestEffort(() => { + socket.send( + encodeTerminalClose({ + channel: "terminal", + type: "close", + sessionId: options.sessionId, + }), + ); + }), + ); + currentSocket = socket; + // These two sends run BEFORE `ready` resolves, so a throw here (the + // socket closed the instant after `openSocket` returned) must be a + // TYPED failure: as a defect it would slip past `runLifecycle`'s + // typed catch, kill the fork without failing `ready`, and leave + // `openAetherTerminalConnection` awaiting it forever. + yield* Effect.try({ + try: () => { + socket.send( + encodeTerminalCreate({ + channel: "terminal", + type: "create", + sessionId: options.sessionId, + }), + ); + // The VM PTY is created at a default 80x24; apply the latest + // requested size up front so output wraps correctly — including + // after a resize + reconnect, when this recreates the shell. + socket.send( + encodeTerminalResize({ + channel: "terminal", + type: "resize", + sessionId: options.sessionId, + cols: currentCols, + rows: currentRows, + }), + ); + }, + catch: (cause) => + new AetherTerminalAttachError({ + taskId: options.taskId, + sessionId: options.sessionId, + cause, + }), + }); + yield* Effect.forkScoped(heartbeat(socket)); + yield* notifyReady; + + let produced = false; + const pump: Effect.Effect<"shell-exit" | "socket-drop"> = Queue.take(signals).pipe( + Effect.flatMap((signal) => { + if (signal._tag === "closed") { + return Effect.succeed("socket-drop" as const); + } + const frame = parseTerminalServerFrame(signal.data); + if (frame._tag === "output" && frame.sessionId === options.sessionId) { + produced = true; + return Effect.sync(() => options.onOutput(frame.data)).pipe( + Effect.flatMap(() => pump), + ); + } + if (frame._tag === "close" && frame.sessionId === options.sessionId) { + return Effect.succeed("shell-exit" as const); + } + return pump; + }), + ); + const outcome = yield* pump; + return { outcome, produced } as const; + }).pipe(Effect.ensuring(Effect.sync(() => (currentSocket = null)))); + + // First attach errors propagate to `ready` (fail loudly). Once connected, + // socket drops reconnect; connect errors during a reconnect count against + // the budget instead of killing the terminal. + const runLifecycle = Effect.gen(function* () { + let consecutive = 0; + let first = true; + while (true) { + const notify = first ? Deferred.succeed(ready, undefined).pipe(Effect.asVoid) : Effect.void; + const attach = Effect.scoped(attachOnce(notify)); + const result = first + ? yield* attach + : yield* attach.pipe( + Effect.orElseSucceed(() => ({ outcome: "socket-drop" as const, produced: false })), + ); + first = false; + if (result.outcome === "shell-exit") { + options.onClosed("shell exited"); + return; + } + consecutive = result.produced ? 0 : consecutive + 1; + if (consecutive > MAX_CONSECUTIVE_RECONNECTS) { + options.onClosed(`disconnected after ${consecutive} reconnect attempts`); + return; + } + options.onOutput("\r\n\x1b[2m[reconnecting…]\x1b[0m\r\n"); + yield* Effect.sleep(reconnectDelay(timing, consecutive)); + } + }).pipe(Effect.catch((error) => Deferred.fail(ready, error).pipe(Effect.asVoid))); + + yield* Effect.forkScoped(runLifecycle); + yield* Deferred.await(ready); + + const sendCurrent = (payload: string, operation: "input" | "resize") => + Effect.suspend(() => { + const socket = currentSocket; + if (socket === null) { + return Effect.fail( + new CloudTerminalWriteError({ detail: `${operation}: terminal is reconnecting` }), + ); + } + return Effect.try({ + try: () => socket.send(payload), + catch: (cause) => + new CloudTerminalWriteError({ detail: `${operation}: socket send failed`, cause }), + }); + }); + + const connection: CloudTerminalConnection = { + write: (data) => + sendCurrent( + encodeTerminalInput({ + channel: "terminal", + type: "input", + sessionId: options.sessionId, + data, + }), + "input", + ), + resize: (cols, rows) => + Effect.suspend(() => { + // Record the latest size even if the send fails mid-reconnect, so the + // next attach recreates the PTY at this size. + currentCols = cols; + currentRows = rows; + return sendCurrent( + encodeTerminalResize({ + channel: "terminal", + type: "resize", + sessionId: options.sessionId, + cols, + rows, + }), + "resize", + ); + }), + }; + return connection; + }); 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/Layers/aether/wireEvents.ts b/apps/server/src/provider/Layers/aether/wireEvents.ts new file mode 100644 index 000000000000..059f9091555e --- /dev/null +++ b/apps/server/src/provider/Layers/aether/wireEvents.ts @@ -0,0 +1,505 @@ +/** + * 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; + +// --------------------------------------------------------------------------- +// 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 + | AetherWsAssistantDeltaEvent + | AetherWsThinkingDeltaEvent + | AetherWsStreamCompleteEvent + | AetherWsAssistantCompletedEvent + | AetherWsThinkingCompletedEvent + | AetherWsTurnCompletedEvent + | AetherWsTurnAwaitingInputEvent + | AetherWsTurnFailedEvent + | AetherWsConversationTruncatedEvent + | AetherWsSlashCommandsUpdatedEvent; + +// --------------------------------------------------------------------------- +// 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 } + /** + * 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 } + /** + * 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. */ + | { 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 decodeRequestIdProbe = Schema.decodeUnknownResult( + Schema.Struct({ requestId: 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 === "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 + // 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 }; + } + + 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..f6421b9df88b --- /dev/null +++ b/apps/server/src/provider/Layers/aether/workspaceSocket.test.ts @@ -0,0 +1,965 @@ +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +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 AetherAgentConnection, + type AetherAgentStreamOptions, + type AetherWebSocketLike, +} from "./workspaceSocket.ts"; +import type { AetherAgentEvent, AetherPortsMessage } 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 ports: 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 ports: Array = []; + const dropped: Array<{ key: string; detail: string }> = []; + const durableOnly: Array = []; + const connects: Array = []; + const connectRetries: Array<{ consecutiveFailures: number; detail: string }> = []; + return { + sockets, + events, + ports, + 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)), + 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)), + }, + }; +} + +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("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({ + 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([]); + }), + ); +}); + +// --------------------------------------------------------------------------- +// 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( + "fails an onConnected request IMMEDIATELY when the server closed right after open", + () => + Effect.gen(function* () { + // `WebSocket.send()` silently discards data once the socket is + // CLOSING/CLOSED, so a non-throwing subscribe is no proof of a live + // connection. Before the detached latch, the reconcile onConnected + // starts issued a request nothing could ever answer and waited the + // full 30s request budget before the queued close could be consumed + // and the reconnect begin. + const outcomes: Array = []; + const harness = makeHarness( + { + getTask: scriptedGetTask([taskProcessing]).getTask, + connectWorkspace: scriptedConnect([runningOutcome]).connectWorkspace, + }, + (socket, index) => { + if (index !== 0) { + return; + } + const originalSend = socket.send.bind(socket); + socket.send = (data: string) => { + originalSend(data); + socket.serverClose(1006, "closed right after open"); + }; + }, + ); + const options: AetherAgentStreamOptions = { + ...harness.options, + onConnected: (connection) => + Effect.gen(function* () { + const result = yield* Effect.result(connection.requestGitDiff({ mode: "main" })); + outcomes.push(Result.isFailure(result) ? result.failure._tag : "unexpected-success"); + }), + }; + const fiber = yield* Effect.forkChild(runAetherAgentStream(options)); + yield* settlePump; + // No clock advancement: the request budget never fired, the close did. + expect(outcomes[0]).toBe("AetherWorkspaceDetachedError"); + // And the drop was observed, so the loop reconnected. + expect(harness.sockets.length).toBeGreaterThan(1); + + yield* Fiber.interrupt(fiber); + }), + // A regression stalls for the request timeout instead of asserting. + { 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 new file mode 100644 index 000000000000..e1ece6658e4d --- /dev/null +++ b/apps/server/src/provider/Layers/aether/workspaceSocket.ts @@ -0,0 +1,990 @@ +/** + * 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 Deferred from "effect/Deferred"; +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, + parseAetherFileReadResponse, + parseAetherGitDiffResponse, + type AetherAgentEvent, + type AetherPortsMessage, + type AetherFrameParseResult, + type AetherWsFileReadSuccessResponse, + type AetherWsGitDiffResult, + type AetherWsRequestOutcome, +} 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; + +// --------------------------------------------------------------------------- +// 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) +// --------------------------------------------------------------------------- + +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; + +export 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; + /** Budget for one correlated git/files request over the live socket. */ + readonly requestTimeoutMs: number; +} + +export const DEFAULT_TIMING: AetherStreamTiming = { + pollInitialMs: 500, + pollMaxMs: 10_000, + reconnectInitialMs: 1_000, + reconnectMaxMs: 30_000, + openTimeoutMs: 15_000, + connectMaxAttempts: 60, + connectDefaultRetryMs: 1_000, + requestTimeoutMs: 30_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 + * 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; + /** + * 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 { + readonly restClient: Pick; + readonly apiBaseUrl: string; + readonly apiKey: string; + 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). 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. */ + 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 + * 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; + /** + * 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; +} + +export 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"), + }), + ), +); + +export 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.catchTags({ + 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; + // 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 + // 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 start = startPermission; + startPermission = false; + const transport = yield* connectForTransport({ + connectWorkspace: options.restClient.connectWorkspace, + workspaceId: resolution.workspaceId, + start, + timing, + }); + if (transport._tag === "unavailable") { + return { _tag: "unavailable", reason: transport.reason } 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 + // 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.catchTags({ + 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, + }), + ); + 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; + // Sticky once the socket is known dead. `WebSocket.send()` + // SILENTLY DISCARDS data in CLOSING/CLOSED, so a non-throwing + // send is no proof a request was delivered: without this latch a + // request issued after the close — including one `onConnected` + // reconciliation starts against a socket that died in the same + // tick as `open` — waits out the full `requestTimeoutMs` before + // the reconnect can begin. + let detached: AetherWorkspaceDetachedError | undefined; + const detach = (error: AetherWorkspaceDetachedError) => + Effect.gen(function* () { + detached = error; + for (const deferred of pending.values()) { + yield* Deferred.fail(deferred, error).pipe(Effect.ignore); + } + pending.clear(); + }); + const socketClosedDetached = new AetherWorkspaceDetachedError({ + detail: "the workspace socket closed with the request in flight", + }); + yield* Effect.addFinalizer(() => detach(socketClosedDetached)); + + 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* () { + if (detached !== undefined) { + return yield* detached; + } + 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.catchTags({ + 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") { + // Fail every in-flight request BEFORE handing the close to + // the consumer: the consumer cannot reach the queued close + // while `onConnected`/`onEvent` is still awaiting a git or + // files response, so a drop would otherwise stall the + // disconnect — and the reconnect — for a full request + // timeout. + yield* detach(socketClosedDetached); + 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(() => + opened.socket.send( + 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, + }), + previewToken: attached.previewToken, + workspaceId: attached.workspaceId, + }); + // The backoff ladder resets only once the connection PROVED + // usable. Resetting it at `open` meant a server that accepts the + // upgrade and then immediately closes (a persistent subscribe or + // protocol rejection) rearmed the minimum delay on every + // iteration, so the exponential ladder never advanced and the + // loop hot-reconnected at reconnectInitialMs forever. + if (detached === undefined) { + reconnectAttempt = 0; + } + + while (true) { + const item = yield* Queue.take(routed); + if (item._tag === "closed") { + return item; + } + const parsed = item.parsed; + 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 "ports": + yield* options.onPortsMessage(parsed.message); + 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.catchTags({ + 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, + }); + if (options.onDisconnected !== undefined) { + yield* options.onDisconnected(); + } + } + reconnectAttempt++; + yield* Effect.sleep( + Duration.millis( + backoffMs(timing.reconnectInitialMs, timing.reconnectMaxMs, reconnectAttempt - 1), + ), + ); + } +}); diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index 01eeae7b7bd7..c8bb8a57bfab 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -23,6 +23,8 @@ import type { import type * as Effect from "effect/Effect"; import type * as Stream from "effect/Stream"; +import type { CloudTerminalConnector } from "../CloudTerminalConnector.ts"; + export type ProviderSessionModelSwitchMode = "in-session" | "unsupported"; export interface ProviderAdapterCapabilities { @@ -49,6 +51,14 @@ export interface ProviderAdapterShape { readonly provider: ProviderDriverKind; readonly capabilities: ProviderAdapterCapabilities; + /** + * Attach an interactive shell inside the provider's remote compute. Present + * only for cloud providers; `undefined` for local-runtime providers. The + * terminal router uses its presence to decide whether a thread's shell runs + * in the cloud VM or as a local PTY. + */ + readonly cloudTerminal?: CloudTerminalConnector; + /** * Start a provider-backed session. */ 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 { + let reads = 0; + return { + getThreadShellById: (threadId: ThreadId) => + Effect.sync(() => { + reads += 1; + return Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + ...(reads === 1 ? {} : { worktreePath }), + }), + ); + }), + }; +}; + const browserOtlpTracingLayer = Layer.mergeAll( FetchHttpClient.layer, OtlpSerialization.layerJson, @@ -615,15 +639,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)({ @@ -735,9 +765,20 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(TerminalManager.TerminalManager)({ - ...options?.layers?.terminalManager, - }), + Layer.mergeAll( + Layer.mock(TerminalManager.TerminalManager)({ + ...options?.layers?.terminalManager, + }), + // Test threads are local-backed: `handles` returns false so every + // terminal RPC routes to the local TerminalManager mock. `close` is + // stubbed because archive tears down both managers unconditionally; + // `subscribeMetadata` because the metadata stream merges both. + Layer.mock(AetherTerminalManager.AetherTerminalManager)({ + handles: () => Effect.succeed(false), + close: () => Effect.void, + subscribeMetadata: () => Effect.succeed(() => {}), + }), + ), ), Layer.provide( Layer.mergeAll( @@ -7387,6 +7428,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { projectSetupScriptRunner: { runForThread, }, + projectionSnapshotQuery: bootstrapWorktreeShellQuery("/tmp/bootstrap-worktree"), }, }); @@ -7436,7 +7478,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", @@ -7636,6 +7678,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { projectSetupScriptRunner: { runForThread, }, + projectionSnapshotQuery: bootstrapWorktreeShellQuery("/tmp/bootstrap-worktree"), }, }); @@ -7682,7 +7725,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 => @@ -7697,6 +7745,128 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("tears down the bootstrap worktree when its attach is superseded", () => + Effect.gen(function* () { + // The decider no-ops a managed attach whose thread was repointed while + // the worktree was being created. The bootstrap must notice: the new + // checkout is not the thread's workspace, so nothing may run in it and + // it must not be left on disk. + const dispatchedCommands: Array = []; + const createWorktree = vi.fn( + (_: Parameters[0]) => + Effect.succeed({ + worktree: { + refName: "t3code/bootstrap-refName", + path: "/tmp/bootstrap-worktree", + }, + }), + ); + const removeWorktree = vi.fn( + (_: Parameters[0]) => Effect.void, + ); + const runForThread = vi.fn( + ( + input: Parameters< + ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]["runForThread"] + >[0], + ) => + Effect.succeed({ + status: "started" as const, + scriptId: "setup", + scriptName: "Setup", + terminalId: "setup-setup", + cwd: input.worktreePath, + }), + ); + + yield* buildAppUnderTest({ + layers: { + gitVcsDriver: { + createWorktree, + removeWorktree, + }, + orchestrationEngine: { + dispatch: (command) => + Effect.sync(() => { + dispatchedCommands.push(command); + return { sequence: dispatchedCommands.length }; + }), + readEvents: () => Stream.empty, + }, + projectSetupScriptRunner: { + runForThread, + }, + projectionSnapshotQuery: { + // The user repointed the thread mid-create, so the attach no-ops + // and the thread still points at THEIR worktree afterwards. + getThreadShellById: (threadId: ThreadId) => + Effect.succeed( + Option.some( + makeDefaultOrchestrationThreadShell({ + id: threadId, + worktreePath: "/tmp/user-picked-worktree", + }), + ), + ), + }, + }, + }); + + const createdAt = "2026-01-01T00:00:00.000Z"; + const wsUrl = yield* getWsServerUrl("/ws"); + yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[ORCHESTRATION_WS_METHODS.dispatchCommand]({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-bootstrap-turn-start-superseded"), + threadId: ThreadId.make("thread-bootstrap-superseded"), + message: { + messageId: MessageId.make("msg-bootstrap-superseded"), + role: "user", + text: "hello", + attachments: [], + }, + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + bootstrap: { + createThread: { + projectId: defaultProjectId, + title: "Bootstrap Thread", + modelSelection: defaultModelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: "main", + worktreePath: null, + createdAt, + }, + prepareWorktree: { + projectCwd: "/tmp/project", + baseBranch: "main", + branch: "t3code/bootstrap-refName", + }, + runSetupScript: true, + }, + createdAt, + }), + ), + ); + + // The orphan is removed, and the setup script never runs in it. + assert.deepEqual(removeWorktree.mock.calls[0]?.[0], { + cwd: "/tmp/project", + path: "/tmp/bootstrap-worktree", + force: true, + }); + assert.equal(runForThread.mock.calls.length, 0); + // The turn still starts — on the workspace the user chose. + assert.deepEqual( + dispatchedCommands.map((command) => command.type), + ["thread.create", "thread.worktree.attach-managed", "thread.turn.start"], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("does not misattribute setup activity dispatch failures as setup launch failures", () => Effect.gen(function* () { const dispatchedCommands: Array = []; @@ -7757,6 +7927,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { projectSetupScriptRunner: { runForThread, }, + projectionSnapshotQuery: bootstrapWorktreeShellQuery("/tmp/bootstrap-worktree"), }, }); @@ -7803,7 +7974,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/server.ts b/apps/server/src/server.ts index 32bcaaa8b96b..66e56e997f4d 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -42,6 +42,8 @@ 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 AetherTerminalManager from "./terminal/AetherTerminalManager.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as McpHttpServer from "./mcp/McpHttpServer.ts"; import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; @@ -257,7 +259,10 @@ const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( // NDJSON writers and is provided at the outer runtime layer so both // `ProviderService` and the per-instance drivers read the same logger pair. const ProviderLayerLive = ProviderServiceLive.pipe( - Layer.provide(ProviderAdapterRegistryLive), + // provideMerge (not provide): the AetherTerminalManager resolves cloud + // shells through the SAME adapter registry the turn engine uses, so it must + // be exposed downstream, not consumed here. + Layer.provideMerge(ProviderAdapterRegistryLive), Layer.provideMerge(ProviderSessionDirectoryLayerLive), ); @@ -324,6 +329,11 @@ const TerminalLayerLive = TerminalManager.layer.pipe( Layer.provide(PortScannerLayerLive), ); +// Cloud-terminal sibling: routed to per thread in ws.ts. Its deps +// (ProviderAdapterRegistry + ProviderSessionDirectory) are satisfied by +// ProviderRuntimeLayerLive later in the runtime pipe. +const AetherTerminalManagerLayerLive = AetherTerminalManager.layer; + const PreviewLayerLive = Layer.empty.pipe( Layer.provideMerge(PreviewManager.layer), Layer.provideMerge(PortScannerLayerLive), @@ -365,6 +375,13 @@ const ProviderRuntimeLayerLive = ProviderSessionReaperLive.pipe( Layer.provideMerge(OrchestrationLayerLive), ); +// AetherTerminalManager consumes the adapter registry + session directory that +// ProviderRuntimeLayerLive exposes. Compose them into one layer so the runtime +// pipe stays within the provideMerge arity cap. +const ProviderRuntimeWithTerminalLive = AetherTerminalManagerLayerLive.pipe( + Layer.provideMerge(ProviderRuntimeLayerLive), +); + const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // Core Services Layer.provideMerge(ServerSettingsLayerLive), @@ -372,7 +389,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( Layer.provideMerge(SourceControlProviderRegistryLayerLive), Layer.provideMerge(GitLayerLive), Layer.provideMerge(VcsLayerLive), - Layer.provideMerge(ProviderRuntimeLayerLive), + Layer.provideMerge(ProviderRuntimeWithTerminalLive), Layer.provideMerge(Layer.mergeAll(TerminalLayerLive, PreviewLayerLive)), Layer.provideMerge(PersistenceLayerLive), Layer.provideMerge(Keybindings.layer), @@ -382,13 +399,22 @@ 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). // 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/terminal/AetherTerminalManager.ts b/apps/server/src/terminal/AetherTerminalManager.ts new file mode 100644 index 000000000000..0030a547c604 --- /dev/null +++ b/apps/server/src/terminal/AetherTerminalManager.ts @@ -0,0 +1,736 @@ +/** + * AetherTerminalManager — the terminal RPC surface for threads backed by the + * Aether cloud provider, attaching a shell INSIDE the task's cloud VM over its + * own tab-scoped workspace socket (see `provider/Layers/aether/terminalConnection`). + * + * It is a sibling of the local `TerminalManager`, not a replacement: the ws + * terminal router dispatches per thread (`handles`) so cloud threads get a VM + * shell while local threads keep their local PTY. Only cloud-relevant surface + * is implemented — no local pid/shell/cwd/subprocess concepts exist for a + * remote PTY. Each session owns one connection under a `CloseableScope`; + * closing the tab (or the session) tears the shell + socket down. + * + * Ordering guarantee: a per-session lock serializes "append history + deliver + * live output" (the drain fiber) against "register listener + emit snapshot" + * (attach), so a freshly attached client always sees the snapshot first and + * then every subsequent byte exactly once, in order. + * + * @module terminal/AetherTerminalManager + */ +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import { + ProviderDriverKind, + ThreadId, + TerminalNotRunningError, + TerminalResizeError, + TerminalSessionLookupError, + TerminalWriteError, + type TerminalAttachInput, + type TerminalAttachStreamEvent, + type TerminalClearInput, + type TerminalCloseInput, + type TerminalError, + type TerminalMetadataStreamEvent, + type TerminalOpenInput, + type TerminalResizeInput, + type TerminalRestartInput, + type TerminalSessionSnapshot, + type TerminalSessionStatus, + type TerminalSummary, + type TerminalWriteInput, +} from "@t3tools/contracts"; + +import type { CloudTerminalConnection } from "../provider/CloudTerminalConnector.ts"; +import { parseAetherResume } from "../provider/Layers/AetherAdapter.ts"; +import * as ProviderAdapterRegistry from "../provider/Services/ProviderAdapterRegistry.ts"; +import * as ProviderSessionDirectory from "../provider/Services/ProviderSessionDirectory.ts"; + +const AETHER_DRIVER_KIND = ProviderDriverKind.make("aether"); +const DEFAULT_COLS = 80; +const DEFAULT_ROWS = 24; +/** Scrollback cap replayed to a (re)attaching client — bytes, not lines. */ +const MAX_HISTORY_BYTES = 256 * 1024; + +type Listener = (event: TerminalAttachStreamEvent) => Effect.Effect; + +type IngressEvent = + | { readonly _tag: "output"; readonly data: string } + | { readonly _tag: "closed"; readonly reason: string }; + +interface AetherTerminalSession { + readonly threadId: string; + readonly terminalId: string; + readonly sessionId: string; + readonly cwd: string; + readonly lock: Semaphore.Semaphore; + readonly listeners: Set; + status: TerminalSessionStatus; + history: string; + cols: number; + rows: number; + sequence: number; + updatedAt: string; + connection: CloudTerminalConnection | null; + scope: Scope.Closeable | null; + /** Last connect-failure message, re-emitted to a listener that attaches after the failure. */ + errorMessage: string | null; +} + +export class AetherTerminalManager extends Context.Service< + AetherTerminalManager, + { + /** Whether this thread's shell should run in the Aether cloud VM. */ + readonly handles: (threadId: string) => Effect.Effect; + readonly open: ( + input: TerminalOpenInput, + ) => Effect.Effect; + readonly attachStream: ( + input: TerminalAttachInput, + listener: Listener, + ) => Effect.Effect<() => void, TerminalError>; + readonly write: (input: TerminalWriteInput) => Effect.Effect; + readonly resize: (input: TerminalResizeInput) => Effect.Effect; + readonly clear: (input: TerminalClearInput) => Effect.Effect; + readonly restart: ( + input: TerminalRestartInput, + ) => Effect.Effect; + readonly close: (input: TerminalCloseInput) => Effect.Effect; + readonly subscribeMetadata: ( + listener: (event: TerminalMetadataStreamEvent) => Effect.Effect, + ) => Effect.Effect<() => void>; + } +>()("t3/terminal/AetherTerminalManager") {} + +const SESSION_KEY_SEPARATOR = " "; +const sessionKey = (threadId: string, terminalId: string): string => + `${threadId}${SESSION_KEY_SEPARATOR}${terminalId}`; +const threadKeyPrefix = (threadId: string): string => `${threadId}${SESSION_KEY_SEPARATOR}`; + +const nowIso = (): string => DateTime.formatIso(DateTime.nowUnsafe()); + +const capHistory = (history: string): string => + history.length > MAX_HISTORY_BYTES ? history.slice(history.length - MAX_HISTORY_BYTES) : history; + +const labelForTerminal = (terminalId: string): string => { + const match = /^term-(\d+)$/.exec(terminalId); + return match ? `Terminal ${match[1]}` : terminalId; +}; + +const snapshotOf = (session: AetherTerminalSession): TerminalSessionSnapshot => ({ + threadId: session.threadId, + terminalId: session.terminalId, + cwd: session.cwd, + worktreePath: null, + status: session.status, + pid: null, + history: session.history, + exitCode: null, + exitSignal: null, + label: labelForTerminal(session.terminalId), + updatedAt: session.updatedAt, + sequence: session.sequence, +}); + +const summaryOf = (session: AetherTerminalSession): TerminalSummary => ({ + threadId: session.threadId, + terminalId: session.terminalId, + cwd: session.cwd, + worktreePath: null, + status: session.status, + pid: null, + exitCode: null, + exitSignal: null, + hasRunningSubprocess: false, + label: labelForTerminal(session.terminalId), + updatedAt: session.updatedAt, +}); + +const resolveDetail = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +export const make = Effect.fn("AetherTerminalManager.make")(function* () { + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry; + // The manager's own (app-lifetime) scope. Used to close a session's + // connection scope from inside its drain fiber without self-interrupt: the + // drain lives in the session scope, so the close must run on a fiber that + // does not. + const managerScope = yield* Effect.scope; + + const sessions = new Map(); + // Provider binding is immutable per thread, so a routing decision caches + // forever — keeps per-keystroke `write` off the persistence layer. + const routingCache = new Map(); + + const deliver = (session: AetherTerminalSession, event: TerminalAttachStreamEvent) => + session.lock.withPermits(1)( + Effect.forEach(Array.from(session.listeners), (listener) => listener(event), { + discard: true, + }), + ); + + // Cross-thread terminal list. Aether sessions publish upsert/remove here; the + // ws router folds this stream into the local manager's metadata so cloud + // terminals appear alongside local ones. + const metadataListeners = new Set<(event: TerminalMetadataStreamEvent) => Effect.Effect>(); + const emitMetadata = (event: TerminalMetadataStreamEvent) => + Effect.forEach(Array.from(metadataListeners), (listener) => listener(event), { discard: true }); + const emitUpsert = (session: AetherTerminalSession) => + emitMetadata({ type: "upsert", terminal: summaryOf(session) }); + + const drainLoop = ( + session: AetherTerminalSession, + ingress: Queue.Queue, + ): Effect.Effect => { + const step = Queue.take(ingress).pipe( + Effect.flatMap((event) => + session.lock.withPermits(1)( + Effect.gen(function* () { + if (event._tag === "output") { + session.history = capHistory(session.history + event.data); + session.sequence += 1; + session.updatedAt = nowIso(); + const wire: TerminalAttachStreamEvent = { + type: "output", + threadId: session.threadId, + terminalId: session.terminalId, + sequence: session.sequence, + data: event.data, + }; + yield* Effect.forEach(Array.from(session.listeners), (listener) => listener(wire), { + discard: true, + }); + return true; + } + if (session.status !== "exited" && session.status !== "error") { + session.status = "exited"; + session.connection = null; + const exitedScope = session.scope; + session.scope = null; + session.sequence += 1; + session.updatedAt = nowIso(); + const wire: TerminalAttachStreamEvent = { + type: "exited", + threadId: session.threadId, + terminalId: session.terminalId, + sequence: session.sequence, + exitCode: null, + exitSignal: null, + }; + yield* Effect.forEach(Array.from(session.listeners), (listener) => listener(wire), { + discard: true, + }); + yield* emitUpsert(session); + // The shell is gone: free the workspace socket now instead of + // holding it until the tab closes. Fork the close onto the + // manager scope — this drain fiber lives in `exitedScope`, so + // closing it inline would interrupt the close itself. + if (exitedScope) { + yield* Scope.close(exitedScope, Exit.void).pipe(Effect.forkIn(managerScope)); + } + } + return false; + }), + ), + ), + ); + return step.pipe( + Effect.flatMap((keepGoing) => (keepGoing ? drainLoop(session, ingress) : Effect.void)), + ); + }; + + const resolveAetherTask = (threadId: string) => + Effect.gen(function* () { + const bindingOption = yield* directory.getBinding(ThreadId.make(threadId)); + const binding = Option.getOrUndefined(bindingOption); + if (!binding || binding.provider !== AETHER_DRIVER_KIND) { + return yield* Effect.die( + new Error(`AetherTerminalManager routed a non-Aether thread '${threadId}'.`), + ); + } + const instanceId = binding.providerInstanceId; + if (instanceId === undefined || instanceId === null) { + return { + _tag: "unavailable", + reason: "the thread has no provider instance binding.", + } as const; + } + const cursor = parseAetherResume(binding.resumeCursor); + if (cursor === undefined) { + return { + _tag: "unavailable", + reason: "this thread has no cloud task yet — run a turn before opening a terminal.", + } as const; + } + return { _tag: "ready", instanceId, taskId: cursor.taskId } as const; + }); + + const teardownConnection = (session: AetherTerminalSession) => + Effect.gen(function* () { + const scope = session.scope; + session.connection = null; + session.scope = null; + if (scope) { + yield* Scope.close(scope, Exit.void); + } + }); + + // On manager/app shutdown, close every open session's connection scope so no + // VM socket leaks. Session scopes are standalone (Scope.make), so nothing + // else reaps them. + yield* Effect.addFinalizer(() => + Effect.forEach(Array.from(sessions.values()), teardownConnection, { discard: true }), + ); + + /** Establish (or re-establish) the VM shell for a session; loud failures become error events. */ + const establishConnection = (session: AetherTerminalSession) => + Effect.gen(function* () { + const resolved = yield* resolveAetherTask(session.threadId); + if (resolved._tag === "unavailable") { + return yield* Effect.fail(resolved.reason); + } + const adapter = yield* registry.getByInstance(resolved.instanceId); + const connector = adapter.cloudTerminal; + if (connector === undefined) { + return yield* Effect.fail( + "this Aether instance cannot open a cloud terminal — set AETHER_API_KEY on the provider instance.", + ); + } + const scope = yield* Scope.make(); + session.scope = scope; + // Fresh ingress per connection: a torn-down connection's in-flight + // frames must never bleed into a later one (e.g. after restart). + const ingress = yield* Queue.unbounded(); + yield* drainLoop(session, ingress).pipe(Effect.forkIn(scope)); + const connection = yield* connector + .openConnection({ + taskId: resolved.taskId, + sessionId: session.sessionId, + cols: session.cols, + rows: session.rows, + onOutput: (data) => { + Queue.offerUnsafe(ingress, { _tag: "output", data }); + }, + onClosed: (reason) => { + Queue.offerUnsafe(ingress, { _tag: "closed", reason }); + }, + }) + .pipe( + Effect.provideService(Scope.Scope, scope), + Effect.mapError((error) => error.message), + ); + session.connection = connection; + session.status = "running"; + session.errorMessage = null; + session.updatedAt = nowIso(); + yield* emitUpsert(session); + }).pipe( + Effect.catch((reason) => + Effect.gen(function* () { + yield* teardownConnection(session); + const message = typeof reason === "string" ? reason : resolveDetail(reason); + session.status = "error"; + session.errorMessage = message; + session.sequence += 1; + session.updatedAt = nowIso(); + yield* deliver(session, { + type: "error", + threadId: session.threadId, + terminalId: session.terminalId, + sequence: session.sequence, + message, + }); + yield* emitUpsert(session); + }), + ), + ); + + const createSession = (input: { + readonly threadId: string; + readonly terminalId: string; + readonly cwd: string; + readonly cols: number; + readonly rows: number; + }) => + Effect.gen(function* () { + const lock = Semaphore.makeUnsafe(1); + const session: AetherTerminalSession = { + threadId: input.threadId, + terminalId: input.terminalId, + sessionId: input.terminalId, + cwd: input.cwd, + lock, + listeners: new Set(), + status: "starting", + history: "", + cols: input.cols, + rows: input.rows, + sequence: 0, + updatedAt: nowIso(), + connection: null, + scope: null, + errorMessage: null, + }; + sessions.set(sessionKey(input.threadId, input.terminalId), session); + yield* establishConnection(session); + return session; + }); + + /** + * One creation slot per terminal key. `open`'s "is there a session?" check + * and `createSession`'s `sessions.set` are separated by yields, so two + * concurrent opens for the SAME new terminal both saw nothing, both built a + * session, and the second `set` orphaned the first — along with its + * standalone Scope, which neither `close` nor the shutdown finalizer can + * reach afterwards, leaking a live cloud shell and its socket. Serializing + * per key makes the second caller observe the first's session and take the + * reuse path. Built with `makeUnsafe` so the map lookup that hands out the + * lock cannot itself yield between its get and set. + */ + const openLocks = new Map(); + const openLockFor = (key: string): Semaphore.Semaphore => { + const existing = openLocks.get(key); + if (existing !== undefined) { + return existing; + } + const created = Semaphore.makeUnsafe(1); + openLocks.set(key, created); + return created; + }; + + const openSerialized: AetherTerminalManager["Service"]["open"] = (input) => + Effect.gen(function* () { + const key = sessionKey(input.threadId, input.terminalId); + const cols = input.cols ?? DEFAULT_COLS; + const rows = input.rows ?? DEFAULT_ROWS; + const existing = sessions.get(key); + if (existing && existing.connection && existing.status === "running") { + if (existing.cols !== cols || existing.rows !== rows) { + existing.cols = cols; + existing.rows = rows; + yield* existing.connection.resize(cols, rows).pipe( + Effect.mapError( + (cause) => + new TerminalResizeError({ + threadId: input.threadId, + terminalId: input.terminalId, + terminalPid: 0, + cols, + rows, + cause, + }), + ), + ); + } + return snapshotOf(existing); + } + if (existing) { + yield* teardownConnection(existing); + existing.status = "starting"; + existing.history = ""; + existing.cols = cols; + existing.rows = rows; + yield* establishConnection(existing); + return snapshotOf(existing); + } + const session = yield* createSession({ + threadId: input.threadId, + terminalId: input.terminalId, + cwd: input.cwd, + cols, + rows, + }); + return snapshotOf(session); + }); + + const open: AetherTerminalManager["Service"]["open"] = (input) => + Effect.suspend(() => + openLockFor(sessionKey(input.threadId, input.terminalId)).withPermits(1)( + openSerialized(input), + ), + ); + + const attachStream: AetherTerminalManager["Service"]["attachStream"] = (input, listener) => + Effect.gen(function* () { + const key = sessionKey(input.threadId, input.terminalId); + let session = sessions.get(key); + if (!session) { + if (input.cwd === undefined) { + return yield* new TerminalSessionLookupError({ + threadId: input.threadId, + terminalId: input.terminalId, + }); + } + yield* open({ + threadId: input.threadId, + terminalId: input.terminalId, + cwd: input.cwd, + ...(input.cols !== undefined ? { cols: input.cols } : {}), + ...(input.rows !== undefined ? { rows: input.rows } : {}), + }); + session = sessions.get(key); + } else if ( + !session.connection && + input.restartIfNotRunning === true && + input.cwd !== undefined + ) { + yield* open({ + threadId: input.threadId, + terminalId: input.terminalId, + cwd: input.cwd, + ...(input.cols !== undefined ? { cols: input.cols } : {}), + ...(input.rows !== undefined ? { rows: input.rows } : {}), + }); + session = sessions.get(key); + } + if (!session) { + return yield* new TerminalSessionLookupError({ + threadId: input.threadId, + terminalId: input.terminalId, + }); + } + const target = session; + // Register + snapshot atomically against the drain: the listener is + // added and the snapshot captured under the lock, so no output is + // delivered before the snapshot or dropped between the two. + yield* target.lock.withPermits(1)( + Effect.gen(function* () { + target.listeners.add(listener); + yield* listener({ type: "snapshot", snapshot: snapshotOf(target) }); + // Re-surface a connect failure that happened before this listener + // attached (open → error → attach): the snapshot carries status but + // not the message, so replay it as an error event. + if (target.status === "error" && target.errorMessage !== null) { + yield* listener({ + type: "error", + threadId: target.threadId, + terminalId: target.terminalId, + sequence: target.sequence, + message: target.errorMessage, + }); + } + }).pipe( + // The listener is registered BEFORE the initial delivery so no output + // can slip between the two — which means a delivery that defects, or + // an interrupt before the unsubscribe below is returned, would strand + // a dead listener in the set. `drainLoop` then defects on the next + // output and terminal output stops for every remaining healthy + // client, so unwind the registration on any non-success exit. + Effect.onError(() => Effect.sync(() => target.listeners.delete(listener))), + ), + ); + return () => { + target.listeners.delete(listener); + }; + }).pipe( + // `onError` above covers a failed delivery, but not an interrupt landing + // AFTER the lock block succeeded and BEFORE the caller receives the + // unsubscribe — the listener would then be registered with no one able to + // remove it. Deleting is idempotent, so covering the whole attach is + // free (the same guard `subscribeMetadata` already carries). + Effect.onInterrupt(() => + Effect.sync(() => { + sessions.get(sessionKey(input.threadId, input.terminalId))?.listeners.delete(listener); + }), + ), + ); + + const write: AetherTerminalManager["Service"]["write"] = (input) => + Effect.gen(function* () { + const session = sessions.get(sessionKey(input.threadId, input.terminalId)); + if (!session || !session.connection) { + return yield* new TerminalNotRunningError({ + threadId: input.threadId, + terminalId: input.terminalId, + }); + } + yield* session.connection.write(input.data).pipe( + Effect.mapError( + (cause) => + new TerminalWriteError({ + threadId: input.threadId, + terminalId: input.terminalId, + terminalPid: 0, + cause, + }), + ), + ); + }); + + const resize: AetherTerminalManager["Service"]["resize"] = (input) => + Effect.gen(function* () { + const session = sessions.get(sessionKey(input.threadId, input.terminalId)); + if (!session || !session.connection) { + return yield* new TerminalNotRunningError({ + threadId: input.threadId, + terminalId: input.terminalId, + }); + } + session.cols = input.cols; + session.rows = input.rows; + yield* session.connection.resize(input.cols, input.rows).pipe( + Effect.mapError( + (cause) => + new TerminalResizeError({ + threadId: input.threadId, + terminalId: input.terminalId, + terminalPid: 0, + cols: input.cols, + rows: input.rows, + cause, + }), + ), + ); + }); + + const closeOne = (session: AetherTerminalSession) => + Effect.gen(function* () { + session.sequence += 1; + yield* deliver(session, { + type: "closed", + threadId: session.threadId, + terminalId: session.terminalId, + sequence: session.sequence, + }); + yield* emitMetadata({ + type: "remove", + threadId: session.threadId, + terminalId: session.terminalId, + }); + yield* teardownConnection(session); + }); + + const close: AetherTerminalManager["Service"]["close"] = (input) => + Effect.gen(function* () { + const keys = + input.terminalId !== undefined + ? [sessionKey(input.threadId, input.terminalId)] + : Array.from(sessions.keys()).filter((key) => + key.startsWith(threadKeyPrefix(input.threadId)), + ); + for (const key of keys) { + const session = sessions.get(key); + if (!session) continue; + // Drop the session from the map only AFTER teardown is guaranteed: + // the manager's shutdown finalizer closes what is still in the map, so + // a `closeOne` that fails or is interrupted before `teardownConnection` + // would orphan a live cloud shell and its socket. + yield* closeOne(session); + sessions.delete(key); + } + }); + + const clear: AetherTerminalManager["Service"]["clear"] = (input) => + Effect.gen(function* () { + const session = sessions.get(sessionKey(input.threadId, input.terminalId)); + if (!session) return; + yield* session.lock.withPermits(1)( + Effect.gen(function* () { + session.history = ""; + session.sequence += 1; + session.updatedAt = nowIso(); + yield* Effect.forEach( + Array.from(session.listeners), + (listener) => + listener({ + type: "cleared", + threadId: session.threadId, + terminalId: session.terminalId, + sequence: session.sequence, + }), + { discard: true }, + ); + }), + ); + }); + + const restart: AetherTerminalManager["Service"]["restart"] = (input) => + Effect.gen(function* () { + const key = sessionKey(input.threadId, input.terminalId); + const session = sessions.get(key); + if (session) { + yield* teardownConnection(session); + session.status = "starting"; + session.history = ""; + session.cols = input.cols; + session.rows = input.rows; + yield* establishConnection(session); + // Every other event path bumps the sequence before building its event; + // without it a restart with no intervening output carries the previous + // event's number and sequence-deduping clients drop the notification. + session.sequence += 1; + yield* deliver(session, { + type: "restarted", + threadId: session.threadId, + terminalId: session.terminalId, + sequence: session.sequence, + snapshot: snapshotOf(session), + }); + return snapshotOf(session); + } + return yield* open({ + threadId: input.threadId, + terminalId: input.terminalId, + cwd: input.cwd, + cols: input.cols, + rows: input.rows, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + ...(input.env !== undefined ? { env: input.env } : {}), + }); + }); + + const handles: AetherTerminalManager["Service"]["handles"] = (threadId) => + Effect.gen(function* () { + const cached = routingCache.get(threadId); + if (cached !== undefined) return cached; + const bindingOption = yield* directory.getBinding(ThreadId.make(threadId)).pipe(Effect.orDie); + // Cache only a REAL answer. The binding is installed by the first turn, + // so a terminal opened before then has none yet — caching that absence + // as "not Aether" would pin the thread to the local PTY for the rest of + // its life, and shell writes there would dirty the driver-owned mirror. + // Provider binding is immutable once written, so a present binding is + // safe to cache forever. + if (Option.isNone(bindingOption)) { + return false; + } + const isAether = bindingOption.value.provider === AETHER_DRIVER_KIND; + routingCache.set(threadId, isAether); + return isAether; + }); + + const subscribeMetadata: AetherTerminalManager["Service"]["subscribeMetadata"] = (listener) => + Effect.gen(function* () { + metadataListeners.add(listener); + const terminals = Array.from(sessions.values()).map(summaryOf); + yield* listener({ type: "snapshot", terminals }); + return () => { + metadataListeners.delete(listener); + }; + }).pipe( + // If the initial snapshot delivery is interrupted before the unsubscribe + // is returned, drop the listener so it does not leak. + Effect.onInterrupt(() => Effect.sync(() => metadataListeners.delete(listener))), + ); + + return { + handles, + open, + attachStream, + write, + resize, + clear, + restart, + close, + subscribeMetadata, + } satisfies AetherTerminalManager["Service"]; +}); + +export const layer = Layer.effect(AetherTerminalManager, make()); diff --git a/apps/server/src/textGeneration/AetherTextGeneration.test.ts b/apps/server/src/textGeneration/AetherTextGeneration.test.ts new file mode 100644 index 000000000000..832174cae264 --- /dev/null +++ b/apps/server/src/textGeneration/AetherTextGeneration.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { stubBranchName } from "./AetherTextGeneration.ts"; + +describe("stubBranchName", () => { + it("keeps the date suffix when the message slug fills the whole limit", () => { + // Slugging to 64 chars first and re-sanitizing after left no room for the + // suffix, so every date collapsed to the same branch name. + const longMessage = "a".repeat(200); + const first = stubBranchName(longMessage, "2026-08-01"); + const second = stubBranchName(longMessage, "2026-08-02"); + expect(first).toContain("-2026-08-01"); + expect(second).toContain("-2026-08-02"); + expect(first).not.toBe(second); + expect(first.length).toBeLessThanOrEqual(64); + }); + + it("still slugs a short message plus the date", () => { + expect(stubBranchName("Add a safer reconnect backoff", "2026-08-01")).toBe( + "add-a-safer-reconnect-backoff-2026-08-01", + ); + }); +}); diff --git a/apps/server/src/textGeneration/AetherTextGeneration.ts b/apps/server/src/textGeneration/AetherTextGeneration.ts new file mode 100644 index 000000000000..ff39be36b098 --- /dev/null +++ b/apps/server/src/textGeneration/AetherTextGeneration.ts @@ -0,0 +1,108 @@ +/** + * AetherTextGeneration — deterministic, never-failing text generation stubs. + * + * Aether exposes no one-shot completion endpoint, so the driver ships + * mechanical generators instead of model calls: commit subjects come from the + * staged summary, PR title/body are passthrough truncation of the git + * context, and branch names are slugs of the user's message plus the date. + * None of these methods may fail — a failure here would block commit/PR + * flows for no good reason. + * + * @module textGeneration/AetherTextGeneration + */ +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import { BRANCH_FRAGMENT_MAX_CHARS, sanitizeBranchFragment } from "@t3tools/shared/git"; + +import type * as TextGeneration from "./TextGeneration.ts"; +import { + limitSection, + sanitizeCommitSubject, + sanitizePrTitle, + sanitizeThreadTitle, +} from "./TextGenerationUtils.ts"; + +const PR_BODY_SECTION_MAX_CHARS = 4_000; + +function firstNonEmptyLine(value: string): string | undefined { + for (const line of value.split(/\r?\n/g)) { + const trimmed = line.trim(); + if (trimmed.length > 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. The slug is truncated with room for the suffix RESERVED — the + * sanitizer caps at 64 chars, so slugging first and trimming after cut the + * date off any message at or past the cap and returned the same branch for + * every date. + */ +export function stubBranchName(message: string, isoDate: string): string { + const dateSuffix = `-${isoDate}`; + const messageSlug = sanitizeBranchFragment(message).slice( + 0, + BRANCH_FRAGMENT_MAX_CHARS - dateSuffix.length, + ); + return sanitizeBranchFragment(`${messageSlug}${dateSuffix}`); +} + +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/server/src/ws.ts b/apps/server/src/ws.ts index 126222d214a2..b4dada0d65d6 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,11 +81,20 @@ import { observeRpcStreamEffect as instrumentRpcStreamEffect, } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; +import { + AETHER_MIRROR_REFUSAL, + guardAetherQueuedMutation, + guardAetherWriteFile, + 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"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; +import * as AetherTerminalManager from "./terminal/AetherTerminalManager.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; import * as PreviewManager from "./preview/Manager.ts"; @@ -362,10 +373,71 @@ 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; const terminalManager = yield* TerminalManager.TerminalManager; + const aetherTerminalManager = yield* AetherTerminalManager.AetherTerminalManager; + // Route each per-thread terminal RPC to the cloud-VM shell when the + // thread is Aether-backed, else the local PTY. `handles` caches per + // thread, so this is a plain in-memory check after the first call. + // + // A thread's terminals must all live in ONE manager. The Aether binding + // only appears with the thread's first turn, so a terminal opened before + // then lands on the local PTY — and once the binding exists every later + // op routes to the cloud manager, leaving that PTY running, unreachable, + // inside the checkout the mirror is about to claim. The first time a + // thread is seen as cloud-backed, close whatever it left behind locally. + const localTerminalsReconciled = new Set(); + const routeTerminal = ( + threadId: string, + onAether: () => Effect.Effect, + onLocal: () => Effect.Effect, + ): Effect.Effect => + aetherTerminalManager.handles(threadId).pipe( + Effect.flatMap((useAether) => { + if (!useAether) { + return onLocal(); + } + if (localTerminalsReconciled.has(threadId)) { + return onAether(); + } + localTerminalsReconciled.add(threadId); + return terminalManager.close({ threadId }).pipe( + Effect.catch((error) => + Effect.logWarning( + "failed to close the pre-binding local terminals of a cloud thread", + { + threadId, + error: error.message, + }, + ), + ), + Effect.andThen(onAether()), + ); + }), + ); const previewManager = yield* PreviewManager.PreviewManager; const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; @@ -911,6 +983,19 @@ const makeWsRpcLayer = ( } if (bootstrap?.prepareWorktree) { + // The thread's workspace as it stands BEFORE the worktree is + // created — creating one takes seconds, and the attach below + // must not overwrite a `thread.meta.update` that lands meanwhile. + const threadBeforePrepare = yield* projectionSnapshotQuery.getThreadShellById( + command.threadId, + ); + const expectedWorkspace = Option.match(threadBeforePrepare, { + onNone: () => ({ branch: null, worktreePath: null }), + onSome: (thread) => ({ + branch: thread.branch, + worktreePath: thread.worktreePath, + }), + }); let worktreeBaseRef = bootstrap.prepareWorktree.baseBranch; // "Start from origin" is a stored default; repos without an // origin remote fall back to the local base branch instead of @@ -941,14 +1026,51 @@ 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, + expectedBranch: expectedWorkspace.branch, + expectedWorktreePath: expectedWorkspace.worktreePath, + }); + // The decider NO-OPS that attach when the thread was repointed + // while the worktree was being created, so the dispatch alone is + // no proof it landed. If it did not, the checkout just created is + // an orphan: the thread runs somewhere else, nothing may execute + // in it (a setup script there would be invisible to the user), + // and leaving it on disk leaks a worktree and its branch. + const threadAfterAttach = yield* projectionSnapshotQuery.getThreadShellById( + command.threadId, + ); + const attachApplied = Option.match(threadAfterAttach, { + onNone: () => false, + onSome: (thread) => thread.worktreePath === targetWorktreePath, }); - yield* refreshGitStatus(targetWorktreePath); + if (attachApplied) { + yield* refreshGitStatus(targetWorktreePath); + } else { + const orphanPath = targetWorktreePath; + // Skips runSetupProgram(), which requires a target worktree. + targetWorktreePath = null; + yield* Effect.logWarning( + "bootstrap worktree attach was superseded; removing the orphaned worktree", + { threadId: command.threadId, worktreePath: orphanPath }, + ); + yield* gitWorkflow + .removeWorktree({ + cwd: bootstrap.prepareWorktree.projectCwd, + path: orphanPath, + force: true, + }) + .pipe(Effect.ignoreCause({ log: true })); + } } yield* runSetupProgram(); @@ -1071,50 +1193,59 @@ const makeWsRpcLayer = ( ) : false; const result = yield* dispatchNormalizedCommand(normalizedCommand); - if (parkingCommand) { + if (parkingCommand && shouldStopSessionAfterCommand) { const parkingKind = parkingCommand.type === "thread.archive" ? "archive" : "settle"; - if (shouldStopSessionAfterCommand) { - yield* Effect.gen(function* () { - const stopCommand = yield* normalizeDispatchCommand({ - type: "thread.session.stop", - commandId: CommandId.make( - `session-stop-for-${parkingKind}:${parkingCommand.commandId}`, - ), - threadId: parkingCommand.threadId, - createdAt: yield* nowIso, - // A settled thread can be re-engaged before this stop is - // decided; the decider then drops the stop instead of - // killing the new session. Archive stops stay - // unconditional: turn starts on archived threads are - // rejected, so there is no new session to protect. - ...(parkingKind === "settle" ? { onlyIfSettled: true } : {}), - }); - - yield* dispatchNormalizedCommand(stopCommand); - }).pipe( - Effect.catchCause((cause) => - Effect.logWarning(`failed to stop provider session during ${parkingKind}`, { - threadId: parkingCommand.threadId, - cause, - }), + yield* Effect.gen(function* () { + const stopCommand = yield* normalizeDispatchCommand({ + type: "thread.session.stop", + commandId: CommandId.make( + `session-stop-for-${parkingKind}:${parkingCommand.commandId}`, ), - ); - } + threadId: parkingCommand.threadId, + createdAt: yield* nowIso, + // A settled thread can be re-engaged before this stop is + // decided; the decider then drops the stop instead of + // killing the new session. Archive stops stay unconditional: + // turn starts on archived threads are rejected, so there is + // no new session to protect. + ...(parkingKind === "settle" ? { onlyIfSettled: true } : {}), + }); - // Terminals are user-opened panes, not thread background - // work: archive removes the thread from view so they close - // with it, but a settled thread stays reachable and may be - // un-settled, so its terminals stay up. - if (parkingCommand.type === "thread.archive") { - yield* terminalManager.close({ threadId: parkingCommand.threadId }).pipe( - Effect.catch((error) => - Effect.logWarning("failed to close thread terminals after archive", { - threadId: parkingCommand.threadId, - error: error.message, - }), - ), - ); - } + yield* dispatchNormalizedCommand(stopCommand); + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning(`failed to stop provider session during ${parkingKind}`, { + threadId: parkingCommand.threadId, + cause, + }), + ), + ); + } + + if (normalizedCommand.type === "thread.archive") { + // Close BOTH managers: a thread is either local- or Aether-backed, + // and closing the one with no sessions is a no-op. Settle keeps its + // terminals: a settled thread stays reachable and may be un-settled. + // DELETION is not handled here — ThreadDeletionReactor owns it, so + // that a `project.delete` cascade (whose child `thread.deleted` + // events never surface as a dispatched command) tears its cloud + // terminals down too instead of leaking their VMs. + yield* terminalManager.close({ threadId: normalizedCommand.threadId }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to close thread terminals after archive", { + threadId: normalizedCommand.threadId, + error: error.message, + }), + ), + ); + yield* aetherTerminalManager.close({ threadId: normalizedCommand.threadId }).pipe( + Effect.catch((error) => + Effect.logWarning("failed to close cloud terminals after archive", { + threadId: normalizedCommand.threadId, + error: error.message, + }), + ), + ); } return result; }).pipe( @@ -1791,15 +1922,19 @@ 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, - }), + guardAetherWriteFile( + aetherMirrorRegistry, + input, + workspaceFileSystem.writeFile(input).pipe( + Effect.mapError( + (cause) => + new ProjectWriteFileError({ + cwd: input.cwd, + relativePath: input.relativePath, + ...projectFileFailureContext(cause), + cause, + }), + ), ), ), { "rpc.aggregate": "workspace" }, @@ -1914,35 +2049,60 @@ 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, + // The ownership check and the run share ONE frozen region, held for + // the whole mutation: runStackedAction commits, branches and + // pushes, so a session registering between a `false` answer and + // the run would land all of that in a live one-way mirror. Checking + // outside the region (as this site used to) took no reader permit + // at all, so the exclusive registration never waited for it. 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)), - ), + guardAetherQueuedMutation( + aetherMirrorRegistry, + input.cwd, + Queue.fail( + queue, + new GitManagerError({ + operation: "git.runStackedAction", + cwd: input.cwd, + detail: AETHER_MIRROR_REFUSAL, }), - ), + ).pipe(Effect.asVoid), + 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" }, ), @@ -1969,25 +2129,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) => @@ -2009,40 +2184,93 @@ const makeWsRpcLayer = ( { "rpc.aggregate": "review" }, ), [WS_METHODS.terminalOpen]: (input) => - observeRpcEffect(WS_METHODS.terminalOpen, terminalManager.open(input), { - "rpc.aggregate": "terminal", - }), + observeRpcEffect( + WS_METHODS.terminalOpen, + routeTerminal( + input.threadId, + () => aetherTerminalManager.open(input), + () => terminalManager.open(input), + ), + { + "rpc.aggregate": "terminal", + }, + ), [WS_METHODS.terminalAttach]: (input) => observeRpcStream( WS_METHODS.terminalAttach, Stream.callback((queue) => Effect.acquireRelease( - terminalManager.attachStream(input, (event) => Queue.offer(queue, event)), + routeTerminal( + input.threadId, + () => + aetherTerminalManager.attachStream(input, (event) => Queue.offer(queue, event)), + () => terminalManager.attachStream(input, (event) => Queue.offer(queue, event)), + ), (unsubscribe) => Effect.sync(unsubscribe), ), ), { "rpc.aggregate": "terminal" }, ), [WS_METHODS.terminalWrite]: (input) => - observeRpcEffect(WS_METHODS.terminalWrite, terminalManager.write(input), { - "rpc.aggregate": "terminal", - }), + observeRpcEffect( + WS_METHODS.terminalWrite, + routeTerminal( + input.threadId, + () => aetherTerminalManager.write(input), + () => terminalManager.write(input), + ), + { + "rpc.aggregate": "terminal", + }, + ), [WS_METHODS.terminalResize]: (input) => - observeRpcEffect(WS_METHODS.terminalResize, terminalManager.resize(input), { - "rpc.aggregate": "terminal", - }), + observeRpcEffect( + WS_METHODS.terminalResize, + routeTerminal( + input.threadId, + () => aetherTerminalManager.resize(input), + () => terminalManager.resize(input), + ), + { + "rpc.aggregate": "terminal", + }, + ), [WS_METHODS.terminalClear]: (input) => - observeRpcEffect(WS_METHODS.terminalClear, terminalManager.clear(input), { - "rpc.aggregate": "terminal", - }), + observeRpcEffect( + WS_METHODS.terminalClear, + routeTerminal( + input.threadId, + () => aetherTerminalManager.clear(input), + () => terminalManager.clear(input), + ), + { + "rpc.aggregate": "terminal", + }, + ), [WS_METHODS.terminalRestart]: (input) => - observeRpcEffect(WS_METHODS.terminalRestart, terminalManager.restart(input), { - "rpc.aggregate": "terminal", - }), + observeRpcEffect( + WS_METHODS.terminalRestart, + routeTerminal( + input.threadId, + () => aetherTerminalManager.restart(input), + () => terminalManager.restart(input), + ), + { + "rpc.aggregate": "terminal", + }, + ), [WS_METHODS.terminalClose]: (input) => - observeRpcEffect(WS_METHODS.terminalClose, terminalManager.close(input), { - "rpc.aggregate": "terminal", - }), + observeRpcEffect( + WS_METHODS.terminalClose, + routeTerminal( + input.threadId, + () => aetherTerminalManager.close(input), + () => terminalManager.close(input), + ), + { + "rpc.aggregate": "terminal", + }, + ), [WS_METHODS.subscribeTerminalEvents]: (_input) => observeRpcStream( WS_METHODS.subscribeTerminalEvents, @@ -2058,10 +2286,29 @@ const makeWsRpcLayer = ( observeRpcStream( WS_METHODS.subscribeTerminalMetadata, Stream.callback((queue) => - Effect.acquireRelease( - terminalManager.subscribeMetadata((event) => Queue.offer(queue, event)), - (unsubscribe) => Effect.sync(unsubscribe), - ), + Effect.gen(function* () { + // Separate acquireReleases: each registers its own finalizer, so + // an interrupt after the local subscription acquires but before + // the Aether one does still releases the local listener. + yield* Effect.acquireRelease( + terminalManager.subscribeMetadata((event) => Queue.offer(queue, event)), + (unsubscribe) => Effect.sync(unsubscribe), + ); + // Fold Aether terminals in: its snapshot becomes upserts so it + // augments the local snapshot instead of replacing it. + yield* Effect.acquireRelease( + aetherTerminalManager.subscribeMetadata((event) => + event.type === "snapshot" + ? Effect.forEach( + event.terminals, + (terminal) => Queue.offer(queue, { type: "upsert", terminal }), + { discard: true }, + ) + : Queue.offer(queue, event), + ), + (unsubscribe) => Effect.sync(unsubscribe), + ); + }), ), { "rpc.aggregate": "terminal" }, ), 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 05ed533acbc2..47a9986deef8 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 32a8e309beb9..0ca2ea7a3ddc 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -256,6 +256,7 @@ import { NoActiveThreadState } from "./NoActiveThreadState"; import { resolveEffectiveEnvMode, resolveLocalCheckoutBranchMismatch, + resolveProviderDefaultsToWorktree, shouldShowComposerContextStrip, shouldShowEnvironmentIndicator, } from "./BranchToolbar.logic"; @@ -483,6 +484,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; @@ -1880,7 +1885,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."); } @@ -1962,6 +1972,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], @@ -4042,10 +4055,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 && @@ -4071,6 +4092,36 @@ 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. Gated on `startFromOriginUserSet` + // exactly as the mode overlay is gated on `envModeUserSet`: once the user has + // toggled the control, their pick is what makes the draft differ from the + // preference — which is the very condition this effect fires on, so without + // the flag every explicit toggle is reset on the next render. + useEffect(() => { + if (!providerDefaultsToWorktree) return; + if (envMode !== "worktree") return; + if (draftThread?.startFromOriginUserSet === true) return; + const desiredStartFromOrigin = resolveNewDraftStartFromOrigin({ + envMode: "worktree", + newWorktreesStartFromOrigin: primaryServerSettings.newWorktreesStartFromOrigin, + }); + if (draftThread?.startFromOrigin === desiredStartFromOrigin) return; + setDraftThreadContext(composerDraftTarget, { startFromOrigin: desiredStartFromOrigin }); + }, [ + providerDefaultsToWorktree, + envMode, + draftThread?.startFromOrigin, + draftThread?.startFromOriginUserSet, + primaryServerSettings.newWorktreesStartFromOrigin, + composerDraftTarget, + setDraftThreadContext, + ]); const localCheckoutBranchMismatch = useMemo( () => isServerThread @@ -5908,6 +5959,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, @@ -5941,6 +5995,9 @@ function ChatViewContent(props: ChatViewProps) { if (isLocalDraftThread) { setDraftThreadContext(composerDraftTarget, { startFromOrigin: nextStartFromOrigin, + // An explicit toggle freezes the value so the Aether worktree overlay + // can no longer re-seed it from the preference (user choice wins). + startFromOriginUserSet: true, }); } }; @@ -6397,7 +6454,7 @@ function ChatViewContent(props: ChatViewProps) { onEnvModeChange={onEnvModeChange} startFromOrigin={startFromOrigin} onStartFromOriginChange={onStartFromOriginChange} - {...(canOverrideServerThreadEnvMode + {...(canOverrideServerThreadEnvMode || isLocalDraftThread ? { effectiveEnvModeOverride: envMode } : {})} {...(canOverrideServerThreadEnvMode 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 }) => ( { + // 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 ( + + + + Port {preview.port} is live + + + Open preview ▸ + + + ); +}); + 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/components/chat/providerIconUtils.ts b/apps/web/src/components/chat/providerIconUtils.ts index 842c616fe1fe..029a46d493de 100644 --- a/apps/web/src/components/chat/providerIconUtils.ts +++ b/apps/web/src/components/chat/providerIconUtils.ts @@ -1,5 +1,5 @@ import { ProviderDriverKind } from "@t3tools/contracts"; -import { ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; +import { AetherIcon, ClaudeAI, CursorIcon, GrokIcon, Icon, OpenAI, OpenCodeIcon } from "../Icons"; import { PROVIDER_OPTIONS } from "../../session-logic"; export const PROVIDER_ICON_BY_PROVIDER: Partial> = { @@ -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/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index c127dfba175e..451be15f9b9e 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1082,6 +1082,109 @@ 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("defaults startFromOriginUserSet to false and flips it on an explicit toggle", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId, envMode: "worktree" }); + + // Freshly seeded: still the auto/default value, so the Aether worktree + // overlay may seed it from the newWorktreesStartFromOrigin preference. + expect(useComposerDraftStore.getState().getDraftThread(draftId)?.startFromOriginUserSet).toBe( + false, + ); + + store.setDraftThreadContext(draftId, { + startFromOrigin: true, + startFromOriginUserSet: true, + }); + expect(useComposerDraftStore.getState().getDraftThread(draftId)).toMatchObject({ + startFromOrigin: true, + startFromOriginUserSet: true, + }); + + // A later context update that omits the flag preserves the user-set value. + store.setDraftThreadContext(draftId, { envMode: "local" }); + expect(useComposerDraftStore.getState().getDraftThread(draftId)?.startFromOriginUserSet).toBe( + true, + ); + }); + + 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 ebafd3b04d29..1f591588e8d5 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -216,6 +216,16 @@ 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))), + // True once the user explicitly toggles "start from origin". While false the + // value is still the auto/default one, so the Aether worktree overlay may + // seed it from the newWorktreesStartFromOrigin preference. Additive + + // defaults false, so pre-existing persisted drafts decode unchanged. + startFromOriginUserSet: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), promotedTo: Schema.optionalKey( Schema.NullOr( Schema.Struct({ @@ -319,7 +329,11 @@ 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; + /** True once the user explicitly toggles start-from-origin (see persisted schema). */ + startFromOriginUserSet: boolean; promotedTo?: ScopedThreadRef | null; } @@ -381,7 +395,9 @@ interface ComposerDraftStoreState { worktreePath?: string | null; createdAt?: string; envMode?: DraftThreadEnvMode; + envModeUserSet?: boolean; startFromOrigin?: boolean; + startFromOriginUserSet?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, @@ -396,7 +412,9 @@ interface ComposerDraftStoreState { worktreePath?: string | null; createdAt?: string; envMode?: DraftThreadEnvMode; + envModeUserSet?: boolean; startFromOrigin?: boolean; + startFromOriginUserSet?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, @@ -410,7 +428,9 @@ interface ComposerDraftStoreState { projectRef?: ScopedProjectRef; createdAt?: string; envMode?: DraftThreadEnvMode; + envModeUserSet?: boolean; startFromOrigin?: boolean; + startFromOriginUserSet?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, @@ -1360,7 +1380,9 @@ function createDraftThreadState( worktreePath?: string | null; createdAt?: string; envMode?: DraftThreadEnvMode; + envModeUserSet?: boolean; startFromOrigin?: boolean; + startFromOriginUserSet?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, @@ -1402,7 +1424,10 @@ function createDraftThreadState( worktreePath: nextWorktreePath, envMode: options?.envMode ?? (nextWorktreePath ? "worktree" : (existingThread?.envMode ?? "local")), + envModeUserSet: options?.envModeUserSet ?? existingThread?.envModeUserSet ?? false, startFromOrigin: nextStartFromOrigin, + startFromOriginUserSet: + options?.startFromOriginUserSet ?? existingThread?.startFromOriginUserSet ?? false, promotedTo: null, }; } @@ -1434,7 +1459,9 @@ 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 && + left.startFromOriginUserSet === right.startFromOriginUserSet && scopedThreadRefsEqual(left.promotedTo, right.promotedTo) ); } @@ -1530,6 +1557,15 @@ 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; + // Same legacy-safe rule as envModeUserSet: absent counts as user-set so + // a draft persisted before this field existed keeps its stored toggle. + const startFromOriginUserSet = candidateDraftThread.startFromOriginUserSet !== false; const normalizedWorktreePath = typeof worktreePath === "string" ? worktreePath : null; const promotedToCandidate = candidateDraftThread.promotedTo; const promotedToRecord = @@ -1577,7 +1613,9 @@ function normalizePersistedDraftThreads( branch: typeof branch === "string" ? branch : null, worktreePath: normalizedWorktreePath, envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), + envModeUserSet, startFromOrigin, + startFromOriginUserSet, promotedTo, }; } @@ -1623,7 +1661,9 @@ function normalizePersistedDraftThreads( branch: null, worktreePath: null, envMode: "local", + envModeUserSet: false, startFromOrigin: false, + startFromOriginUserSet: false, promotedTo: null, }; } else if ( @@ -2226,7 +2266,9 @@ function toHydratedDraftThreadState( branch: persistedDraftThread.branch, worktreePath: persistedDraftThread.worktreePath, envMode: persistedDraftThread.envMode, + envModeUserSet: persistedDraftThread.envModeUserSet, startFromOrigin: persistedDraftThread.startFromOrigin, + startFromOriginUserSet: persistedDraftThread.startFromOriginUserSet, promotedTo: persistedDraftThread.promotedTo ? scopeThreadRef( persistedDraftThread.promotedTo.environmentId as EnvironmentId, @@ -2445,6 +2487,14 @@ const composerDraftStore = create()( options.startFromOrigin === undefined ? existing.startFromOrigin : options.startFromOrigin; + const nextEnvModeUserSet = + options.envModeUserSet === undefined + ? existing.envModeUserSet + : options.envModeUserSet; + const nextStartFromOriginUserSet = + options.startFromOriginUserSet === undefined + ? existing.startFromOriginUserSet + : options.startFromOriginUserSet; const nextDraftThread: DraftThreadState = { threadId: existing.threadId, environmentId: nextProjectRef.environmentId, @@ -2460,7 +2510,9 @@ const composerDraftStore = create()( worktreePath: nextWorktreePath, envMode: options.envMode ?? (nextWorktreePath ? "worktree" : (existing.envMode ?? "local")), + envModeUserSet: nextEnvModeUserSet, startFromOrigin: nextStartFromOrigin, + startFromOriginUserSet: nextStartFromOriginUserSet, promotedTo: existing.promotedTo ?? null, }; const isUnchanged = @@ -2473,7 +2525,9 @@ const composerDraftStore = create()( nextDraftThread.branch === existing.branch && nextDraftThread.worktreePath === existing.worktreePath && nextDraftThread.envMode === existing.envMode && + nextDraftThread.envModeUserSet === existing.envModeUserSet && nextDraftThread.startFromOrigin === existing.startFromOrigin && + nextDraftThread.startFromOriginUserSet === existing.startFromOriginUserSet && scopedThreadRefsEqual(nextDraftThread.promotedTo, existing.promotedTo); if (isUnchanged) { return state; diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index ef3623621620..ccbf9878351a 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -367,6 +367,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({ 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/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); diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4d0a76cf133b..dc728bea118e 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", }, + // T6 landed the turn protocol + mirror sync: Aether is selectable end to + // end. + { + value: ProviderDriverKind.make("aether"), + label: "Aether", + available: true, + pickerSidebarBadge: "new", + }, ]; export type WorkLogToolLifecycleStatus = @@ -79,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. */ @@ -900,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/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/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index eba1b4648b25..ce335b031712 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,59 @@ 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", + expectedBranch: null, + expectedWorktreePath: null, + }); + assert.strictEqual(parsed.type, "thread.worktree.attach-managed"); + if (parsed.type === "thread.worktree.attach-managed") { + assert.strictEqual(parsed.worktreePath, "/tmp/worktrees/thread-1"); + assert.strictEqual(parsed.expectedWorktreePath, null); + } + }), +); + 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 35fef721efa7..1272749af8b2 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -372,6 +372,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, @@ -442,6 +449,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, @@ -758,6 +767,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) => @@ -1015,6 +1027,30 @@ 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. +// `expected*` carry the thread's workspace as the bootstrap observed it just +// BEFORE it started creating the worktree. Creating one takes seconds and a +// `thread.meta.update` can land in that window, so the decider compares before +// applying: a stale attach must not revert the user's newer pick, nor mark +// THEIR worktree driver-owned. +const ThreadWorktreeAttachManagedCommand = Schema.Struct({ + type: Schema.Literal("thread.worktree.attach-managed"), + commandId: CommandId, + threadId: ThreadId, + branch: TrimmedNonEmptyString, + worktreePath: TrimmedNonEmptyString, + expectedBranch: Schema.NullOr(TrimmedNonEmptyString), + expectedWorktreePath: Schema.NullOr(TrimmedNonEmptyString), +}); + const ThreadTitleRegenerationCompleteCommand = Schema.Struct({ type: Schema.Literal("thread.title.regeneration.complete"), commandId: CommandId, @@ -1031,6 +1067,7 @@ const InternalOrchestrationCommand = Schema.Union([ ThreadTurnDiffCompleteCommand, ThreadActivityAppendCommand, ThreadRevertCompleteCommand, + ThreadWorktreeAttachManagedCommand, ThreadTitleRegenerationCompleteCommand, ]); export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; @@ -1201,6 +1238,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/project.test.ts b/packages/contracts/src/project.test.ts index 8e6771cba88f..69bee342beb9 100644 --- a/packages/contracts/src/project.test.ts +++ b/packages/contracts/src/project.test.ts @@ -58,6 +58,15 @@ describe("project RPC errors", () => { resolvedPath: "/workspace/src/index.ts", cause, }); + const writeError = new ProjectWriteFileError({ + cwd: "/workspace", + relativePath: "src/index.ts", + failure: "operation_failed", + operation: "write-file", + operationPath: "/workspace/src/index.ts", + resolvedPath: "/workspace/src/index.ts", + cause, + }); expect(searchError.message).toBe("Failed to search workspace entries in '/workspace'."); expect(searchError.message).not.toContain(cause.message); @@ -69,6 +78,11 @@ describe("project RPC errors", () => { expect(readError.message).toBe("Failed to read workspace file 'src/index.ts' in '/workspace'."); expect(readError.message).not.toContain(cause.message); expect(readError.cause).toBe(cause); + expect(writeError.message).toBe( + "Failed to write workspace file 'src/index.ts' in '/workspace'.", + ); + expect(writeError.message).not.toContain(cause.message); + expect(writeError.cause).toBe(cause); const contentSearchError = new ProjectSearchContentsError({ cwd: "/workspace", diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 757c000a065a..be3a256331e5 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -211,9 +211,18 @@ export const ProjectFileFailure = Schema.Literals([ "path_not_file", "binary_file", "operation_failed", + "aether_mirror_read_only", ]); export type ProjectFileFailure = typeof ProjectFileFailure.Type; +/** + * The human-facing message the ProjectFile errors derive for the + * `aether_mirror_read_only` failure: while an Aether cloud task owns a + * checkout, that checkout is a one-way mirror and local writes are refused. + */ +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."; + export const ProjectFileOperation = Schema.Literals([ "realpath-workspace-root", "realpath-target", @@ -256,8 +265,10 @@ export class ProjectReadFileError extends Schema.TaggedErrorClass 0 ? branchFragment : "update";