From f371b19f89cf6b9407550c86365df68086c52973 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 29 Oct 2026 10:05:33 -0400 Subject: [PATCH 01/17] feat(server): fx gets update checks and the Update now button fx was manual-only: T3 never knew when a newer fx release was out and offered no way to update it. fx now resolves its installed binary (PATH or binaryPath), checks releases.fx.sh/latest.txt (the file `fx upgrade` and fx's setup script read), and offers ` upgrade`, which replaces the binary it runs from. Docs cover the minimum version (0.0.13) and updating. Co-Authored-By: Claude Opus 5.5 --- docs/user/providers-fx.md | 8 +- packages/provider-fx/src/server/driver.ts | 46 +++++++++-- .../src/server/maintenance.test.ts | 82 +++++++++++++++++++ .../provider-fx/src/server/maintenance.ts | 52 ++++++++++++ packages/provider-fx/src/server/status.ts | 1 - 5 files changed, 178 insertions(+), 11 deletions(-) create mode 100644 packages/provider-fx/src/server/maintenance.test.ts create mode 100644 packages/provider-fx/src/server/maintenance.ts diff --git a/docs/user/providers-fx.md b/docs/user/providers-fx.md index 430f7a705a499..f01ae227dcf1c 100644 --- a/docs/user/providers-fx.md +++ b/docs/user/providers-fx.md @@ -15,8 +15,14 @@ T3 Code can run [fx](https://fx.sh). fx uses the login on the machine that runs If `fx` is not on the server's `PATH`, set **Binary path** to the executable. +T3 Code supports fx 0.0.13 and later. Use the latest release when you can; macOS needs version 15 or later for fx releases after 0.0.13. + Every fx instance on the same machine shares that machine's fx login. T3 Code does not keep a separate login per instance. +## Update fx + +When a newer fx release is out, the fx card in **Settings → Providers** shows it. **Update now** runs `fx upgrade` on the binary that instance uses, including one set in **Binary path**. You can also run `fx upgrade` on that machine yourself. T3 Code checks for new stable releases only; on fx's dev channel, run `fx upgrade` yourself to get newer dev builds. + ## Models and effort The model picker lists **Default** first. That option runs the model `fx status` reports on the server's machine. A model set only for one project in fx's settings is not picked up, so choose it from the list instead. If the thread's fx backend does not offer that model, the thread keeps its current one. The other entries come from `fx models`. A custom model you add in the provider settings goes to fx as is, and fx reports an error if its backend does not offer it. @@ -38,7 +44,7 @@ fx offers two composer modes. T3 Code does not offer **Full access** or **Auto-accept edits** for fx. A thread already set to one of those runs in **Supervised**. Changing the permission mode restarts the connection and reloads the same fx session. -On fx versions after [vercel-labs/fx#1189](https://github.com/vercel-labs/fx/pull/1189), fx saves the mode T3 Code sets as its default permission mode. T3 Code sets it each time it opens an fx thread, so fx in the terminal then starts in that thread's mode. +On fx 0.0.14 and later, fx saves the mode T3 Code sets as its default permission mode. T3 Code sets it each time it opens an fx thread, so fx in the terminal then starts in that thread's mode. A message steered into a running turn stops that turn, and fx answers the new message in the same session. diff --git a/packages/provider-fx/src/server/driver.ts b/packages/provider-fx/src/server/driver.ts index c922b315b56ce..e9cbc2cf7a226 100644 --- a/packages/provider-fx/src/server/driver.ts +++ b/packages/provider-fx/src/server/driver.ts @@ -6,9 +6,11 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as HttpClient from "effect/http/HttpClient"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import type * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import { type FxAdapterV2DriverEnv, makeFxAdapterV2Driver } from "./adapter.ts"; import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; import { @@ -25,7 +27,12 @@ import { import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { discoverFxSkills } from "./skills.ts"; -import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; +import { + enrichProviderSnapshotWithVersionAdvisory, + makeCachedProviderMaintenanceResolution, + resolveProviderMaintenanceCapabilitiesEffect, +} from "@t3tools/provider-core/server/maintenanceResolver"; +import { fetchLatestFxVersion, makeFxMaintenance } from "./maintenance.ts"; import { haveProviderSnapshotSettingsChanged, makeProviderSnapshotSettingsSource, @@ -35,12 +42,6 @@ import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textG import { FX_DRIVER_KIND } from "./acpSupport.ts"; const decodeFxSettings = Schema.decodeSync(FxSettings); -// fx installs from its own setup script and updates with `fx upgrade`; there -// is no npm package to read a latest version from. -const MAINTENANCE = makeManualOnlyProviderMaintenanceCapabilities({ - provider: FX_DRIVER_KIND, - packageName: null, -}); // See `supportsTextGeneration` in status.ts. const unsupportedTextGeneration: ProviderTextGeneration = (() => { @@ -65,8 +66,10 @@ export type FxDriverEnv = | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem + | HttpClient.HttpClient | Path.Path - | ProviderHost.ProviderHost; + | ProviderHost.ProviderHost + | ProviderLatestVersions.ProviderLatestVersions; export const FxDriver: ProviderDriver = { driverKind: FX_DRIVER_KIND, @@ -87,6 +90,8 @@ export const FxDriver: ProviderDriver = { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const httpClient = yield* HttpClient.HttpClient; + const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; const processEnv = yield* mergeProviderInstanceEnvironment( environment, yield* HostProcess.Environment, @@ -103,9 +108,21 @@ export const FxDriver: ProviderDriver = { continuationGroupKey: continuationIdentity.continuationKey, }); const effectiveConfig = { ...config, enabled } satisfies FxSettings; + const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( + resolveProviderMaintenanceCapabilitiesEffect( + makeFxMaintenance( + fetchLatestFxVersion().pipe(Effect.provideService(HttpClient.HttpClient, httpClient)), + ), + { binaryPath: effectiveConfig.binaryPath, env: processEnv }, + ).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + ); const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); const snapshot = yield* makeManagedServerProvider>({ - resolveMaintenance: () => Effect.succeed(MAINTENANCE), + resolveMaintenance, getSettings: snapshotSettings.getSettings, streamSettings: snapshotSettings.streamSettings, haveSettingsChanged: haveProviderSnapshotSettingsChanged, @@ -115,6 +132,17 @@ export const FxDriver: ProviderDriver = { Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ), + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + resolveMaintenance().pipe( + Effect.flatMap((maintenanceCapabilities) => + enrichProviderSnapshotWithVersionAdvisory(currentSnapshot, maintenanceCapabilities, { + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + }), + ), + Effect.flatMap(publishSnapshot), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), + ), }).pipe( Effect.mapError( (cause) => diff --git a/packages/provider-fx/src/server/maintenance.test.ts b/packages/provider-fx/src/server/maintenance.test.ts new file mode 100644 index 0000000000000..8f51b3b159aa9 --- /dev/null +++ b/packages/provider-fx/src/server/maintenance.test.ts @@ -0,0 +1,82 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; + +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; +import { enrichProviderSnapshotWithVersionAdvisory } from "@t3tools/provider-core/server/maintenanceResolver"; +import { fetchLatestFxVersion, makeFxMaintenance } from "./maintenance.ts"; + +const snapshot: ServerProvider = { + instanceId: ProviderInstanceId.make("fx"), + driver: ProviderDriverKind.make("fx"), + enabled: true, + installed: true, + version: "0.0.13", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-10-29T00:00:00.000Z", + models: [], + skills: [], + slashCommands: [], +}; + +const serving = (response: Response) => + HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, response))); + +it.layer(NodeServices.layer)("fx maintenance", (it) => { + it.effect("offers `fx upgrade` on the instance's own binary once a newer release is out", () => + Effect.gen(function* () { + const binaryPath = "/opt/tools/custom fx"; + const capabilities = yield* makeFxMaintenance( + fetchLatestFxVersion().pipe( + Effect.provideService(HttpClient.HttpClient, serving(new Response("v0.0.15\n"))), + ), + ).resolve({ + binaryPath, + resolvedCommandPath: binaryPath, + realCommandPath: "/opt/tools/fx-real", + platform: "darwin", + env: { HOME: "/home/fx" }, + }); + expect(capabilities.update).toMatchObject({ + executable: binaryPath, + args: ["upgrade"], + command: `'${binaryPath}' upgrade`, + lockKey: "fx:/opt/tools/fx-real", + env: { HOME: "/home/fx" }, + }); + + const enriched = yield* enrichProviderSnapshotWithVersionAdvisory(snapshot, capabilities, { + enableProviderUpdateChecks: true, + }).pipe( + Effect.provideService(HttpClient.HttpClient, serving(new Response("unused"))), + Effect.provide(ProviderLatestVersions.layer), + ); + expect(enriched.versionAdvisory).toMatchObject({ + status: "behind_latest", + currentVersion: "0.0.13", + latestVersion: "0.0.15", + canUpdate: true, + updateCommand: `'${binaryPath}' upgrade`, + }); + }), + ); + + it.effect("leaves the latest version unknown when the release file is unusable", () => + Effect.gen(function* () { + for (const response of [ + new Response("not found"), + new Response("v0.0.15", { status: 503 }), + ]) { + const latest = yield* fetchLatestFxVersion().pipe( + Effect.provideService(HttpClient.HttpClient, serving(response)), + ); + expect(latest).toBeNull(); + } + expect((yield* makeFxMaintenance(Effect.succeed("0.0.15")).resolve(null)).update).toBeNull(); + }), + ); +}); diff --git a/packages/provider-fx/src/server/maintenance.ts b/packages/provider-fx/src/server/maintenance.ts new file mode 100644 index 0000000000000..5c744db651cf7 --- /dev/null +++ b/packages/provider-fx/src/server/maintenance.ts @@ -0,0 +1,52 @@ +import * as Effect from "effect/Effect"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; + +import { + makeManualOnlyProviderMaintenanceCapabilities, + makeProviderMaintenanceCapabilities, + type ProviderMaintenanceCapabilitiesResolver, +} from "@t3tools/provider-core/server/maintenanceResolver"; +import { FX_DRIVER_KIND } from "./acpSupport.ts"; + +/** The stable release `fx upgrade` and fx's setup script install. */ +const FX_LATEST_RELEASE_URL = "https://releases.fx.sh/latest.txt"; + +export const fetchLatestFxVersion = Effect.fn("fetchLatestFxVersion")(function* () { + const client = yield* HttpClient.HttpClient; + return yield* client.execute(HttpClientRequest.get(FX_LATEST_RELEASE_URL)).pipe( + Effect.flatMap((response) => + response.status >= 200 && response.status < 300 ? response.text : Effect.succeed(""), + ), + Effect.map((body) => body.trim().match(/^v?(\d+\.\d+\.\d+)$/)?.[1] ?? null), + Effect.timeout(4_000), + Effect.orElseSucceed(() => null), + ); +}); + +/** + * `fx upgrade` replaces the executable it runs from, so the update runs the + * instance's resolved binary, wherever `binaryPath` points. + */ +export const makeFxMaintenance = ( + lookupLatestVersion: Effect.Effect, +): ProviderMaintenanceCapabilitiesResolver => ({ + resolve: (context) => + Effect.succeed( + context === null + ? makeManualOnlyProviderMaintenanceCapabilities({ + provider: FX_DRIVER_KIND, + packageName: null, + }) + : makeProviderMaintenanceCapabilities({ + provider: FX_DRIVER_KIND, + packageName: null, + updateExecutable: context.resolvedCommandPath, + updateArgs: ["upgrade"], + updateLockKey: `fx:${context.realCommandPath}`, + platform: context.platform, + env: context.env, + lookupLatestVersion, + }), + ), +}); diff --git a/packages/provider-fx/src/server/status.ts b/packages/provider-fx/src/server/status.ts index c71e909197df2..9f28f2f56f0da 100644 --- a/packages/provider-fx/src/server/status.ts +++ b/packages/provider-fx/src/server/status.ts @@ -77,7 +77,6 @@ const snapshot = ( listed?: ReadonlyArray, ): ServerProviderDraft => ({ ...buildServerProvider({ - // Gives the snapshot its version advisory; fx runs no enrichment pass. driver: FX_DRIVER_KIND, presentation: FX_PRESENTATION, enabled: settings.enabled, From 6cd1ef960e06bef9bac136a9ef737551c0b22697 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 29 Oct 2026 10:25:38 -0400 Subject: [PATCH 02/17] fix(server): fx 0.0.15 tools and sub-agents cut off by Stop show as interrupted fx 0.0.15 answers Stop by failing every tool call still open, sub-agents included, before it acknowledges the cancel. T3 projected those as failed, and an interrupted fx sub-agent's child thread showed fx's raw error JSON as its reply. A tool an agent fails after Stop now reads as interrupted, like one it leaves open, and the fx sub-agent reply shows fx's error text. Recorded on fx 0.0.15: an fx turn_interrupt_mid_tool variant and fx_subagent_stop (a Supervised sub-agent stopped while it waits). The 0.0.13 transcripts and the 0.0.14 fx_subagent stay as coverage for older fx. Co-Authored-By: Claude Opus 5.5 --- .../scripts/record-fx-acp-replay-fixture.ts | 6 +- .../fixtures/fx_subagent_stop/fx_output.ts | 40 ++++++++++++ .../fx_subagent_stop/fx_transcript.ndjson | 41 ++++++++++++ .../fixtures/fx_subagent_stop/input.ts | 15 +++++ .../testkit/fixtures/index.ts | 26 ++++++++ .../testkit/fixtures/shared.ts | 2 + .../turn_interrupt_mid_tool/fx_output.ts | 64 +++++++++++++++++++ .../fx_transcript.ndjson | 33 ++++++++++ packages/provider-acp/src/server/adapter.ts | 22 ++++++- packages/provider-fx/src/server/adapter.ts | 19 +++++- 10 files changed, 261 insertions(+), 7 deletions(-) create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/fx_subagent_stop/fx_output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/fx_subagent_stop/fx_transcript.ndjson create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/fx_subagent_stop/input.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/fx_output.ts create mode 100644 apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/fx_transcript.ndjson diff --git a/apps/server/scripts/record-fx-acp-replay-fixture.ts b/apps/server/scripts/record-fx-acp-replay-fixture.ts index 73ab4023f732c..9deb40d90cafc 100644 --- a/apps/server/scripts/record-fx-acp-replay-fixture.ts +++ b/apps/server/scripts/record-fx-acp-replay-fixture.ts @@ -1,8 +1,8 @@ /** - * Records an fx ACP replay transcript through the real orchestrator and - * FxAdapterV2, teeing only the protocol logger. Run from apps/server with - * fx 0.0.13 on PATH (or T3_FX_BIN); HOME stays the user's real login: + * Records an fx ACP replay transcript through the real orchestrator and FxAdapterV2. + * Run from apps/server with fx on PATH (or T3_FX_BIN), signed in under HOME: * node scripts/record-fx-acp-replay-fixture.ts --scenario simple + * fx 0.0.14+ saves each recorded mode as the default in ~/.fx/settings.json; restore it after. */ import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProviderInstanceId, type ProviderReplayEntry } from "@t3tools/contracts"; diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/fx_subagent_stop/fx_output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/fx_subagent_stop/fx_output.ts new file mode 100644 index 0000000000000..99676f19a77d3 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/fx_subagent_stop/fx_output.ts @@ -0,0 +1,40 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertBaseProjection, + assertSemanticProjectionIntegrity, + assertUserMessagesInclude, + FX_SUBAGENT_STOP_PROMPT, + projectionFor, +} from "../shared.ts"; + +/** fx 0.0.15 fails a sub-agent that Stop cut off; T3 shows it, and its child thread, as interrupted. */ +export function assertFxSubagentStopOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["interrupted"] }); + + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + assertUserMessagesInclude(projection, [FX_SUBAGENT_STOP_PROMPT]); + assert.notInclude( + projection.turnItems.map((item) => item.type), + "dynamic_tool", + "the subagent call must not also show as a tool row", + ); + assert.lengthOf(projection.subagents, 1); + const subagent = projection.subagents[0]!; + assert.equal(subagent.status, "interrupted"); + assert.include(subagent.prompt, "stopped.txt"); + assert.equal(subagent.result, "Cancelled", "fx's error, not its JSON envelope"); + if (subagent.childThreadId === null) throw new Error("The subagent has no child thread"); + assert.equal( + result.projections.get(subagent.childThreadId)?.thread.lineage.parentThreadId, + projection.thread.id, + ); + assert.equal(projection.providerTurns[0]?.status, "interrupted"); + assert.equal(projection.providerThreads[0]?.status, "idle"); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/fx_subagent_stop/fx_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/fx_subagent_stop/fx_transcript.ndjson new file mode 100644 index 0000000000000..5c6aebc238ac4 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/fx_subagent_stop/fx_transcript.ndjson @@ -0,0 +1,41 @@ +{"type":"transcript_start","provider":"fx","protocol":"acp.ndjson-jsonrpc","version":"1","scenario":"fx_subagent_stop","metadata":{"generatedBy":"live-fx-recorder","fxVersion":"0.0.15","normalization":"Session ids are fx-session-NN, the workspace is , HOME is /home/fx-replay and the recording user is fx-replay. T3-owned prompt text, MCP servers and initialize params other than clientCapabilities and _meta are . User skill names, emails and token-shaped strings are removed. Timestamps are kept as recorded.","droppedFrames":0}} +{"type":"expect_outbound","label":"request:initialize","frame":{"kind":"request","method":"initialize","params":{"protocolVersion":2,"info":"","capabilities":"","clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false,"session":{"notices":{}},"elicitation":{"form":{}}},"clientInfo":""}}} +{"type":"emit_inbound","label":"response:initialize","frame":{"kind":"response","method":"initialize","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":true,"audio":false,"embeddedContext":true},"mcpCapabilities":{"http":true,"sse":false,"acp":false},"sessionCapabilities":{"list":{},"resume":{},"close":{},"systemPrompt":{}},"_meta":{"fx":{"steering":true}}},"agentInfo":{"name":"fx","title":"fx","version":"0.0.15"},"authMethods":[]}}} +{"type":"expect_outbound","label":"request:session/new","frame":{"kind":"request","method":"session/new","params":{"cwd":"","mcpServers":""}}} +{"type":"emit_inbound","label":"response:session/new","frame":{"kind":"response","method":"session/new","result":{"sessionId":"fx-session-01","configOptions":[{"id":"provider","name":"Provider","category":"model","type":"select","currentValue":"codex","options":[{"value":"gateway","name":"Vercel AI Gateway"},{"value":"codex","name":"Codex subscription"},{"value":"grok","name":"Grok subscription"}]},{"id":"model","name":"Model","category":"model","type":"select","currentValue":"gpt-6.1-sol","options":[{"value":"gpt-6.1-sol","name":"gpt-6.1-sol"},{"value":"gpt-6-astra","name":"gpt-6-astra"},{"value":"gpt-6.1-terra","name":"gpt-6.1-terra"},{"value":"gpt-6-sol","name":"gpt-6-sol"},{"value":"gpt-6-luna","name":"gpt-6-luna"},{"value":"gpt-5.6-sol","name":"gpt-5.6-sol"},{"value":"gpt-5.6-terra","name":"gpt-5.6-terra"},{"value":"gpt-5.6-luna","name":"gpt-5.6-luna"},{"value":"gpt-daybreak-blue-latest","name":"gpt-daybreak-blue-latest"}]},{"id":"mode","name":"Session Mode","description":"Controls how the agent requests permission","category":"mode","type":"select","currentValue":"ask","options":[{"value":"auto","name":"Auto","description":"Check actions with an automatic safety review instead of asking","_meta":{"fx":{"permissionMode":"auto"}}},{"value":"ask","name":"Ask","description":"Request permission before making any changes","_meta":{"fx":{"permissionMode":"ask"}}},{"value":"full-access","name":"Full access","description":"Run every action without asking, outside the sandbox","_meta":{"fx":{"permissionMode":"yolo"}}}]},{"id":"effort","name":"Reasoning Effort","description":"Controls how much the model thinks before responding","category":"thought_level","type":"select","currentValue":"auto","options":[{"value":"auto","name":"default"},{"value":"low","name":"low"},{"value":"medium","name":"medium"},{"value":"high","name":"high"},{"value":"xhigh","name":"xhigh"},{"value":"max","name":"max"},{"value":"ultra","name":"ultra"}]},{"id":"fast","name":"Fast Mode","description":"Uses the model's fast lane","category":"model","type":"select","currentValue":"false","options":[{"value":"false","name":"off"},{"value":"true","name":"on"}]}],"modes":{"currentModeId":"ask","availableModes":[{"id":"auto","name":"Auto","description":"Check actions with an automatic safety review instead of asking"},{"id":"ask","name":"Ask","description":"Request permission before making any changes"},{"id":"full-access","name":"Full access","description":"Run every action without asking, outside the sandbox"}]}}}} +{"type":"emit_inbound","label":"notification:session/update:available_commands_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}} +{"type":"expect_outbound","label":"request:session/set_config_option","frame":{"kind":"request","method":"session/set_config_option","params":{"sessionId":"fx-session-01","configId":"effort","value":"medium"}}} +{"type":"emit_inbound","label":"response:session/set_config_option","frame":{"kind":"response","method":"session/set_config_option","result":{"configOptions":[{"id":"provider","name":"Provider","category":"model","type":"select","currentValue":"codex","options":[{"value":"gateway","name":"Vercel AI Gateway"},{"value":"codex","name":"Codex subscription"},{"value":"grok","name":"Grok subscription"}]},{"id":"model","name":"Model","category":"model","type":"select","currentValue":"gpt-6.1-sol","options":[{"value":"gpt-6.1-sol","name":"gpt-6.1-sol"},{"value":"gpt-6-astra","name":"gpt-6-astra"},{"value":"gpt-6.1-terra","name":"gpt-6.1-terra"},{"value":"gpt-6-sol","name":"gpt-6-sol"},{"value":"gpt-6-luna","name":"gpt-6-luna"},{"value":"gpt-5.6-sol","name":"gpt-5.6-sol"},{"value":"gpt-5.6-terra","name":"gpt-5.6-terra"},{"value":"gpt-5.6-luna","name":"gpt-5.6-luna"},{"value":"gpt-daybreak-blue-latest","name":"gpt-daybreak-blue-latest"}]},{"id":"mode","name":"Session Mode","description":"Controls how the agent requests permission","category":"mode","type":"select","currentValue":"ask","options":[{"value":"auto","name":"Auto","description":"Check actions with an automatic safety review instead of asking","_meta":{"fx":{"permissionMode":"auto"}}},{"value":"ask","name":"Ask","description":"Request permission before making any changes","_meta":{"fx":{"permissionMode":"ask"}}},{"value":"full-access","name":"Full access","description":"Run every action without asking, outside the sandbox","_meta":{"fx":{"permissionMode":"yolo"}}}]},{"id":"effort","name":"Reasoning Effort","description":"Controls how much the model thinks before responding","category":"thought_level","type":"select","currentValue":"medium","options":[{"value":"auto","name":"default"},{"value":"low","name":"low"},{"value":"medium","name":"medium"},{"value":"high","name":"high"},{"value":"xhigh","name":"xhigh"},{"value":"max","name":"max"},{"value":"ultra","name":"ultra"}]},{"id":"fast","name":"Fast Mode","description":"Uses the model's fast lane","category":"model","type":"select","currentValue":"false","options":[{"value":"false","name":"off"},{"value":"true","name":"on"}]}]}}} +{"type":"expect_outbound","label":"request:session/set_config_option","frame":{"kind":"request","method":"session/set_config_option","params":{"sessionId":"fx-session-01","configId":"mode","value":"ask"}}} +{"type":"emit_inbound","label":"response:session/set_config_option","frame":{"kind":"response","method":"session/set_config_option","result":{"configOptions":[{"id":"provider","name":"Provider","category":"model","type":"select","currentValue":"codex","options":[{"value":"gateway","name":"Vercel AI Gateway"},{"value":"codex","name":"Codex subscription"},{"value":"grok","name":"Grok subscription"}]},{"id":"model","name":"Model","category":"model","type":"select","currentValue":"gpt-6.1-sol","options":[{"value":"gpt-6.1-sol","name":"gpt-6.1-sol"},{"value":"gpt-6-astra","name":"gpt-6-astra"},{"value":"gpt-6.1-terra","name":"gpt-6.1-terra"},{"value":"gpt-6-sol","name":"gpt-6-sol"},{"value":"gpt-6-luna","name":"gpt-6-luna"},{"value":"gpt-5.6-sol","name":"gpt-5.6-sol"},{"value":"gpt-5.6-terra","name":"gpt-5.6-terra"},{"value":"gpt-5.6-luna","name":"gpt-5.6-luna"},{"value":"gpt-daybreak-blue-latest","name":"gpt-daybreak-blue-latest"}]},{"id":"mode","name":"Session Mode","description":"Controls how the agent requests permission","category":"mode","type":"select","currentValue":"ask","options":[{"value":"auto","name":"Auto","description":"Check actions with an automatic safety review instead of asking","_meta":{"fx":{"permissionMode":"auto"}}},{"value":"ask","name":"Ask","description":"Request permission before making any changes","_meta":{"fx":{"permissionMode":"ask"}}},{"value":"full-access","name":"Full access","description":"Run every action without asking, outside the sandbox","_meta":{"fx":{"permissionMode":"yolo"}}}]},{"id":"effort","name":"Reasoning Effort","description":"Controls how much the model thinks before responding","category":"thought_level","type":"select","currentValue":"medium","options":[{"value":"auto","name":"default"},{"value":"low","name":"low"},{"value":"medium","name":"medium"},{"value":"high","name":"high"},{"value":"xhigh","name":"xhigh"},{"value":"max","name":"max"},{"value":"ultra","name":"ultra"}]},{"id":"fast","name":"Fast Mode","description":"Uses the model's fast lane","category":"model","type":"select","currentValue":"false","options":[{"value":"false","name":"off"},{"value":"true","name":"on"}]}]}}} +{"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"fx-session-01","prompt":[{"type":"text","text":"\n\n\n\n\nUse exactly one subagent to create a file named stopped.txt containing STOP. Do not create the file yourself. Wait for the subagent, then reply DONE.\n"}]}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":"I’ll have one"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":" sub"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":"agent"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":" create"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":" `"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":"st"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":"opped"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":".txt"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":"`"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":" containing"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":" `"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":"STOP"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":"`,"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":" then"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":" wait"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":" for"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":" it"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":" to"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":" finish"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":".\n"}}}}} +{"type":"emit_inbound","label":"notification:session/update:tool_call","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"tool_call","toolCallId":"call_77315bade0584581bc46d00bd72efcc7","name":"subagent","title":"Subagent · Create a file named stopped.txt in the current workspace containing exactly STOP (no newline). Use local tools to cre...","kind":"other","status":"pending","rawInput":{"action":"run","task":"Create a file named stopped.txt in the current workspace containing exactly STOP (no newline). Use local tools to create it and verify its contents. Do not spawn any subagents. Report completion."},"_meta":{"fx":{"toolCall":{"internal":false}}}}}}} +{"type":"emit_inbound","label":"notification:session/update:tool_call_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_77315bade0584581bc46d00bd72efcc7","status":"in_progress"}}}} +{"type":"expect_outbound","label":"notification:session/cancel","frame":{"kind":"notification","method":"session/cancel","params":{"sessionId":"fx-session-01"}}} +{"type":"emit_inbound","label":"notification:session/update:tool_call_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_77315bade0584581bc46d00bd72efcc7","status":"failed","content":[{"type":"content","content":{"type":"text","text":"{\"error\":{\"type\":\"tool_execution_failed\",\"tool_name\":\"subagent\",\"message\":\"Tool execution failed\",\"details\":{\"error\":\"Cancelled\"}}}"}}]}}}} +{"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"session_info_update","title":"Create stopped.txt via subagent","updatedAt":"2026-10-29T14:23:01Z"}}}} +{"type":"emit_inbound","label":"notification:session/update:usage_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"usage_update","used":6410,"size":500000}}}} +{"type":"emit_inbound","label":"response:session/prompt","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"cancelled","usage":{"inputTokens":6321,"outputTokens":89,"cacheReadTokens":3328,"cacheWriteTokens":0,"reasoningTokens":0}}}} +{"type":"expect_outbound","label":"request:session/close","frame":{"kind":"request","method":"session/close","params":{"sessionId":"fx-session-01"}}} +{"type":"emit_inbound","label":"response:session/close","frame":{"kind":"response","method":"session/close","result":{}}} +{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/fx_subagent_stop/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/fx_subagent_stop/input.ts new file mode 100644 index 0000000000000..a4f664a6d3737 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/fx_subagent_stop/input.ts @@ -0,0 +1,15 @@ +import { FX_SUBAGENT_STOP_PROMPT, type OrchestratorFixtureInput } from "../shared.ts"; + +/** + * Supervised: fx never forwards a sub-agent's permission request over ACP, + * so the sub-agent waits until Stop. + */ +export function fxSubagentStopInput(): OrchestratorFixtureInput { + return { + runtimeMode: "approval-required", + steps: [ + { type: "message", text: FX_SUBAGENT_STOP_PROMPT }, + { type: "interrupt", targetRunIndex: 1, waitForTurnItemType: "subagent" }, + ], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts index f242c8e0a467d..601f47862b86f 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts @@ -198,9 +198,12 @@ import { fxEarlyStopInput } from "./fx_early_stop/input.ts"; import { assertFxEarlyStopOutput } from "./fx_early_stop/fx_output.ts"; import { fxSubagentInput } from "./fx_subagent/input.ts"; import { assertFxSubagentOutput } from "./fx_subagent/fx_output.ts"; +import { fxSubagentStopInput } from "./fx_subagent_stop/input.ts"; +import { assertFxSubagentStopOutput } from "./fx_subagent_stop/fx_output.ts"; import { assertTurnInterruptMidToolClaudeOutput } from "./turn_interrupt_mid_tool/claude_output.ts"; import { assertTurnInterruptMidToolCodexOutput } from "./turn_interrupt_mid_tool/codex_output.ts"; import { assertTurnInterruptMidToolCursorOutput } from "./turn_interrupt_mid_tool/cursor_output.ts"; +import { assertTurnInterruptMidToolFxOutput } from "./turn_interrupt_mid_tool/fx_output.ts"; import { turnInterruptMidToolInput } from "./turn_interrupt_mid_tool/input.ts"; import { assertTurnInterruptMidToolPiOutput } from "./turn_interrupt_mid_tool/pi_output.ts"; import { assertTurnInterruptRestartClaudeOutput } from "./turn_interrupt_restart/claude_output.ts"; @@ -1673,6 +1676,18 @@ export const ORCHESTRATOR_REPLAY_FIXTURES: ReadonlyArray {}, 30000)\"`. Do not answer until it completes, then respond exactly: interrupt fixture should not finish naturally."; const COMMAND_OUTPUT_STREAMING_COMMAND = diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/fx_output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/fx_output.ts new file mode 100644 index 0000000000000..54e3c806df07a --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/fx_output.ts @@ -0,0 +1,64 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertBaseProjection, + assertSemanticProjectionIntegrity, + assertTurnItemTypes, + assertUserMessagesInclude, + assertVisibleTurnItemsMirrorLocalTurnItems, + projectionFor, + TURN_INTERRUPT_MID_TOOL_PROMPT, +} from "../shared.ts"; + +function acpFrame(entry: ProviderReplayTranscript["entries"][number]) { + return entry.type === "runtime_exit" + ? undefined + : (entry.frame as { + readonly method?: string; + readonly params?: { readonly update?: { sessionUpdate?: string; status?: string } }; + }); +} + +/** fx 0.0.15 answers Stop by failing the running command itself; T3 still shows it interrupted. */ +export function assertTurnInterruptMidToolFxOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + const cancelIndex = transcript.entries.findIndex( + (entry) => entry.type === "expect_outbound" && acpFrame(entry)?.method === "session/cancel", + ); + const failedAfterCancel = transcript.entries.findIndex( + (entry, index) => + index > cancelIndex && + entry.type === "emit_inbound" && + acpFrame(entry)?.params?.update?.sessionUpdate === "tool_call_update" && + acpFrame(entry)?.params?.update?.status === "failed", + ); + assert.isAtLeast(cancelIndex, 0); + assert.isAbove(failedAfterCancel, cancelIndex, "fx fails the cut-off command after Stop"); + assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["interrupted"] }); + + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + assertVisibleTurnItemsMirrorLocalTurnItems(projection); + assertTurnItemTypes(projection, [ + "user_message", + "command_execution", + "run_interrupt_request", + "run_interrupt_result", + ]); + assertUserMessagesInclude(projection, [TURN_INTERRUPT_MID_TOOL_PROMPT]); + + const commandItem = projection.turnItems.find((item) => item.type === "command_execution"); + assert.isDefined(commandItem); + assert.include(commandItem.input, "node -e"); + assert.equal(commandItem.status, "interrupted"); + assert.equal( + projection.turnItems.find((item) => item.type === "run_interrupt_result")?.status, + "interrupted", + ); + assert.equal(projection.providerTurns[0]?.status, "interrupted"); + assert.equal(projection.providerThreads[0]?.status, "idle"); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/fx_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/fx_transcript.ndjson new file mode 100644 index 0000000000000..0b4e36962f7bb --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/fx_transcript.ndjson @@ -0,0 +1,33 @@ +{"type":"transcript_start","provider":"fx","protocol":"acp.ndjson-jsonrpc","version":"1","scenario":"turn_interrupt_mid_tool","metadata":{"generatedBy":"live-fx-recorder","fxVersion":"0.0.15","normalization":"Session ids are fx-session-NN, the workspace is , HOME is /home/fx-replay and the recording user is fx-replay. T3-owned prompt text, MCP servers and initialize params other than clientCapabilities and _meta are . User skill names, emails and token-shaped strings are removed. Timestamps are kept as recorded.","droppedFrames":0}} +{"type":"expect_outbound","label":"request:initialize","frame":{"kind":"request","method":"initialize","params":{"protocolVersion":2,"info":"","capabilities":"","clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false,"session":{"notices":{}},"elicitation":{"form":{}}},"clientInfo":""}}} +{"type":"emit_inbound","label":"response:initialize","frame":{"kind":"response","method":"initialize","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":true,"audio":false,"embeddedContext":true},"mcpCapabilities":{"http":true,"sse":false,"acp":false},"sessionCapabilities":{"list":{},"resume":{},"close":{},"systemPrompt":{}},"_meta":{"fx":{"steering":true}}},"agentInfo":{"name":"fx","title":"fx","version":"0.0.15"},"authMethods":[]}}} +{"type":"expect_outbound","label":"request:session/new","frame":{"kind":"request","method":"session/new","params":{"cwd":"","mcpServers":""}}} +{"type":"emit_inbound","label":"response:session/new","frame":{"kind":"response","method":"session/new","result":{"sessionId":"fx-session-01","configOptions":[{"id":"provider","name":"Provider","category":"model","type":"select","currentValue":"codex","options":[{"value":"gateway","name":"Vercel AI Gateway"},{"value":"codex","name":"Codex subscription"},{"value":"grok","name":"Grok subscription"}]},{"id":"model","name":"Model","category":"model","type":"select","currentValue":"gpt-6.1-sol","options":[{"value":"gpt-6.1-sol","name":"gpt-6.1-sol"},{"value":"gpt-6-astra","name":"gpt-6-astra"},{"value":"gpt-6.1-terra","name":"gpt-6.1-terra"},{"value":"gpt-6-sol","name":"gpt-6-sol"},{"value":"gpt-6-luna","name":"gpt-6-luna"},{"value":"gpt-5.6-sol","name":"gpt-5.6-sol"},{"value":"gpt-5.6-terra","name":"gpt-5.6-terra"},{"value":"gpt-5.6-luna","name":"gpt-5.6-luna"},{"value":"gpt-daybreak-blue-latest","name":"gpt-daybreak-blue-latest"}]},{"id":"mode","name":"Session Mode","description":"Controls how the agent requests permission","category":"mode","type":"select","currentValue":"auto","options":[{"value":"auto","name":"Auto","description":"Check actions with an automatic safety review instead of asking","_meta":{"fx":{"permissionMode":"auto"}}},{"value":"ask","name":"Ask","description":"Request permission before making any changes","_meta":{"fx":{"permissionMode":"ask"}}},{"value":"full-access","name":"Full access","description":"Run every action without asking, outside the sandbox","_meta":{"fx":{"permissionMode":"yolo"}}}]},{"id":"effort","name":"Reasoning Effort","description":"Controls how much the model thinks before responding","category":"thought_level","type":"select","currentValue":"auto","options":[{"value":"auto","name":"default"},{"value":"low","name":"low"},{"value":"medium","name":"medium"},{"value":"high","name":"high"},{"value":"xhigh","name":"xhigh"},{"value":"max","name":"max"},{"value":"ultra","name":"ultra"}]},{"id":"fast","name":"Fast Mode","description":"Uses the model's fast lane","category":"model","type":"select","currentValue":"false","options":[{"value":"false","name":"off"},{"value":"true","name":"on"}]}],"modes":{"currentModeId":"auto","availableModes":[{"id":"auto","name":"Auto","description":"Check actions with an automatic safety review instead of asking"},{"id":"ask","name":"Ask","description":"Request permission before making any changes"},{"id":"full-access","name":"Full access","description":"Run every action without asking, outside the sandbox"}]}}}} +{"type":"emit_inbound","label":"notification:session/update:available_commands_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}} +{"type":"expect_outbound","label":"request:session/set_config_option","frame":{"kind":"request","method":"session/set_config_option","params":{"sessionId":"fx-session-01","configId":"effort","value":"medium"}}} +{"type":"emit_inbound","label":"response:session/set_config_option","frame":{"kind":"response","method":"session/set_config_option","result":{"configOptions":[{"id":"provider","name":"Provider","category":"model","type":"select","currentValue":"codex","options":[{"value":"gateway","name":"Vercel AI Gateway"},{"value":"codex","name":"Codex subscription"},{"value":"grok","name":"Grok subscription"}]},{"id":"model","name":"Model","category":"model","type":"select","currentValue":"gpt-6.1-sol","options":[{"value":"gpt-6.1-sol","name":"gpt-6.1-sol"},{"value":"gpt-6-astra","name":"gpt-6-astra"},{"value":"gpt-6.1-terra","name":"gpt-6.1-terra"},{"value":"gpt-6-sol","name":"gpt-6-sol"},{"value":"gpt-6-luna","name":"gpt-6-luna"},{"value":"gpt-5.6-sol","name":"gpt-5.6-sol"},{"value":"gpt-5.6-terra","name":"gpt-5.6-terra"},{"value":"gpt-5.6-luna","name":"gpt-5.6-luna"},{"value":"gpt-daybreak-blue-latest","name":"gpt-daybreak-blue-latest"}]},{"id":"mode","name":"Session Mode","description":"Controls how the agent requests permission","category":"mode","type":"select","currentValue":"auto","options":[{"value":"auto","name":"Auto","description":"Check actions with an automatic safety review instead of asking","_meta":{"fx":{"permissionMode":"auto"}}},{"value":"ask","name":"Ask","description":"Request permission before making any changes","_meta":{"fx":{"permissionMode":"ask"}}},{"value":"full-access","name":"Full access","description":"Run every action without asking, outside the sandbox","_meta":{"fx":{"permissionMode":"yolo"}}}]},{"id":"effort","name":"Reasoning Effort","description":"Controls how much the model thinks before responding","category":"thought_level","type":"select","currentValue":"medium","options":[{"value":"auto","name":"default"},{"value":"low","name":"low"},{"value":"medium","name":"medium"},{"value":"high","name":"high"},{"value":"xhigh","name":"xhigh"},{"value":"max","name":"max"},{"value":"ultra","name":"ultra"}]},{"id":"fast","name":"Fast Mode","description":"Uses the model's fast lane","category":"model","type":"select","currentValue":"false","options":[{"value":"false","name":"off"},{"value":"true","name":"on"}]}]}}} +{"type":"expect_outbound","label":"request:session/set_config_option","frame":{"kind":"request","method":"session/set_config_option","params":{"sessionId":"fx-session-01","configId":"mode","value":"ask"}}} +{"type":"emit_inbound","label":"response:session/set_config_option","frame":{"kind":"response","method":"session/set_config_option","result":{"configOptions":[{"id":"provider","name":"Provider","category":"model","type":"select","currentValue":"codex","options":[{"value":"gateway","name":"Vercel AI Gateway"},{"value":"codex","name":"Codex subscription"},{"value":"grok","name":"Grok subscription"}]},{"id":"model","name":"Model","category":"model","type":"select","currentValue":"gpt-6.1-sol","options":[{"value":"gpt-6.1-sol","name":"gpt-6.1-sol"},{"value":"gpt-6-astra","name":"gpt-6-astra"},{"value":"gpt-6.1-terra","name":"gpt-6.1-terra"},{"value":"gpt-6-sol","name":"gpt-6-sol"},{"value":"gpt-6-luna","name":"gpt-6-luna"},{"value":"gpt-5.6-sol","name":"gpt-5.6-sol"},{"value":"gpt-5.6-terra","name":"gpt-5.6-terra"},{"value":"gpt-5.6-luna","name":"gpt-5.6-luna"},{"value":"gpt-daybreak-blue-latest","name":"gpt-daybreak-blue-latest"}]},{"id":"mode","name":"Session Mode","description":"Controls how the agent requests permission","category":"mode","type":"select","currentValue":"ask","options":[{"value":"auto","name":"Auto","description":"Check actions with an automatic safety review instead of asking","_meta":{"fx":{"permissionMode":"auto"}}},{"value":"ask","name":"Ask","description":"Request permission before making any changes","_meta":{"fx":{"permissionMode":"ask"}}},{"value":"full-access","name":"Full access","description":"Run every action without asking, outside the sandbox","_meta":{"fx":{"permissionMode":"yolo"}}}]},{"id":"effort","name":"Reasoning Effort","description":"Controls how much the model thinks before responding","category":"thought_level","type":"select","currentValue":"medium","options":[{"value":"auto","name":"default"},{"value":"low","name":"low"},{"value":"medium","name":"medium"},{"value":"high","name":"high"},{"value":"xhigh","name":"xhigh"},{"value":"max","name":"max"},{"value":"ultra","name":"ultra"}]},{"id":"fast","name":"Fast Mode","description":"Uses the model's fast lane","category":"model","type":"select","currentValue":"false","options":[{"value":"false","name":"off"},{"value":"true","name":"on"}]}]}}} +{"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"fx-session-01","prompt":[{"type":"text","text":"\n\n\n\n\nRun this exact local command: `node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"`. Do not answer until it completes, then respond exactly: interrupt fixture should not finish naturally.\n"}]}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"c6368a3ae8b2d57bec270c4e9afcaa54","content":{"type":"text","text":"I’ll run the"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"c6368a3ae8b2d57bec270c4e9afcaa54","content":{"type":"text","text":" exact"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"c6368a3ae8b2d57bec270c4e9afcaa54","content":{"type":"text","text":" command"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"c6368a3ae8b2d57bec270c4e9afcaa54","content":{"type":"text","text":" and"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"c6368a3ae8b2d57bec270c4e9afcaa54","content":{"type":"text","text":" wait"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"c6368a3ae8b2d57bec270c4e9afcaa54","content":{"type":"text","text":" for"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"c6368a3ae8b2d57bec270c4e9afcaa54","content":{"type":"text","text":" it"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"c6368a3ae8b2d57bec270c4e9afcaa54","content":{"type":"text","text":" to"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"c6368a3ae8b2d57bec270c4e9afcaa54","content":{"type":"text","text":" complete"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"c6368a3ae8b2d57bec270c4e9afcaa54","content":{"type":"text","text":".\n"}}}}} +{"type":"emit_inbound","label":"notification:session/update:tool_call","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"tool_call","toolCallId":"call_573669dc43cb4393aeceab4ea77cffbb","name":"shell","title":"Running","kind":"execute","status":"pending","rawInput":{"action":"run","command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","yield_time_ms":1000},"_meta":{"fx":{"toolCall":{"internal":false}}}}}}} +{"type":"emit_inbound","label":"request:session/request_permission","frame":{"kind":"request","method":"session/request_permission","params":{"sessionId":"fx-session-01","toolCall":{"toolCallId":"call_573669dc43cb4393aeceab4ea77cffbb","name":"shell","title":"shell.run node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","kind":"execute","status":"pending","rawInput":{"action":"run","command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","yield_time_ms":1000}},"options":[{"optionId":"allow_once","name":"Allow once","kind":"allow_once"},{"optionId":"allow_always","name":"Allow for this session","kind":"allow_always"},{"optionId":"reject_once","name":"Reject","kind":"reject_once"}]}}} +{"type":"expect_outbound","label":"response:session/request_permission","frame":{"kind":"response","method":"session/request_permission","result":{"outcome":{"outcome":"selected","optionId":"allow_once"}}}} +{"type":"emit_inbound","label":"notification:session/update:tool_call_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_573669dc43cb4393aeceab4ea77cffbb","status":"in_progress"}}}} +{"type":"expect_outbound","label":"notification:session/cancel","frame":{"kind":"notification","method":"session/cancel","params":{"sessionId":"fx-session-01"}}} +{"type":"emit_inbound","label":"notification:session/update:tool_call_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_573669dc43cb4393aeceab4ea77cffbb","status":"failed","content":[{"type":"content","content":{"type":"text","text":""}}],"command_result":{"kind":"command","command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","cwd":"","exit_code":null,"signal":null,"timed_out":false,"termination_indeterminate":true,"duration_ms":null,"stdout_bytes":0,"stderr_bytes":0,"truncated":false,"output_file":null,"stdout_file":null,"stderr_file":null}}}}} +{"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"session_info_update","title":"Run Interrupt Fixture Tool Locally","updatedAt":"2026-10-29T14:22:39Z"}}}} +{"type":"emit_inbound","label":"notification:session/update:usage_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"usage_update","used":6405,"size":500000}}}} +{"type":"emit_inbound","label":"response:session/prompt","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"cancelled","usage":{"inputTokens":6336,"outputTokens":69,"cacheReadTokens":0,"cacheWriteTokens":0,"reasoningTokens":0}}}} +{"type":"expect_outbound","label":"request:session/close","frame":{"kind":"request","method":"session/close","params":{"sessionId":"fx-session-01"}}} +{"type":"emit_inbound","label":"response:session/close","frame":{"kind":"response","method":"session/close","result":{}}} +{"type":"runtime_exit","status":"success"} diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index b7c77c4049591..b8471b132827d 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -3601,6 +3601,17 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ? toolCall : { ...toolCall, backgroundCommandTaskId }, ); + // fx 0.0.15 fails the tools a Stop cut off; they read as interrupted, like ones left open. + const previousStatus = previous === undefined ? undefined : toolStatus(previous.status); + const stoppedStatus = + projectedStatus ?? + (context.interrupted && + toolCall.status === "failed" && + (previousStatus === "pending" || + previousStatus === "running" || + previousStatus === "waiting") + ? "interrupted" + : undefined); if ( followsAgentWorkState && projectedStatus === undefined && @@ -3705,7 +3716,12 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ? undefined : extractSubagentUpdate(toolCall); if (subagentUpdate !== undefined) { - yield* emitSubagent(context, subagentUpdate); + yield* emitSubagent( + context, + stoppedStatus === "interrupted" && subagentUpdate.status === "failed" + ? { ...subagentUpdate, status: "interrupted" } + : subagentUpdate, + ); if (followsAgentWorkState) yield* syncNativeChildren(context); if (subagentUpdate.suppressNormalTool !== false) { yield* rearmDeferredFinalize(context); @@ -3713,7 +3729,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } } if ( - projectedStatus === undefined && + stoppedStatus === undefined && !shouldPersistToolUpdate( context, toolCall.toolCallId, @@ -3725,7 +3741,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( yield* rearmDeferredFinalize(context); return; } - const status = projectedStatus ?? toolStatus(toolCall.status); + const status = stoppedStatus ?? toolStatus(toolCall.status); const now = yield* DateTime.now; const nativeItemId = `${context.nativeThreadId}:tool:${toolCall.toolCallId}`; const ordinal = yield* resolveItemOrdinal(context, nativeItemId); diff --git a/packages/provider-fx/src/server/adapter.ts b/packages/provider-fx/src/server/adapter.ts index dfb956e6d13f1..433f800035400 100644 --- a/packages/provider-fx/src/server/adapter.ts +++ b/packages/provider-fx/src/server/adapter.ts @@ -97,6 +97,16 @@ const decodeFxSubagentResult = Schema.decodeUnknownOption( ), ); const decodeJsonString = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.String)); +const decodeFxToolError = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + error: Schema.Struct({ + message: Schema.String, + details: Schema.optional(Schema.Struct({ error: Schema.optional(Schema.String) })), + }), + }), + ), +); /** * fx clips every tool result to 199 bytes, so a long sub-agent reply arrives @@ -125,6 +135,10 @@ function extractFxSubagentUpdate(tool: AcpToolCallState): AcpAdapterV2SubagentUp const request = "request" in input.value ? input.value.request : input.value; const text = Option.getOrUndefined(decodeFxToolContent(tool.data.content))?.[0]?.content.text; const outcome = text === undefined ? undefined : fxSubagentOutcome(text); + // A failed call carries fx's error envelope, e.g. `Cancelled` after Stop. + const failure = Option.getOrUndefined( + text === undefined ? Option.none() : decodeFxToolError(text), + )?.error; const status = tool.status === "completed" ? outcome?.ok === false @@ -140,7 +154,10 @@ function extractFxSubagentUpdate(tool: AcpToolCallState): AcpAdapterV2SubagentUp title: request.action === "message" ? request.agent : null, model: request.model ?? null, status, - result: status === "running" ? null : (outcome?.result ?? text ?? null), + result: + status === "running" + ? null + : (outcome?.result ?? failure?.details?.error ?? failure?.message ?? text ?? null), }; } From 4b5dfa819bb030cf555238b7ee3ac982b42f90c3 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 29 Oct 2026 10:54:48 -0400 Subject: [PATCH 03/17] fix(server): a tool Stop cut off stays interrupted through later updates After Stop, the first failed update was stored as failed, so a later update for the same tool no longer matched the open-status check and the card flipped back to failed. The turn now remembers which tools Stop cut off. Co-Authored-By: Claude Opus 5.5 --- apps/server/scripts/acp-mock-agent.ts | 9 ++++++++ .../Adapters/AcpAdapterV2.test.ts | 21 +++++++++++++++---- packages/provider-acp/src/server/adapter.ts | 14 ++++++++++--- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index de3fdb1a31404..514059738d91c 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -1301,6 +1301,15 @@ const program = Effect.gen(function* () { content: [{ type: "content", content: { type: "text", text: "Cancelled." } }], }, }); + // A later output-only update must not undo the interrupted status. + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "native-cancel-tool", + content: [{ type: "content", content: { type: "text", text: "Cancelled. exit 130" } }], + }, + }); yield* agent.client.sessionUpdate({ sessionId: requestedSessionId, update: { diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 07c3bd731633f..e0451ea06db5e 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -5402,15 +5402,28 @@ describe("AcpAdapterV2", () => { ); yield* native.current.request("_test/finish-cancel", {}); yield* Fiber.join(interrupt); - const terminal = Option.getOrThrow( + const untilTerminal = Array.from( yield* runtime.events.pipe( - Stream.filter( + Stream.takeUntil( (event) => event.type === "turn.terminal" && event.providerTurnId === providerTurnId, ), - Stream.runHead, + Stream.runCollect, ), ); - assert.equal(terminal.type === "turn.terminal" && terminal.status, "interrupted"); + const terminal = untilTerminal.at(-1); + assert.equal(terminal?.type === "turn.terminal" && terminal.status, "interrupted"); + // The agent fails the command Stop cut off, then updates its output. + assert.equal( + untilTerminal + .flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === "native-cancel-tool" + ? [event.turnItem.status] + : [], + ) + .at(-1), + "interrupted", + ); yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }), ); diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index b8471b132827d..c2c3e3f371fa5 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -1209,6 +1209,8 @@ interface ActiveAcpTurn { contextUsage: ThreadTokenUsageSnapshot | null; nativeMetadata: OrchestrationV2ProviderThreadNativeMetadata | null; readonly tools: Map; + /** Tools the agent failed after Stop; later updates keep them interrupted. */ + readonly stoppedToolCallIds: Set; readonly toolUpdatesSkipped: Map; readonly toolStartedAt: Map; readonly subagents: Map; @@ -3603,13 +3605,18 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ); // fx 0.0.15 fails the tools a Stop cut off; they read as interrupted, like ones left open. const previousStatus = previous === undefined ? undefined : toolStatus(previous.status); - const stoppedStatus = - projectedStatus ?? - (context.interrupted && + if ( + context.interrupted && toolCall.status === "failed" && (previousStatus === "pending" || previousStatus === "running" || previousStatus === "waiting") + ) { + context.stoppedToolCallIds.add(toolCall.toolCallId); + } + const stoppedStatus = + projectedStatus ?? + (toolCall.status === "failed" && context.stoppedToolCallIds.has(toolCall.toolCallId) ? "interrupted" : undefined); if ( @@ -7726,6 +7733,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( contextUsage: rememberedContextUsage ?? turnInput.providerThread.contextUsage ?? null, nativeMetadata: initialNativeMetadata, tools: new Map(), + stoppedToolCallIds: new Set(), toolUpdatesSkipped: new Map(), toolStartedAt: new Map(), subagents: new Map(), From 009dd0d1b97d6cade8c6fe58f4156e655b7ef9e7 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 29 Oct 2026 11:01:24 -0400 Subject: [PATCH 04/17] fix(server): no fx update offer on macOS 14 fx releases after 0.0.13 need macOS 15, and `fx upgrade` installs the latest release without checking, so Update now on macOS 14 would leave an fx that cannot start. Older Macs get neither the update action nor a latest-version check, so T3 does not advertise a release they cannot run. Co-Authored-By: Claude Opus 5.5 --- docs/user/providers-fx.md | 2 +- .../provider-fx/src/server/maintenance.test.ts | 16 ++++++++++++++++ packages/provider-fx/src/server/maintenance.ts | 9 +++++++-- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/docs/user/providers-fx.md b/docs/user/providers-fx.md index f01ae227dcf1c..0dec44680c0a5 100644 --- a/docs/user/providers-fx.md +++ b/docs/user/providers-fx.md @@ -21,7 +21,7 @@ Every fx instance on the same machine shares that machine's fx login. T3 Code do ## Update fx -When a newer fx release is out, the fx card in **Settings → Providers** shows it. **Update now** runs `fx upgrade` on the binary that instance uses, including one set in **Binary path**. You can also run `fx upgrade` on that machine yourself. T3 Code checks for new stable releases only; on fx's dev channel, run `fx upgrade` yourself to get newer dev builds. +When a newer fx release is out, the fx card in **Settings → Providers** shows it. **Update now** runs `fx upgrade` on the binary that instance uses, including one set in **Binary path**. You can also run `fx upgrade` on that machine yourself. T3 Code checks for new stable releases only; on fx's dev channel, run `fx upgrade` yourself to get newer dev builds. On macOS 14 or earlier, which newer fx releases do not support, T3 Code does not offer fx updates. ## Models and effort diff --git a/packages/provider-fx/src/server/maintenance.test.ts b/packages/provider-fx/src/server/maintenance.test.ts index 8f51b3b159aa9..044049bd4db8e 100644 --- a/packages/provider-fx/src/server/maintenance.test.ts +++ b/packages/provider-fx/src/server/maintenance.test.ts @@ -34,6 +34,7 @@ it.layer(NodeServices.layer)("fx maintenance", (it) => { fetchLatestFxVersion().pipe( Effect.provideService(HttpClient.HttpClient, serving(new Response("v0.0.15\n"))), ), + "24.0.0", ).resolve({ binaryPath, resolvedCommandPath: binaryPath, @@ -79,4 +80,19 @@ it.layer(NodeServices.layer)("fx maintenance", (it) => { expect((yield* makeFxMaintenance(Effect.succeed("0.0.15")).resolve(null)).update).toBeNull(); }), ); + + it.effect("offers no update or latest version on macOS 14, which newer fx cannot run on", () => + Effect.gen(function* () { + const binaryPath = "/opt/tools/fx"; + const capabilities = yield* makeFxMaintenance(Effect.succeed("0.0.15"), "23.6.0").resolve({ + binaryPath, + resolvedCommandPath: binaryPath, + realCommandPath: binaryPath, + platform: "darwin", + env: {}, + }); + expect(capabilities.update).toBeNull(); + expect(capabilities.lookupLatestVersion).toBeUndefined(); + }), + ); }); diff --git a/packages/provider-fx/src/server/maintenance.ts b/packages/provider-fx/src/server/maintenance.ts index 5c744db651cf7..20452f755110e 100644 --- a/packages/provider-fx/src/server/maintenance.ts +++ b/packages/provider-fx/src/server/maintenance.ts @@ -1,3 +1,5 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeOS from "node:os"; import * as Effect from "effect/Effect"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; @@ -26,14 +28,17 @@ export const fetchLatestFxVersion = Effect.fn("fetchLatestFxVersion")(function* /** * `fx upgrade` replaces the executable it runs from, so the update runs the - * instance's resolved binary, wherever `binaryPath` points. + * instance's resolved binary, wherever `binaryPath` points. fx after 0.0.13 + * needs macOS 15 (Darwin 24) and `fx upgrade` does not check, so older Macs + * get no update offer or latest-version check. */ export const makeFxMaintenance = ( lookupLatestVersion: Effect.Effect, + osRelease: string = NodeOS.release(), ): ProviderMaintenanceCapabilitiesResolver => ({ resolve: (context) => Effect.succeed( - context === null + context === null || (context.platform === "darwin" && Number.parseInt(osRelease, 10) < 24) ? makeManualOnlyProviderMaintenanceCapabilities({ provider: FX_DRIVER_KIND, packageName: null, From fc336dbc46024079bc091b6b816b2aa4b2fac8d8 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 29 Oct 2026 11:13:07 -0400 Subject: [PATCH 05/17] test(server): the Stop test checks the follow-up output reached the tool Without it, dropping the output-only update after Stop would still pass on the earlier interrupted status. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/AcpAdapterV2.test.ts | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index e0451ea06db5e..885ae0efaff17 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -5413,17 +5413,16 @@ describe("AcpAdapterV2", () => { const terminal = untilTerminal.at(-1); assert.equal(terminal?.type === "turn.terminal" && terminal.status, "interrupted"); // The agent fails the command Stop cut off, then updates its output. - assert.equal( - untilTerminal - .flatMap((event) => - event.type === "turn_item.updated" && - event.turnItem.nativeItemRef?.nativeId === "native-cancel-tool" - ? [event.turnItem.status] - : [], - ) - .at(-1), - "interrupted", - ); + const command = untilTerminal + .flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === "native-cancel-tool" + ? [event.turnItem] + : [], + ) + .at(-1); + assert.equal(command?.status, "interrupted"); + assert.include(command?.type === "command_execution" ? command.output : "", "exit 130"); yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }), ); From eb8b52dd795faefaca9a05d4c934b4127f781af5 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Mon, 2 Nov 2026 10:18:17 -0500 Subject: [PATCH 06/17] fix(fx): preserve real tool failures during Stop --- apps/server/scripts/acp-mock-agent.ts | 19 ++++++ .../Adapters/AcpAdapterV2.test.ts | 18 ++++++ packages/effect-acp/src/protocol.test.ts | 36 ++++++++++- packages/effect-acp/src/protocol.ts | 9 +++ packages/provider-acp/src/server/adapter.ts | 8 ++- .../provider-fx/src/server/acpSupport.test.ts | 64 ++++++++++++++++++- packages/provider-fx/src/server/acpSupport.ts | 43 +++++++++++++ packages/provider-fx/src/server/adapter.ts | 2 + .../provider-fx/src/server/maintenance.ts | 2 +- 9 files changed, 194 insertions(+), 7 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index 514059738d91c..eeabd7f2adb7b 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -1290,8 +1290,27 @@ const program = Effect.gen(function* () { status: "in_progress", }, }); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "native-failed-tool", + title: "Failing command", + kind: "execute", + status: "in_progress", + }, + }); yield* Deferred.await(nativeCancelRequested); yield* Deferred.await(nativeCancelRelease); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "native-failed-tool", + status: "failed", + content: [{ type: "content", content: { type: "text", text: "Permission denied" } }], + }, + }); yield* agent.client.sessionUpdate({ sessionId: requestedSessionId, update: { diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 885ae0efaff17..1f4228625d23a 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -5332,6 +5332,11 @@ describe("AcpAdapterV2", () => { flavor: { driver: ACP_TEST_DRIVER, capabilities: AcpProviderCapabilitiesV2, + isCancelledToolCall: Schema.is( + Schema.Struct({ + update: Schema.Struct({ toolCallId: Schema.Literal("native-cancel-tool") }), + }), + ), makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath: yield* path.fromFileUrl( @@ -5423,6 +5428,19 @@ describe("AcpAdapterV2", () => { .at(-1); assert.equal(command?.status, "interrupted"); assert.include(command?.type === "command_execution" ? command.output : "", "exit 130"); + const failure = untilTerminal + .flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === "native-failed-tool" + ? [event.turnItem] + : [], + ) + .at(-1); + assert.equal(failure?.status, "failed"); + assert.include( + failure?.type === "command_execution" ? failure.output : "", + "Permission denied", + ); yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }), ); diff --git a/packages/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts index eae3394a1653f..a4929c466cf8e 100644 --- a/packages/effect-acp/src/protocol.test.ts +++ b/packages/effect-acp/src/protocol.test.ts @@ -92,7 +92,7 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { const notifications = yield* Deferred.make>(); yield* transport.incoming.pipe( - Stream.take(2), + Stream.take(3), Stream.runCollect, Effect.flatMap((notificationChunk) => Deferred.succeed(notifications, notificationChunk)), Effect.forkScoped, @@ -133,8 +133,29 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { }, }); + const commandResult = { + exit_code: null, + signal: null, + timed_out: false, + termination_indeterminate: true, + }; + const cancelledToolFrame = encoder.encode( + `${encodeUnknownJsonString({ + jsonrpc: "2.0", + method: "session/update", + params: { + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "cancelled-tool", + status: "failed", + command_result: commandResult, + }, + }, + })}\n`, + ); // Frames split across reads, with one read ending one frame and starting the next. - const wire = new Uint8Array([...updateFrame, ...completionFrame]); + const wire = new Uint8Array([...updateFrame, ...completionFrame, ...cancelledToolFrame]); const splits = [10, updateFrame.length + 10, wire.length]; let start = 0; for (const end of splits) { @@ -142,9 +163,18 @@ it.layer(NodeServices.layer)("effect-acp protocol", (it) => { start = end; } - const [update, completion] = yield* Deferred.await(notifications); + const [update, completion, cancelledTool] = yield* Deferred.await(notifications); assert.equal(update?._tag, "SessionUpdate"); assert.equal(completion?._tag, "ElicitationComplete"); + assert.deepEqual(cancelledTool?.params, { + sessionId: "session-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "cancelled-tool", + status: "failed", + command_result: commandResult, + }, + }); }), ); diff --git a/packages/effect-acp/src/protocol.ts b/packages/effect-acp/src/protocol.ts index 8763e66361e12..1528e792c04fb 100644 --- a/packages/effect-acp/src/protocol.ts +++ b/packages/effect-acp/src/protocol.ts @@ -153,6 +153,15 @@ const decodeSessionUpdate = Schema.decodeUnknownEffect( _meta: AcpSchema.UpdateSessionNotification.fields._meta, }), }), + // fx reports command cancellation outside ACP's standard tool fields. + Schema.Struct({ + ...AcpSchemaV1.SessionNotification.fields, + update: Schema.Struct({ + ...AcpSchemaV1.ToolCallUpdate.fields, + sessionUpdate: Schema.Literals(["tool_call", "tool_call_update"]), + command_result: Schema.Json, + }), + }), AcpSchema.UpdateSessionNotification, AcpSchemaV1.SessionNotification, ]), diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index c2c3e3f371fa5..ae3112f6753c1 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -402,6 +402,7 @@ export interface AcpAdapterV2Flavor { ) => AcpAdapterV2SubagentUpdate | undefined; readonly isProvisionalSubagentTool?: (toolCall: AcpToolCallState) => boolean; readonly normalizeToolCall?: (toolCall: AcpToolCallState) => AcpToolCallState; + readonly isCancelledToolCall?: (payload: unknown) => boolean; readonly extractProposedPlanMarkdown?: (toolCall: AcpToolCallState) => string | undefined; readonly extractBackgroundTaskId?: (toolCall: AcpToolCallState) => string | undefined; /** Distinguishes commands from monitors when lifecycle notifications omit their kind. */ @@ -3511,6 +3512,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( context: ActiveAcpTurn, incoming: AcpToolCallState, projectedStatus?: ProjectedToolStatus, + rawPayload?: unknown, ) => Effect.Effect = () => Effect.void; const markAwaitingBackgroundHydration = @@ -3563,6 +3565,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( context: ActiveAcpTurn, incoming: AcpToolCallState, projectedStatus?: ProjectedToolStatus, + rawPayload?: unknown, ) { if (context.assistant.current?.sourceMessageId == null) yield* closeTextStream(context, "assistant"); @@ -3603,11 +3606,12 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ? toolCall : { ...toolCall, backgroundCommandTaskId }, ); - // fx 0.0.15 fails the tools a Stop cut off; they read as interrupted, like ones left open. + // A failure racing Stop needs the provider's cancellation signal before relabeling it. const previousStatus = previous === undefined ? undefined : toolStatus(previous.status); if ( context.interrupted && toolCall.status === "failed" && + flavor.isCancelledToolCall?.(rawPayload) === true && (previousStatus === "pending" || previousStatus === "running" || previousStatus === "waiting") @@ -5275,7 +5279,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const parsed = parseSessionUpdateEvent(notification); for (const event of parsed.events) { if (event._tag === "ToolCallUpdated") { - yield* emitTool(context, event.toolCall); + yield* emitTool(context, event.toolCall, undefined, event.rawPayload); } else if (event._tag === "PlanUpdated") { yield* emitPlan(context, event.payload); } else if (event._tag === "NoticeReceived") { diff --git a/packages/provider-fx/src/server/acpSupport.test.ts b/packages/provider-fx/src/server/acpSupport.test.ts index ae40665cb031c..2785814b73728 100644 --- a/packages/provider-fx/src/server/acpSupport.test.ts +++ b/packages/provider-fx/src/server/acpSupport.test.ts @@ -3,7 +3,7 @@ import type * as EffectAcpSchema from "effect-acp/compat"; import { ProviderAdapterV2RuntimePolicy } from "@t3tools/provider-core/server/ProviderAdapter"; -import { fxPermissionDisposition } from "./acpSupport.ts"; +import { fxPermissionDisposition, fxToolCallWasCancelled } from "./acpSupport.ts"; const editRequest = { sessionId: "fx-session-01", @@ -31,3 +31,65 @@ describe("fx permission prompts", () => { ); }); }); + +describe("fx cancellation failures", () => { + it.each([ + { content: "Cancelled", cancelled: true }, + { content: "Cancelled.", cancelled: true }, + { content: '{"error":{"details":{"error":"Cancelled"}}}', cancelled: true }, + { content: '{"error":{"details":{"error":"Permission denied"}}}', cancelled: false }, + { content: "Command failed", cancelled: false }, + { content: "", cancelled: false }, + ])("recognizes $content as cancellation: $cancelled", ({ content, cancelled }) => { + assert.equal( + fxToolCallWasCancelled({ update: { content: [{ content: { text: content } }] } }), + cancelled, + ); + }); + + it.each([ + { + exit_code: null, + signal: null, + timed_out: false, + termination_indeterminate: true, + content: "", + cancelled: true, + }, + { + exit_code: null, + signal: null, + timed_out: false, + termination_indeterminate: true, + content: + '{"error":{"type":"tool_execution_failed","message":"Command started, but its final process status could not be confirmed",', + cancelled: false, + }, + { + exit_code: 1, + signal: null, + timed_out: false, + termination_indeterminate: false, + content: "", + cancelled: false, + }, + { + exit_code: null, + signal: null, + timed_out: true, + termination_indeterminate: true, + content: "", + cancelled: false, + }, + ])( + "recognizes a command result with exit $exit_code, timeout $timed_out", + ({ cancelled, content, ...commandResult }) => { + assert.equal( + fxToolCallWasCancelled({ + update: { command_result: commandResult, content: [{ content: { text: content } }] }, + }), + cancelled, + ); + }, + ); +}); diff --git a/packages/provider-fx/src/server/acpSupport.ts b/packages/provider-fx/src/server/acpSupport.ts index fb6dfae56ef86..c9b56323fd7dc 100644 --- a/packages/provider-fx/src/server/acpSupport.ts +++ b/packages/provider-fx/src/server/acpSupport.ts @@ -10,6 +10,8 @@ import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import type * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; import type * as Scope from "effect/Scope"; import type * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import * as EffectAcpErrors from "effect-acp/errors"; @@ -57,6 +59,47 @@ export const FX_SUPPORTED_RUNTIME_MODES = [ /** fx 0.0.13 answers a cancelled prompt with `stopReason: cancelled`. */ export const FX_ACP_CANCEL_BEHAVIOR = "wait-for-prompt" as const; +const isCancelledFxCommand = Schema.is( + Schema.Struct({ + update: Schema.Struct({ + command_result: Schema.Struct({ + exit_code: Schema.Null, + signal: Schema.Null, + timed_out: Schema.Literal(false), + termination_indeterminate: Schema.Literal(true), + }), + }), + }), +); +const decodeFxToolText = Schema.decodeUnknownOption( + Schema.Struct({ + update: Schema.Struct({ + content: Schema.Array(Schema.Struct({ content: Schema.Struct({ text: Schema.String }) })), + }), + }), +); +const decodeFxCancelledError = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + error: Schema.Struct({ details: Schema.Struct({ error: Schema.Literal("Cancelled") }) }), + }), + ), +); + +export function fxToolCallWasCancelled(payload: unknown): boolean { + const update = decodeFxToolText(payload); + const texts = Option.isSome(update) + ? update.value.update.content.map(({ content }) => content.text) + : []; + if ( + texts.some((text) => /^Cancelled\.?$/.test(text) || Option.isSome(decodeFxCancelledError(text))) + ) { + return true; + } + // fx also uses indeterminate status for real failures, whose error JSON can be clipped. + return isCancelledFxCommand(payload) && texts.every((text) => !/^\s*\{\s*"error"\s*:/.test(text)); +} + /** * Supervised runs fx's `ask` mode and Auto its automatic review. An explicit * approval or sandbox policy must not launch automatic review, which would diff --git a/packages/provider-fx/src/server/adapter.ts b/packages/provider-fx/src/server/adapter.ts index 433f800035400..73b866ba2d9d4 100644 --- a/packages/provider-fx/src/server/adapter.ts +++ b/packages/provider-fx/src/server/adapter.ts @@ -20,6 +20,7 @@ import { applyFxModelSelection, FX_DRIVER_KIND, fxPermissionDisposition, + fxToolCallWasCancelled, fxSessionModeForPolicy, makeFxAcpRuntime, withFxSessionMode, @@ -202,6 +203,7 @@ function makeFxAcpAdapterFlavor(options: FxAdapterV2Options): AcpAdapterV2Flavor sessionModeForPolicy: fxSessionModeForPolicy, permissionDisposition: fxPermissionDisposition, extractSubagentUpdate: extractFxSubagentUpdate, + isCancelledToolCall: fxToolCallWasCancelled, ...(options.assertComplete === undefined ? {} : { assertComplete: options.assertComplete }), }; } diff --git a/packages/provider-fx/src/server/maintenance.ts b/packages/provider-fx/src/server/maintenance.ts index 20452f755110e..1899e64387248 100644 --- a/packages/provider-fx/src/server/maintenance.ts +++ b/packages/provider-fx/src/server/maintenance.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics nodeBuiltinImport:off -- fx's macOS upgrade gate needs the host kernel release. import * as NodeOS from "node:os"; import * as Effect from "effect/Effect"; import * as HttpClient from "effect/http/HttpClient"; From 7ad71f343e5fd2122498aab50c8b505152fc0cd1 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Mon, 2 Nov 2026 11:21:34 -0500 Subject: [PATCH 07/17] fix(fx): update Homebrew installs through their owning brew --- docs/user/providers-fx.md | 4 +- .../src/server/maintenance.test.ts | 83 +++++++++++++++++++ .../provider-fx/src/server/maintenance.ts | 51 ++++++------ 3 files changed, 113 insertions(+), 25 deletions(-) diff --git a/docs/user/providers-fx.md b/docs/user/providers-fx.md index 0dec44680c0a5..9b7da467f2257 100644 --- a/docs/user/providers-fx.md +++ b/docs/user/providers-fx.md @@ -21,7 +21,9 @@ Every fx instance on the same machine shares that machine's fx login. T3 Code do ## Update fx -When a newer fx release is out, the fx card in **Settings → Providers** shows it. **Update now** runs `fx upgrade` on the binary that instance uses, including one set in **Binary path**. You can also run `fx upgrade` on that machine yourself. T3 Code checks for new stable releases only; on fx's dev channel, run `fx upgrade` yourself to get newer dev builds. On macOS 14 or earlier, which newer fx releases do not support, T3 Code does not offer fx updates. +When a newer fx release is out, the fx card in **Settings → Providers** shows it. For standalone installs, **Update now** runs the instance's selected executable with `upgrade`, including one set in **Binary path**. For Homebrew's `fx-agent` formula, it uses the owning Homebrew installation to run `brew upgrade fx-agent` and checks Homebrew's release version. If that ownership cannot be confirmed, update through your package manager yourself. + +To update a standalone install manually, run `fx upgrade` on the server's machine. If **Binary path** is set, run that exact executable instead, for example `"/path/to/fx" upgrade`. Standalone version checks cover stable releases only; use the same manual command to get newer dev builds on fx's dev channel. On macOS 14 or earlier, which newer fx releases do not support, T3 Code does not offer fx updates. ## Models and effort diff --git a/packages/provider-fx/src/server/maintenance.test.ts b/packages/provider-fx/src/server/maintenance.test.ts index 044049bd4db8e..a8599b5d937ad 100644 --- a/packages/provider-fx/src/server/maintenance.test.ts +++ b/packages/provider-fx/src/server/maintenance.test.ts @@ -2,6 +2,12 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { expect, it } from "@effect/vitest"; import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; +import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; @@ -27,6 +33,83 @@ const serving = (response: Response) => HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, response))); it.layer(NodeServices.layer)("fx maintenance", (it) => { + it.effect("uses the owning Homebrew installation and otherwise stays manual-only", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const prefix = yield* fs + .makeTempDirectoryScoped({ prefix: "t3-fx-brew-" }) + .pipe(Effect.flatMap((directory) => fs.realPath(directory))); + const brewPath = path.join(prefix, "bin", "brew"); + yield* fs.makeDirectory(path.dirname(brewPath), { recursive: true }); + yield* fs.writeFileString(brewPath, ""); + let reportedPrefix = prefix; + const spawner = ChildProcessSpawner.make((command) => { + if (command._tag !== "StandardCommand") return Effect.die("Expected a brew probe"); + const output = + command.args[0] === "--prefix" + ? reportedPrefix + : JSON.stringify({ formulae: [{ versions: { stable: "0.0.14" } }] }); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText(Stream.make(output)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }); + const context = { + binaryPath: path.join(prefix, "bin", "fx"), + resolvedCommandPath: path.join(prefix, "bin", "fx"), + realCommandPath: path.join(prefix, "Cellar", "fx-agent", "0.0.13", "bin", "fx"), + platform: "darwin" as const, + env: { PATH: "" }, + }; + const resolver = makeFxMaintenance( + Effect.die("Homebrew must not use fx's release channel"), + "24.0.0", + ); + const capabilities = yield* resolver + .resolve(context) + .pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(HostProcess.Platform, "darwin"), + ); + expect(capabilities.update).toMatchObject({ + executable: brewPath, + args: ["upgrade", "fx-agent"], + lockKey: "homebrew", + }); + expect(yield* capabilities.lookupLatestVersion!).toBe("0.0.14"); + + reportedPrefix = path.join(prefix, "another-installation"); + expect( + (yield* resolver + .resolve(context) + .pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(HostProcess.Platform, "darwin"), + )).update, + ).toBeNull(); + yield* fs.remove(brewPath); + expect((yield* resolver.resolve(context)).update).toBeNull(); + expect( + (yield* resolver.resolve({ + ...context, + realCommandPath: path.join(prefix, "Cellar", "mise", "1.0.0", "bin", "fx"), + })).update, + ).toBeNull(); + }).pipe(Effect.scoped), + ); + it.effect("offers `fx upgrade` on the instance's own binary once a newer release is out", () => Effect.gen(function* () { const binaryPath = "/opt/tools/custom fx"; diff --git a/packages/provider-fx/src/server/maintenance.ts b/packages/provider-fx/src/server/maintenance.ts index 1899e64387248..0d801312b4ee5 100644 --- a/packages/provider-fx/src/server/maintenance.ts +++ b/packages/provider-fx/src/server/maintenance.ts @@ -5,8 +5,10 @@ import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; import { + homebrewOwnershipFromCommandPath, makeManualOnlyProviderMaintenanceCapabilities, makeProviderMaintenanceCapabilities, + resolveHomebrewProviderMaintenance, type ProviderMaintenanceCapabilitiesResolver, } from "@t3tools/provider-core/server/maintenanceResolver"; import { FX_DRIVER_KIND } from "./acpSupport.ts"; @@ -26,32 +28,33 @@ export const fetchLatestFxVersion = Effect.fn("fetchLatestFxVersion")(function* ); }); -/** - * `fx upgrade` replaces the executable it runs from, so the update runs the - * instance's resolved binary, wherever `binaryPath` points. fx after 0.0.13 - * needs macOS 15 (Darwin 24) and `fx upgrade` does not check, so older Macs - * get no update offer or latest-version check. - */ +// fx after 0.0.13 needs Darwin 24, but its updater does not check the host version. export const makeFxMaintenance = ( lookupLatestVersion: Effect.Effect, osRelease: string = NodeOS.release(), ): ProviderMaintenanceCapabilitiesResolver => ({ - resolve: (context) => - Effect.succeed( - context === null || (context.platform === "darwin" && Number.parseInt(osRelease, 10) < 24) - ? makeManualOnlyProviderMaintenanceCapabilities({ - provider: FX_DRIVER_KIND, - packageName: null, - }) - : makeProviderMaintenanceCapabilities({ - provider: FX_DRIVER_KIND, - packageName: null, - updateExecutable: context.resolvedCommandPath, - updateArgs: ["upgrade"], - updateLockKey: `fx:${context.realCommandPath}`, - platform: context.platform, - env: context.env, - lookupLatestVersion, - }), - ), + resolve: Effect.fn("FxMaintenance.resolve")(function* (context) { + const input = { provider: FX_DRIVER_KIND, packageName: null }; + const manualOnly = makeManualOnlyProviderMaintenanceCapabilities(input); + if ( + context === null || + (context.platform === "darwin" && Number.parseInt(osRelease, 10) < 24) + ) { + return manualOnly; + } + const homebrew = homebrewOwnershipFromCommandPath(context.realCommandPath); + if (homebrew) { + if (homebrew.kind !== "formula" || homebrew.name !== "fx-agent") return manualOnly; + return (yield* resolveHomebrewProviderMaintenance(input, homebrew, context)) ?? manualOnly; + } + return makeProviderMaintenanceCapabilities({ + ...input, + updateExecutable: context.resolvedCommandPath, + updateArgs: ["upgrade"], + updateLockKey: `fx:${context.realCommandPath}`, + platform: context.platform, + env: context.env, + lookupLatestVersion, + }); + }), }); From 744d008d73ff57fd32fda51619dc55f8a8c16238 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Mon, 2 Nov 2026 13:02:43 -0500 Subject: [PATCH 08/17] fix(fx): leave mise-managed updates to mise --- .../src/server/maintenanceResolver.ts | 4 +-- .../src/server/maintenance.test.ts | 34 ++++++++++++++++++- .../provider-fx/src/server/maintenance.ts | 9 +++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts index 1810fff9a52c4..1ee5a53096526 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -245,7 +245,7 @@ function isYarnGlobalCommandPath(commandPath: string): boolean { * Version-manager installs are pinned in its config, so updating them means editing that. * A command wrapper is a symlink to the `mise` binary, which dispatches on argv[0]. */ -function isMiseCommandPath(commandPath: string): boolean { +export function isMiseCommandPath(commandPath: string): boolean { const normalized = normalizeCommandPath(commandPath); return ( normalized.includes("/mise/installs/") || @@ -708,7 +708,7 @@ export const resolveHomebrewProviderMaintenance = Effect.fn("resolveHomebrewProv }, ); -const isMiseWrapperScript = Effect.fn("isMiseWrapperScript")(function* ( +export const isMiseWrapperScript = Effect.fn("isMiseWrapperScript")(function* ( context: ProviderMaintenanceResolutionContext, ) { const fileSystem = yield* FileSystem.FileSystem; diff --git a/packages/provider-fx/src/server/maintenance.test.ts b/packages/provider-fx/src/server/maintenance.test.ts index a8599b5d937ad..53f343b7b4f30 100644 --- a/packages/provider-fx/src/server/maintenance.test.ts +++ b/packages/provider-fx/src/server/maintenance.test.ts @@ -146,7 +146,39 @@ it.layer(NodeServices.layer)("fx maintenance", (it) => { canUpdate: true, updateCommand: `'${binaryPath}' upgrade`, }); - }), + + const resolver = makeFxMaintenance(Effect.succeed("0.0.15"), "24.0.0"); + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-fx-mise-" }); + const wrapperPath = path.join(directory, "fx"); + for (const managedPath of [ + "/home/fx/.local/share/mise/installs/fx/0.0.15/bin/fx", + "/home/fx/.local/share/mise/shims/fx", + "/home/fx/.local/share/mise/command-wrappers/fx", + ]) { + for (const paths of [ + { resolvedCommandPath: managedPath, realCommandPath: binaryPath }, + { resolvedCommandPath: binaryPath, realCommandPath: managedPath }, + ]) { + expect( + (yield* resolver.resolve({ binaryPath, ...paths, platform: "linux", env: {} })).update, + ).toBeNull(); + } + } + for (const command of ["x", "exec"]) { + yield* fs.writeFileString(wrapperPath, `#!/bin/sh\nexec mise ${command} -- fx "$@"\n`); + expect( + (yield* resolver.resolve({ + binaryPath: wrapperPath, + resolvedCommandPath: wrapperPath, + realCommandPath: wrapperPath, + platform: "linux", + env: {}, + })).update, + ).toBeNull(); + } + }).pipe(Effect.scoped), ); it.effect("leaves the latest version unknown when the release file is unusable", () => diff --git a/packages/provider-fx/src/server/maintenance.ts b/packages/provider-fx/src/server/maintenance.ts index 0d801312b4ee5..8b6f112ba4f63 100644 --- a/packages/provider-fx/src/server/maintenance.ts +++ b/packages/provider-fx/src/server/maintenance.ts @@ -6,6 +6,8 @@ import * as HttpClientRequest from "effect/http/HttpClientRequest"; import { homebrewOwnershipFromCommandPath, + isMiseCommandPath, + isMiseWrapperScript, makeManualOnlyProviderMaintenanceCapabilities, makeProviderMaintenanceCapabilities, resolveHomebrewProviderMaintenance, @@ -42,6 +44,13 @@ export const makeFxMaintenance = ( ) { return manualOnly; } + if ( + isMiseCommandPath(context.resolvedCommandPath) || + isMiseCommandPath(context.realCommandPath) || + (yield* isMiseWrapperScript(context)) + ) { + return manualOnly; + } const homebrew = homebrewOwnershipFromCommandPath(context.realCommandPath); if (homebrew) { if (homebrew.kind !== "formula" || homebrew.name !== "fx-agent") return manualOnly; From 97acf7d65956fe8ffbb3464346a2b7d416362297 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Mon, 2 Nov 2026 13:45:37 -0500 Subject: [PATCH 09/17] fix(fx): detect configured mise roots and retain advisories --- docs/user/providers-fx.md | 2 +- .../src/server/maintenanceResolver.test.ts | 16 +++- .../src/server/maintenanceResolver.ts | 34 +++++++- .../src/server/maintenance.test.ts | 80 +++++++++++++++---- .../provider-fx/src/server/maintenance.ts | 10 ++- 5 files changed, 114 insertions(+), 28 deletions(-) diff --git a/docs/user/providers-fx.md b/docs/user/providers-fx.md index 9b7da467f2257..ee77d2773632c 100644 --- a/docs/user/providers-fx.md +++ b/docs/user/providers-fx.md @@ -21,7 +21,7 @@ Every fx instance on the same machine shares that machine's fx login. T3 Code do ## Update fx -When a newer fx release is out, the fx card in **Settings → Providers** shows it. For standalone installs, **Update now** runs the instance's selected executable with `upgrade`, including one set in **Binary path**. For Homebrew's `fx-agent` formula, it uses the owning Homebrew installation to run `brew upgrade fx-agent` and checks Homebrew's release version. If that ownership cannot be confirmed, update through your package manager yourself. +When a newer fx release is out, the fx card in **Settings → Providers** shows it. For standalone installs, **Update now** runs the instance's selected executable with `upgrade`, including one set in **Binary path**. For Homebrew's `fx-agent` formula, it uses the owning Homebrew installation to run `brew upgrade fx-agent` and checks Homebrew's release version. If that ownership cannot be confirmed, update through your package manager yourself. For mise-managed installs, T3 Code checks stable releases but leaves updates to your mise configuration. To update a standalone install manually, run `fx upgrade` on the server's machine. If **Binary path** is set, run that exact executable instead, for example `"/path/to/fx" upgrade`. Standalone version checks cover stable releases only; use the same manual command to get newer dev builds on fx's dev channel. On macOS 14 or earlier, which newer fx releases do not support, T3 Code does not offer fx updates. diff --git a/packages/provider-core/src/server/maintenanceResolver.test.ts b/packages/provider-core/src/server/maintenanceResolver.test.ts index 7883d827d4e64..02be0ed766584 100644 --- a/packages/provider-core/src/server/maintenanceResolver.test.ts +++ b/packages/provider-core/src/server/maintenanceResolver.test.ts @@ -1090,10 +1090,10 @@ it.layer( }), ); - it.effect.each(["wrapper", "symlink"])( + it.effect.each(["wrapper", "symlink", "custom-data"])( "keeps a mise-owned %s in a native install location manual-only", (launcher, { skip }) => { - if (windowsHost || (launcher === "symlink" && !symlinksSupported)) skip(); + if (windowsHost || (launcher !== "wrapper" && !symlinksSupported)) skip(); return Effect.gen(function* () { const tempDir = yield* makeTempDir("t3-native-mise"); const nativePath = NodePath.join(tempDir, ".local", "bin", "native-package-tool"); @@ -1106,7 +1106,7 @@ it.layer( } else { const target = NodePath.join( tempDir, - "mise", + launcher === "custom-data" ? "managed-tools" : "mise", "installs", "native-package-tool", "1.0.0", @@ -1119,7 +1119,15 @@ it.layer( const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( nativePackageToolUpdate, - { binaryPath: nativePath, env: { PATH: "" } }, + { + binaryPath: nativePath, + env: { + PATH: "", + ...(launcher === "custom-data" + ? { MISE_DATA_DIR: NodePath.join(tempDir, "managed-tools") } + : {}), + }, + }, ).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn)); expect(capabilities.update).toBeNull(); diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts index 1ee5a53096526..3fe6d1cb00d49 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -245,13 +245,36 @@ function isYarnGlobalCommandPath(commandPath: string): boolean { * Version-manager installs are pinned in its config, so updating them means editing that. * A command wrapper is a symlink to the `mise` binary, which dispatches on argv[0]. */ -export function isMiseCommandPath(commandPath: string): boolean { +export function isMiseCommandPath(commandPath: string, env?: NodeJS.ProcessEnv): boolean { const normalized = normalizeCommandPath(commandPath); + const dataDirectory = env?.MISE_DATA_DIR; + const systemDataDirectory = env?.MISE_SYSTEM_DATA_DIR; + const roots = [ + ...(dataDirectory + ? ["installs", "i", "shims", "command-wrappers"].map( + (directory) => `${dataDirectory.replace(/[\\/]+$/, "")}/${directory}`, + ) + : []), + ...(systemDataDirectory + ? ["installs", "shims"].map( + (directory) => `${systemDataDirectory.replace(/[\\/]+$/, "")}/${directory}`, + ) + : []), + env?.MISE_INSTALLS_DIR, + env?.MISE_INSTALL_STORE_DIR, + env?.MISE_SHIMS_DIR, + env?.MISE_SYSTEM_INSTALLS_DIR, + env?.MISE_SYSTEM_SHIMS_DIR, + ]; return ( normalized.includes("/mise/installs/") || + normalized.includes("/mise/i/") || normalized.includes("/mise/shims/") || normalized.includes("/mise/command-wrappers/") || - /\/mise(?:\.exe)?$/.test(normalized) + /\/mise(?:\.exe)?$/.test(normalized) || + roots.some( + (root) => root && normalized.startsWith(`${normalizeCommandPath(root).replace(/\/+$/, "")}/`), + ) ); } @@ -481,6 +504,9 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( installPath: context.realCommandPath, }); const commandPaths = [context.resolvedCommandPath, context.realCommandPath]; + const miseManaged = commandPaths.some((commandPath) => + isMiseCommandPath(commandPath, context.env), + ); const packageName = definition.npmPackageName; // WinGet-managed claude update exits 0 without updating; T3 does not run winget upgrade. if ( @@ -536,7 +562,7 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( if ( nativeUpdate?.isCommandPath && commandPaths.some(nativeUpdate.isCommandPath) && - !commandPaths.some(isMiseCommandPath) && + !miseManaged && !(yield* isMiseWrapperScript(context)) ) { return native; @@ -633,7 +659,7 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( const homebrew = homebrewOwnershipFromCommandPath(context.realCommandPath); if ( - commandPaths.some(isMiseCommandPath) || + miseManaged || // Mise shims resolve to the version manager's keg, not the provider's. (homebrew?.kind === "formula" && homebrew.name.toLowerCase() === "mise") || (yield* isMiseWrapperScript(context)) diff --git a/packages/provider-fx/src/server/maintenance.test.ts b/packages/provider-fx/src/server/maintenance.test.ts index 53f343b7b4f30..f5886f832a619 100644 --- a/packages/provider-fx/src/server/maintenance.test.ts +++ b/packages/provider-fx/src/server/maintenance.test.ts @@ -152,32 +152,80 @@ it.layer(NodeServices.layer)("fx maintenance", (it) => { const path = yield* Path.Path; const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-fx-mise-" }); const wrapperPath = path.join(directory, "fx"); - for (const managedPath of [ - "/home/fx/.local/share/mise/installs/fx/0.0.15/bin/fx", - "/home/fx/.local/share/mise/shims/fx", - "/home/fx/.local/share/mise/command-wrappers/fx", + for (const { managedPath, env } of [ + { managedPath: "/home/fx/.local/share/mise/installs/fx/0.0.15/bin/fx", env: {} }, + { managedPath: "/home/fx/.local/share/mise/shims/fx", env: {} }, + { managedPath: "/home/fx/.local/share/mise/command-wrappers/fx", env: {} }, + { + managedPath: "/opt/tools/installs/fx/0.0.15/bin/fx", + env: { MISE_DATA_DIR: "/opt/tools/" }, + }, + { + managedPath: "/opt/versions/fx/0.0.15/bin/fx", + env: { MISE_INSTALLS_DIR: "/opt/versions" }, + }, + { managedPath: "/opt/store/fx-abc/bin/fx", env: { MISE_INSTALL_STORE_DIR: "/opt/store" } }, + { managedPath: "/opt/dispatch/fx", env: { MISE_SHIMS_DIR: "/opt/dispatch" } }, + { + managedPath: "/opt/system/installs/fx/0.0.15/bin/fx", + env: { MISE_SYSTEM_DATA_DIR: "/opt/system" }, + }, + { + managedPath: "/opt/system-installs/fx/0.0.15/bin/fx", + env: { MISE_SYSTEM_INSTALLS_DIR: "/opt/system-installs" }, + }, + { + managedPath: "/opt/system-shims/fx", + env: { MISE_SYSTEM_SHIMS_DIR: "/opt/system-shims" }, + }, ]) { for (const paths of [ { resolvedCommandPath: managedPath, realCommandPath: binaryPath }, { resolvedCommandPath: binaryPath, realCommandPath: managedPath }, ]) { - expect( - (yield* resolver.resolve({ binaryPath, ...paths, platform: "linux", env: {} })).update, - ).toBeNull(); + const managed = yield* resolver.resolve({ binaryPath, ...paths, platform: "linux", env }); + expect(managed.update).toBeNull(); + const managedSnapshot = yield* enrichProviderSnapshotWithVersionAdvisory( + snapshot, + managed, + { + enableProviderUpdateChecks: true, + }, + ).pipe( + Effect.provideService(HttpClient.HttpClient, serving(new Response("unused"))), + Effect.provide(ProviderLatestVersions.layer), + ); + expect(managedSnapshot.versionAdvisory).toMatchObject({ + status: "behind_latest", + latestVersion: "0.0.15", + canUpdate: false, + }); + expect(managed.installPath).toBe(paths.realCommandPath); } } for (const command of ["x", "exec"]) { yield* fs.writeFileString(wrapperPath, `#!/bin/sh\nexec mise ${command} -- fx "$@"\n`); - expect( - (yield* resolver.resolve({ - binaryPath: wrapperPath, - resolvedCommandPath: wrapperPath, - realCommandPath: wrapperPath, - platform: "linux", - env: {}, - })).update, - ).toBeNull(); + const managed = yield* resolver.resolve({ + binaryPath: wrapperPath, + resolvedCommandPath: wrapperPath, + realCommandPath: wrapperPath, + platform: "linux", + env: {}, + }); + expect(managed.update).toBeNull(); + expect(managed.installPath).toBe(wrapperPath); + expect(yield* managed.lookupLatestVersion!).toBe("0.0.15"); } + const outsideMise = "/opt/tools-extra/installs/fx/0.0.15/bin/fx"; + expect( + (yield* resolver.resolve({ + binaryPath: outsideMise, + resolvedCommandPath: outsideMise, + realCommandPath: outsideMise, + platform: "linux", + env: { MISE_DATA_DIR: "/opt/tools" }, + })).update, + ).not.toBeNull(); }).pipe(Effect.scoped), ); diff --git a/packages/provider-fx/src/server/maintenance.ts b/packages/provider-fx/src/server/maintenance.ts index 8b6f112ba4f63..d4297c7720483 100644 --- a/packages/provider-fx/src/server/maintenance.ts +++ b/packages/provider-fx/src/server/maintenance.ts @@ -45,11 +45,15 @@ export const makeFxMaintenance = ( return manualOnly; } if ( - isMiseCommandPath(context.resolvedCommandPath) || - isMiseCommandPath(context.realCommandPath) || + isMiseCommandPath(context.resolvedCommandPath, context.env) || + isMiseCommandPath(context.realCommandPath, context.env) || (yield* isMiseWrapperScript(context)) ) { - return manualOnly; + return makeManualOnlyProviderMaintenanceCapabilities({ + ...input, + installPath: context.realCommandPath, + lookupLatestVersion, + }); } const homebrew = homebrewOwnershipFromCommandPath(context.realCommandPath); if (homebrew) { From efd23b13c41d63efdcc6b4fca8c127214ca488ec Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Mon, 2 Nov 2026 14:20:25 -0500 Subject: [PATCH 10/17] refactor(fx): read kernel release through the host context --- .../src/server/maintenance.test.ts | 44 ++++++++++++------- .../provider-fx/src/server/maintenance.ts | 6 +-- packages/shared/src/HostProcess.ts | 7 +++ 3 files changed, 37 insertions(+), 20 deletions(-) diff --git a/packages/provider-fx/src/server/maintenance.test.ts b/packages/provider-fx/src/server/maintenance.test.ts index f5886f832a619..33ca744cf3f4d 100644 --- a/packages/provider-fx/src/server/maintenance.test.ts +++ b/packages/provider-fx/src/server/maintenance.test.ts @@ -3,6 +3,7 @@ import { expect, it } from "@effect/vitest"; import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; 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 Sink from "effect/Sink"; import * as Stream from "effect/Stream"; @@ -32,7 +33,12 @@ const snapshot: ServerProvider = { const serving = (response: Response) => HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, response))); -it.layer(NodeServices.layer)("fx maintenance", (it) => { +const maintenanceLayer = Layer.merge( + NodeServices.layer, + Layer.succeed(HostProcess.KernelRelease, "24.0.0"), +); + +it.layer(maintenanceLayer)("fx maintenance", (it) => { it.effect("uses the owning Homebrew installation and otherwise stays manual-only", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -73,10 +79,7 @@ it.layer(NodeServices.layer)("fx maintenance", (it) => { platform: "darwin" as const, env: { PATH: "" }, }; - const resolver = makeFxMaintenance( - Effect.die("Homebrew must not use fx's release channel"), - "24.0.0", - ); + const resolver = makeFxMaintenance(Effect.die("Homebrew must not use fx's release channel")); const capabilities = yield* resolver .resolve(context) .pipe( @@ -117,7 +120,6 @@ it.layer(NodeServices.layer)("fx maintenance", (it) => { fetchLatestFxVersion().pipe( Effect.provideService(HttpClient.HttpClient, serving(new Response("v0.0.15\n"))), ), - "24.0.0", ).resolve({ binaryPath, resolvedCommandPath: binaryPath, @@ -147,7 +149,7 @@ it.layer(NodeServices.layer)("fx maintenance", (it) => { updateCommand: `'${binaryPath}' upgrade`, }); - const resolver = makeFxMaintenance(Effect.succeed("0.0.15"), "24.0.0"); + const resolver = makeFxMaintenance(Effect.succeed("0.0.15")); const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-fx-mise-" }); @@ -164,7 +166,10 @@ it.layer(NodeServices.layer)("fx maintenance", (it) => { managedPath: "/opt/versions/fx/0.0.15/bin/fx", env: { MISE_INSTALLS_DIR: "/opt/versions" }, }, - { managedPath: "/opt/store/fx-abc/bin/fx", env: { MISE_INSTALL_STORE_DIR: "/opt/store" } }, + { + managedPath: "/opt/store/fx-abc/bin/fx", + env: { MISE_INSTALL_STORE_DIR: "/opt/store" }, + }, { managedPath: "/opt/dispatch/fx", env: { MISE_SHIMS_DIR: "/opt/dispatch" } }, { managedPath: "/opt/system/installs/fx/0.0.15/bin/fx", @@ -183,7 +188,12 @@ it.layer(NodeServices.layer)("fx maintenance", (it) => { { resolvedCommandPath: managedPath, realCommandPath: binaryPath }, { resolvedCommandPath: binaryPath, realCommandPath: managedPath }, ]) { - const managed = yield* resolver.resolve({ binaryPath, ...paths, platform: "linux", env }); + const managed = yield* resolver.resolve({ + binaryPath, + ...paths, + platform: "linux", + env, + }); expect(managed.update).toBeNull(); const managedSnapshot = yield* enrichProviderSnapshotWithVersionAdvisory( snapshot, @@ -247,13 +257,15 @@ it.layer(NodeServices.layer)("fx maintenance", (it) => { it.effect("offers no update or latest version on macOS 14, which newer fx cannot run on", () => Effect.gen(function* () { const binaryPath = "/opt/tools/fx"; - const capabilities = yield* makeFxMaintenance(Effect.succeed("0.0.15"), "23.6.0").resolve({ - binaryPath, - resolvedCommandPath: binaryPath, - realCommandPath: binaryPath, - platform: "darwin", - env: {}, - }); + const capabilities = yield* makeFxMaintenance(Effect.succeed("0.0.15")) + .resolve({ + binaryPath, + resolvedCommandPath: binaryPath, + realCommandPath: binaryPath, + platform: "darwin", + env: {}, + }) + .pipe(Effect.provideService(HostProcess.KernelRelease, "23.6.0")); expect(capabilities.update).toBeNull(); expect(capabilities.lookupLatestVersion).toBeUndefined(); }), diff --git a/packages/provider-fx/src/server/maintenance.ts b/packages/provider-fx/src/server/maintenance.ts index d4297c7720483..ce6c0a8861ad3 100644 --- a/packages/provider-fx/src/server/maintenance.ts +++ b/packages/provider-fx/src/server/maintenance.ts @@ -1,5 +1,4 @@ -// @effect-diagnostics nodeBuiltinImport:off -- fx's macOS upgrade gate needs the host kernel release. -import * as NodeOS from "node:os"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Effect from "effect/Effect"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; @@ -33,14 +32,13 @@ export const fetchLatestFxVersion = Effect.fn("fetchLatestFxVersion")(function* // fx after 0.0.13 needs Darwin 24, but its updater does not check the host version. export const makeFxMaintenance = ( lookupLatestVersion: Effect.Effect, - osRelease: string = NodeOS.release(), ): ProviderMaintenanceCapabilitiesResolver => ({ resolve: Effect.fn("FxMaintenance.resolve")(function* (context) { const input = { provider: FX_DRIVER_KIND, packageName: null }; const manualOnly = makeManualOnlyProviderMaintenanceCapabilities(input); if ( context === null || - (context.platform === "darwin" && Number.parseInt(osRelease, 10) < 24) + (context.platform === "darwin" && Number.parseInt(yield* HostProcess.KernelRelease, 10) < 24) ) { return manualOnly; } diff --git a/packages/shared/src/HostProcess.ts b/packages/shared/src/HostProcess.ts index f24365cd5c81f..7d287014dd42f 100644 --- a/packages/shared/src/HostProcess.ts +++ b/packages/shared/src/HostProcess.ts @@ -15,6 +15,13 @@ export const Architecture = Context.Reference( }, ); +export const KernelRelease = Context.Reference( + "@t3tools/shared/HostProcess/KernelRelease", + { + defaultValue: () => NodeOS.release(), + }, +); + export const Hostname = Context.Reference("@t3tools/shared/HostProcess/Hostname", { defaultValue: () => NodeOS.hostname(), }); From 30e993f32c8c0b9b8cc6ddca5dc77d836f720e67 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Mon, 2 Nov 2026 14:35:04 -0500 Subject: [PATCH 11/17] fix(fx): use the first tool result when detecting cancellation --- .../provider-fx/src/server/acpSupport.test.ts | 36 ++++++++++++++----- packages/provider-fx/src/server/acpSupport.ts | 4 ++- 2 files changed, 30 insertions(+), 10 deletions(-) diff --git a/packages/provider-fx/src/server/acpSupport.test.ts b/packages/provider-fx/src/server/acpSupport.test.ts index 2785814b73728..2993e9c8afd3b 100644 --- a/packages/provider-fx/src/server/acpSupport.test.ts +++ b/packages/provider-fx/src/server/acpSupport.test.ts @@ -34,15 +34,29 @@ describe("fx permission prompts", () => { describe("fx cancellation failures", () => { it.each([ - { content: "Cancelled", cancelled: true }, - { content: "Cancelled.", cancelled: true }, - { content: '{"error":{"details":{"error":"Cancelled"}}}', cancelled: true }, - { content: '{"error":{"details":{"error":"Permission denied"}}}', cancelled: false }, - { content: "Command failed", cancelled: false }, - { content: "", cancelled: false }, + { content: ["Cancelled"], cancelled: true }, + { content: ["Cancelled."], cancelled: true }, + { content: ['{"error":{"details":{"error":"Cancelled"}}}'], cancelled: true }, + { content: ['{"error":{"details":{"error":"Permission denied"}}}'], cancelled: false }, + { content: ["Command failed"], cancelled: false }, + { content: [""], cancelled: false }, + { + content: ['{"error":{"details":{"error":"Permission denied"}}}', "Cancelled"], + cancelled: false, + }, + { + content: ["Command failed", '{"error":{"details":{"error":"Cancelled"}}}'], + cancelled: false, + }, + { + content: ["Cancelled", '{"error":{"details":{"error":"Permission denied"}}}'], + cancelled: true, + }, ])("recognizes $content as cancellation: $cancelled", ({ content, cancelled }) => { assert.equal( - fxToolCallWasCancelled({ update: { content: [{ content: { text: content } }] } }), + fxToolCallWasCancelled({ + update: { content: content.map((text) => ({ content: { text } })) }, + }), cancelled, ); }); @@ -63,6 +77,7 @@ describe("fx cancellation failures", () => { termination_indeterminate: true, content: '{"error":{"type":"tool_execution_failed","message":"Command started, but its final process status could not be confirmed",', + followingContent: ["Cancelled"], cancelled: false, }, { @@ -83,10 +98,13 @@ describe("fx cancellation failures", () => { }, ])( "recognizes a command result with exit $exit_code, timeout $timed_out", - ({ cancelled, content, ...commandResult }) => { + ({ cancelled, content, followingContent = [], ...commandResult }) => { assert.equal( fxToolCallWasCancelled({ - update: { command_result: commandResult, content: [{ content: { text: content } }] }, + update: { + command_result: commandResult, + content: [content, ...followingContent].map((text) => ({ content: { text } })), + }, }), cancelled, ); diff --git a/packages/provider-fx/src/server/acpSupport.ts b/packages/provider-fx/src/server/acpSupport.ts index c9b56323fd7dc..6a6df07bb010a 100644 --- a/packages/provider-fx/src/server/acpSupport.ts +++ b/packages/provider-fx/src/server/acpSupport.ts @@ -91,8 +91,10 @@ export function fxToolCallWasCancelled(payload: unknown): boolean { const texts = Option.isSome(update) ? update.value.update.content.map(({ content }) => content.text) : []; + const firstText = texts[0]; if ( - texts.some((text) => /^Cancelled\.?$/.test(text) || Option.isSome(decodeFxCancelledError(text))) + firstText !== undefined && + (/^Cancelled\.?$/.test(firstText) || Option.isSome(decodeFxCancelledError(firstText))) ) { return true; } From 336badcbb6a09249c4dd6133ffcc59b8a7b0afcf Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Mon, 2 Nov 2026 14:57:17 -0500 Subject: [PATCH 12/17] fix(fx): retain errors across mixed tool content --- .../provider-fx/src/server/acpSupport.test.ts | 65 ++++++++++++++++--- packages/provider-fx/src/server/acpSupport.ts | 20 ++++-- 2 files changed, 68 insertions(+), 17 deletions(-) diff --git a/packages/provider-fx/src/server/acpSupport.test.ts b/packages/provider-fx/src/server/acpSupport.test.ts index 2993e9c8afd3b..d6d5dc2bb35e0 100644 --- a/packages/provider-fx/src/server/acpSupport.test.ts +++ b/packages/provider-fx/src/server/acpSupport.test.ts @@ -33,6 +33,9 @@ describe("fx permission prompts", () => { }); describe("fx cancellation failures", () => { + const diff = { type: "diff", path: "notes.txt", oldText: "old", newText: "new" }; + const terminal = { type: "terminal", terminalId: "fx-terminal" }; + it.each([ { content: ["Cancelled"], cancelled: true }, { content: ["Cancelled."], cancelled: true }, @@ -40,6 +43,8 @@ describe("fx cancellation failures", () => { { content: ['{"error":{"details":{"error":"Permission denied"}}}'], cancelled: false }, { content: ["Command failed"], cancelled: false }, { content: [""], cancelled: false }, + { content: ["Cancelled"], followingItems: [diff], cancelled: true }, + { content: ["Cancelled"], precedingItems: [diff], cancelled: false }, { content: ['{"error":{"details":{"error":"Permission denied"}}}', "Cancelled"], cancelled: false, @@ -52,14 +57,23 @@ describe("fx cancellation failures", () => { content: ["Cancelled", '{"error":{"details":{"error":"Permission denied"}}}'], cancelled: true, }, - ])("recognizes $content as cancellation: $cancelled", ({ content, cancelled }) => { - assert.equal( - fxToolCallWasCancelled({ - update: { content: content.map((text) => ({ content: { text } })) }, - }), - cancelled, - ); - }); + ])( + "recognizes $content as cancellation: $cancelled", + ({ content, cancelled, precedingItems = [], followingItems = [] }) => { + assert.equal( + fxToolCallWasCancelled({ + update: { + content: [ + ...precedingItems, + ...content.map((text) => ({ content: { text } })), + ...followingItems, + ], + }, + }), + cancelled, + ); + }, + ); it.each([ { @@ -80,6 +94,34 @@ describe("fx cancellation failures", () => { followingContent: ["Cancelled"], cancelled: false, }, + { + exit_code: null, + signal: null, + timed_out: false, + termination_indeterminate: true, + content: '{"error":{"message":"Permission denied","details":{"error":"Permission denied"}}}', + followingItems: [terminal], + cancelled: false, + }, + { + exit_code: null, + signal: null, + timed_out: false, + termination_indeterminate: true, + content: '{"error":{"type":"tool_execution_failed","message":"clipped",', + followingItems: [diff], + cancelled: false, + }, + { + exit_code: null, + signal: null, + timed_out: false, + termination_indeterminate: true, + content: "Still processing", + followingContent: ['{"error":{"message":"Permission denied"}}'], + followingItems: [diff], + cancelled: false, + }, { exit_code: 1, signal: null, @@ -98,12 +140,15 @@ describe("fx cancellation failures", () => { }, ])( "recognizes a command result with exit $exit_code, timeout $timed_out", - ({ cancelled, content, followingContent = [], ...commandResult }) => { + ({ cancelled, content, followingContent = [], followingItems = [], ...commandResult }) => { assert.equal( fxToolCallWasCancelled({ update: { command_result: commandResult, - content: [content, ...followingContent].map((text) => ({ content: { text } })), + content: [ + ...[content, ...followingContent].map((text) => ({ content: { text } })), + ...followingItems, + ], }, }), cancelled, diff --git a/packages/provider-fx/src/server/acpSupport.ts b/packages/provider-fx/src/server/acpSupport.ts index 6a6df07bb010a..6281a790308c0 100644 --- a/packages/provider-fx/src/server/acpSupport.ts +++ b/packages/provider-fx/src/server/acpSupport.ts @@ -71,13 +71,14 @@ const isCancelledFxCommand = Schema.is( }), }), ); -const decodeFxToolText = Schema.decodeUnknownOption( +const decodeFxToolUpdate = Schema.decodeUnknownOption( Schema.Struct({ - update: Schema.Struct({ - content: Schema.Array(Schema.Struct({ content: Schema.Struct({ text: Schema.String }) })), - }), + update: Schema.Struct({ content: Schema.Array(Schema.Unknown) }), }), ); +const decodeFxContentText = Schema.decodeUnknownOption( + Schema.Struct({ content: Schema.Struct({ text: Schema.String }) }), +); const decodeFxCancelledError = Schema.decodeUnknownOption( Schema.fromJsonString( Schema.Struct({ @@ -87,9 +88,11 @@ const decodeFxCancelledError = Schema.decodeUnknownOption( ); export function fxToolCallWasCancelled(payload: unknown): boolean { - const update = decodeFxToolText(payload); + const update = decodeFxToolUpdate(payload); const texts = Option.isSome(update) - ? update.value.update.content.map(({ content }) => content.text) + ? update.value.update.content.map( + (item) => Option.getOrUndefined(decodeFxContentText(item))?.content.text, + ) : []; const firstText = texts[0]; if ( @@ -99,7 +102,10 @@ export function fxToolCallWasCancelled(payload: unknown): boolean { return true; } // fx also uses indeterminate status for real failures, whose error JSON can be clipped. - return isCancelledFxCommand(payload) && texts.every((text) => !/^\s*\{\s*"error"\s*:/.test(text)); + return ( + isCancelledFxCommand(payload) && + texts.every((text) => text === undefined || !/^\s*\{\s*"error"\s*:/.test(text)) + ); } /** From 579de19f14a17adcdcc8cae239c9547c745b3d93 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 2 Nov 2026 21:19:08 +0000 Subject: [PATCH 13/17] fix(fx): recognize cancellation errors from fx 0.0.16 --- packages/provider-fx/src/server/acpSupport.test.ts | 8 ++++++++ packages/provider-fx/src/server/acpSupport.ts | 5 ++++- packages/provider-fx/src/server/adapter.ts | 10 ++++++++-- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/packages/provider-fx/src/server/acpSupport.test.ts b/packages/provider-fx/src/server/acpSupport.test.ts index d6d5dc2bb35e0..2f70a70a777dc 100644 --- a/packages/provider-fx/src/server/acpSupport.test.ts +++ b/packages/provider-fx/src/server/acpSupport.test.ts @@ -40,6 +40,14 @@ describe("fx cancellation failures", () => { { content: ["Cancelled"], cancelled: true }, { content: ["Cancelled."], cancelled: true }, { content: ['{"error":{"details":{"error":"Cancelled"}}}'], cancelled: true }, + { + content: ['{"error":{"tool":"shell","code":"Cancelled","retryable":false}}'], + cancelled: true, + }, + { + content: ['{"error":{"tool":"shell","code":"PermissionDenied","retryable":false}}'], + cancelled: false, + }, { content: ['{"error":{"details":{"error":"Permission denied"}}}'], cancelled: false }, { content: ["Command failed"], cancelled: false }, { content: [""], cancelled: false }, diff --git a/packages/provider-fx/src/server/acpSupport.ts b/packages/provider-fx/src/server/acpSupport.ts index 6281a790308c0..c3ffcfcd5a4bb 100644 --- a/packages/provider-fx/src/server/acpSupport.ts +++ b/packages/provider-fx/src/server/acpSupport.ts @@ -82,7 +82,10 @@ const decodeFxContentText = Schema.decodeUnknownOption( const decodeFxCancelledError = Schema.decodeUnknownOption( Schema.fromJsonString( Schema.Struct({ - error: Schema.Struct({ details: Schema.Struct({ error: Schema.Literal("Cancelled") }) }), + error: Schema.Union([ + Schema.Struct({ details: Schema.Struct({ error: Schema.Literal("Cancelled") }) }), + Schema.Struct({ code: Schema.Literal("Cancelled") }), + ]), }), ), ); diff --git a/packages/provider-fx/src/server/adapter.ts b/packages/provider-fx/src/server/adapter.ts index 73b866ba2d9d4..4ef217b7820d7 100644 --- a/packages/provider-fx/src/server/adapter.ts +++ b/packages/provider-fx/src/server/adapter.ts @@ -102,7 +102,8 @@ const decodeFxToolError = Schema.decodeUnknownOption( Schema.fromJsonString( Schema.Struct({ error: Schema.Struct({ - message: Schema.String, + message: Schema.optional(Schema.String), + code: Schema.optional(Schema.String), details: Schema.optional(Schema.Struct({ error: Schema.optional(Schema.String) })), }), }), @@ -158,7 +159,12 @@ function extractFxSubagentUpdate(tool: AcpToolCallState): AcpAdapterV2SubagentUp result: status === "running" ? null - : (outcome?.result ?? failure?.details?.error ?? failure?.message ?? text ?? null), + : (outcome?.result ?? + failure?.details?.error ?? + failure?.message ?? + failure?.code ?? + text ?? + null), }; } From 4585b90775c71c7b785e99ca0b4d271eff37de15 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 2 Nov 2026 23:44:04 +0000 Subject: [PATCH 14/17] fix(fx): preserve subagent failures after stop --- .../Adapters/AcpAdapterV2.test.ts | 21 +++++++++++++++++++ .../testkit/fixtures/index.ts | 1 - packages/provider-acp/src/server/adapter.ts | 6 ++++-- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 1f4228625d23a..04d29f4aee427 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -5332,6 +5332,19 @@ describe("AcpAdapterV2", () => { flavor: { driver: ACP_TEST_DRIVER, capabilities: AcpProviderCapabilitiesV2, + extractSubagentUpdate: (tool) => + tool.toolCallId === "native-failed-tool" + ? { + nativeTaskId: tool.toolCallId, + childSessionId: null, + prompt: "Run a command", + title: null, + model: null, + status: tool.status === "failed" ? "failed" : "running", + result: tool.status === "failed" ? "Permission denied" : null, + suppressNormalTool: tool.status === "failed" ? false : undefined, + } + : undefined, isCancelledToolCall: Schema.is( Schema.Struct({ update: Schema.Struct({ toolCallId: Schema.Literal("native-cancel-tool") }), @@ -5437,6 +5450,14 @@ describe("AcpAdapterV2", () => { ) .at(-1); assert.equal(failure?.status, "failed"); + const subagent = untilTerminal + .flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "subagent" + ? [event.turnItem] + : [], + ) + .at(-1); + assert.equal(subagent?.status, "failed"); assert.include( failure?.type === "command_execution" ? failure.output : "", "Permission denied", diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts index eef8905067d4f..d37da5a454255 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts @@ -235,7 +235,6 @@ import { turnInterruptMidToolKiroInput, } from "./turn_interrupt_mid_tool/input.ts"; import { assertTurnInterruptMidToolKiroOutput } from "./turn_interrupt_mid_tool/kiro_output.ts"; - import { assertTurnInterruptMidToolPiOutput } from "./turn_interrupt_mid_tool/pi_output.ts"; import { assertTurnInterruptRestartClaudeOutput } from "./turn_interrupt_restart/claude_output.ts"; import { turnInterruptRestartInput } from "./turn_interrupt_restart/input.ts"; diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 209f9d2f39784..18cd5aa374c76 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -3007,9 +3007,11 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( context: ActiveAcpTurn, reported: AcpAdapterV2SubagentUpdate, ) { - // See stoppedToolStatus: after Stop, a failed subagent was interrupted. + // Providers with cancellation detection already classify their subagent failures. const update: AcpAdapterV2SubagentUpdate = - context.interrupted && reported.status === "failed" + flavor.isCancelledToolCall === undefined && + context.interrupted && + reported.status === "failed" ? { ...reported, status: "interrupted" } : reported; // Hydration uses a new toolCallId but references the original child by subagent_id or task id. From b903a242ec0468cbd6d4fd4f9e0a4c11b2b843aa Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 2 Nov 2026 23:45:04 +0000 Subject: [PATCH 15/17] test(fx): keep cancellation fixture options typed --- apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 04d29f4aee427..1d81064ff96a9 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -5342,7 +5342,7 @@ describe("AcpAdapterV2", () => { model: null, status: tool.status === "failed" ? "failed" : "running", result: tool.status === "failed" ? "Permission denied" : null, - suppressNormalTool: tool.status === "failed" ? false : undefined, + suppressNormalTool: tool.status !== "failed", } : undefined, isCancelledToolCall: Schema.is( From 9d8eb86e0a356412ed2e0f39ff998e5706e96167 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 3 Nov 2026 03:46:31 +0000 Subject: [PATCH 16/17] fix(fx): classify first cancelled tool updates as interrupted --- apps/server/scripts/acp-mock-agent.ts | 11 +++++++++++ .../orchestration-v2/Adapters/AcpAdapterV2.test.ts | 13 ++++++++++++- packages/provider-acp/src/server/adapter.ts | 3 ++- 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index eeabd7f2adb7b..e6be642824cfc 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -1320,6 +1320,17 @@ const program = Effect.gen(function* () { content: [{ type: "content", content: { type: "text", text: "Cancelled." } }], }, }); + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "native-unseen-cancel-tool", + title: "Cancelled command", + kind: "execute", + status: "failed", + content: [{ type: "content", content: { type: "text", text: "Cancelled." } }], + }, + }); // A later output-only update must not undo the interrupted status. yield* agent.client.sessionUpdate({ sessionId: requestedSessionId, diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 560f0acc7f2d8..9418ae9d6c428 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -5439,7 +5439,9 @@ describe("AcpAdapterV2", () => { : undefined, isCancelledToolCall: Schema.is( Schema.Struct({ - update: Schema.Struct({ toolCallId: Schema.Literal("native-cancel-tool") }), + update: Schema.Struct({ + toolCallId: Schema.Literals(["native-cancel-tool", "native-unseen-cancel-tool"]), + }), }), ), makeRuntime: makeMockRuntime({ @@ -5531,6 +5533,15 @@ describe("AcpAdapterV2", () => { : [], ) .at(-1); + const unseenCommand = untilTerminal + .flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === "native-unseen-cancel-tool" + ? [event.turnItem] + : [], + ) + .at(-1); + assert.equal(unseenCommand?.status, "interrupted"); assert.equal(command?.status, "interrupted"); assert.include(command?.type === "command_execution" ? command.output : "", "exit 130"); const failure = untilTerminal diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index f5d121968308c..bafd3edd5ab4a 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -3765,7 +3765,8 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( context.interrupted && toolCall.status === "failed" && flavor.isCancelledToolCall?.(rawPayload) === true && - (previousStatus === "pending" || + (previousStatus === undefined || + previousStatus === "pending" || previousStatus === "running" || previousStatus === "waiting") ) { From ffbbe21446249a1bd7eca469f39692ade83b69d8 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Tue, 3 Nov 2026 03:59:22 +0000 Subject: [PATCH 17/17] fix(fx): require mise ownership in shared shim directories --- .../src/server/maintenanceResolver.test.ts | 6 +++++- .../src/server/maintenanceResolver.ts | 2 -- .../provider-fx/src/server/maintenance.test.ts | 17 +++++++++++------ 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/provider-core/src/server/maintenanceResolver.test.ts b/packages/provider-core/src/server/maintenanceResolver.test.ts index 8a687120ba55d..91abf33218ae4 100644 --- a/packages/provider-core/src/server/maintenanceResolver.test.ts +++ b/packages/provider-core/src/server/maintenanceResolver.test.ts @@ -1075,7 +1075,11 @@ it.layer( nativePackageToolUpdate, { binaryPath: "native-package-tool", - env: { PATH: nativeBinDir }, + env: { + PATH: nativeBinDir, + MISE_SHIMS_DIR: nativeBinDir, + MISE_SYSTEM_SHIMS_DIR: nativeBinDir, + }, }, ).pipe( Effect.provideService(HostProcess.Platform, "darwin"), diff --git a/packages/provider-core/src/server/maintenanceResolver.ts b/packages/provider-core/src/server/maintenanceResolver.ts index 49a6700f5de98..19049d996ce6c 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -262,9 +262,7 @@ export function isMiseCommandPath(commandPath: string, env?: NodeJS.ProcessEnv): : []), env?.MISE_INSTALLS_DIR, env?.MISE_INSTALL_STORE_DIR, - env?.MISE_SHIMS_DIR, env?.MISE_SYSTEM_INSTALLS_DIR, - env?.MISE_SYSTEM_SHIMS_DIR, ]; return ( normalized.includes("/mise/installs/") || diff --git a/packages/provider-fx/src/server/maintenance.test.ts b/packages/provider-fx/src/server/maintenance.test.ts index 33ca744cf3f4d..071e4c408e703 100644 --- a/packages/provider-fx/src/server/maintenance.test.ts +++ b/packages/provider-fx/src/server/maintenance.test.ts @@ -170,7 +170,6 @@ it.layer(maintenanceLayer)("fx maintenance", (it) => { managedPath: "/opt/store/fx-abc/bin/fx", env: { MISE_INSTALL_STORE_DIR: "/opt/store" }, }, - { managedPath: "/opt/dispatch/fx", env: { MISE_SHIMS_DIR: "/opt/dispatch" } }, { managedPath: "/opt/system/installs/fx/0.0.15/bin/fx", env: { MISE_SYSTEM_DATA_DIR: "/opt/system" }, @@ -179,10 +178,6 @@ it.layer(maintenanceLayer)("fx maintenance", (it) => { managedPath: "/opt/system-installs/fx/0.0.15/bin/fx", env: { MISE_SYSTEM_INSTALLS_DIR: "/opt/system-installs" }, }, - { - managedPath: "/opt/system-shims/fx", - env: { MISE_SYSTEM_SHIMS_DIR: "/opt/system-shims" }, - }, ]) { for (const paths of [ { resolvedCommandPath: managedPath, realCommandPath: binaryPath }, @@ -220,12 +215,22 @@ it.layer(maintenanceLayer)("fx maintenance", (it) => { resolvedCommandPath: wrapperPath, realCommandPath: wrapperPath, platform: "linux", - env: {}, + env: { MISE_SHIMS_DIR: directory, MISE_SYSTEM_SHIMS_DIR: directory }, }); expect(managed.update).toBeNull(); expect(managed.installPath).toBe(wrapperPath); expect(yield* managed.lookupLatestVersion!).toBe("0.0.15"); } + yield* fs.writeFileString(wrapperPath, "#!/bin/sh\nexit 0\n"); + expect( + (yield* resolver.resolve({ + binaryPath: wrapperPath, + resolvedCommandPath: wrapperPath, + realCommandPath: wrapperPath, + platform: "linux", + env: { MISE_SHIMS_DIR: directory, MISE_SYSTEM_SHIMS_DIR: directory }, + })).update, + ).not.toBeNull(); const outsideMise = "/opt/tools-extra/installs/fx/0.0.15/bin/fx"; expect( (yield* resolver.resolve({