From d7be76211afafb2b6ca5aba585b56304e3050723 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 29 Oct 2026 10:05:33 -0400 Subject: [PATCH 1/5] 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 ec8ad5bce9f8e..5139b4353cc37 100644 --- a/packages/provider-fx/src/server/status.ts +++ b/packages/provider-fx/src/server/status.ts @@ -78,7 +78,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 0670c96caa9916250636c8217f159ebcf746257b Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 29 Oct 2026 10:25:38 -0400 Subject: [PATCH 2/5] 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 1782f6f072a1f..0043340d1b87a 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts @@ -196,9 +196,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"; @@ -1644,6 +1647,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 398eac2170e70..b085cf6b41453 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -3441,6 +3441,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 && @@ -3545,7 +3556,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); @@ -3553,7 +3569,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } } if ( - projectedStatus === undefined && + stoppedStatus === undefined && !shouldPersistToolUpdate( context, toolCall.toolCallId, @@ -3565,7 +3581,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 f3d064d4f1514441fefb54dab12380c1a9574bcf Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 29 Oct 2026 10:54:48 -0400 Subject: [PATCH 3/5] 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 a10f9855cbbbf..1939d1fd48dde 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -1235,6 +1235,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 8a68e92b89160..bdfee399d7bd2 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -4233,15 +4233,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 b085cf6b41453..3af300061f1ab 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -1058,6 +1058,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; @@ -3443,13 +3445,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 ( @@ -7455,6 +7462,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 36bb2725a0ac300dad8fc619962c360c94278fe9 Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 29 Oct 2026 11:01:24 -0400 Subject: [PATCH 4/5] 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 207857a0d9fea1ac26bf334093a1abd6f759838f Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Thu, 29 Oct 2026 11:13:07 -0400 Subject: [PATCH 5/5] 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 bdfee399d7bd2..ec022d1f1f7b0 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -4244,17 +4244,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 }), );