diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index feb4aa405ae64..2d8b48f5561d7 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -1292,8 +1292,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: { @@ -1303,6 +1322,26 @@ 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, + 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/scripts/record-fx-acp-replay-fixture.ts b/apps/server/scripts/record-fx-acp-replay-fixture.ts index 17b1a8f1b7d06..0a340a0fa0a00 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 NodeCrypto from "@effect/platform-node/NodeCrypto"; import * as NodeServices from "@effect/platform-node/NodeServices"; diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index dff370bd61101..2aa90aaf68115 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -5871,6 +5871,26 @@ 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", + } + : undefined, + isCancelledToolCall: Schema.is( + Schema.Struct({ + update: Schema.Struct({ + toolCallId: Schema.Literals(["native-cancel-tool", "native-unseen-cancel-tool"]), + }), + }), + ), makeRuntime: makeMockRuntime({ childProcessSpawner, mockAgentPath: yield* path.fromFileUrl( @@ -5941,15 +5961,57 @@ 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. + const command = untilTerminal + .flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === "native-cancel-tool" + ? [event.turnItem] + : [], + ) + .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 + .flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === "native-failed-tool" + ? [event.turnItem] + : [], + ) + .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", + ); yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }), ); 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 811598474bac5..d37da5a454255 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts @@ -224,9 +224,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, turnInterruptMidToolKiroInput, @@ -1879,6 +1882,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/docs/user/providers-fx.md b/docs/user/providers-fx.md index 430f7a705a499..ee77d2773632c 100644 --- a/docs/user/providers-fx.md +++ b/docs/user/providers-fx.md @@ -15,8 +15,16 @@ 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. 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. + ## 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 +46,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/effect-acp/src/protocol.test.ts b/packages/effect-acp/src/protocol.test.ts index 6eb9fe0ea3ca0..75f3376ebeb39 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 c29656cd12e7a..65ca016d449a8 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 678cd7edf2296..c1996c24069aa 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -487,6 +487,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. */ @@ -1377,6 +1378,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; @@ -3092,9 +3095,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. @@ -3748,6 +3753,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( context: ActiveAcpTurn, incoming: AcpToolCallState, projectedStatus?: ProjectedToolStatus, + rawPayload?: unknown, ) => Effect.Effect = () => Effect.void; const markAwaitingBackgroundHydration = @@ -3800,6 +3806,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"); @@ -3840,6 +3847,24 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ? toolCall : { ...toolCall, backgroundCommandTaskId }, ); + // 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 === undefined || + 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 ( followsAgentWorkState && projectedStatus === undefined && @@ -3944,7 +3969,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); @@ -3952,7 +3982,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } } if ( - projectedStatus === undefined && + stoppedStatus === undefined && !shouldPersistToolUpdate( context, toolCall.toolCallId, @@ -3965,7 +3995,11 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( return; } const status = - projectedStatus ?? stoppedToolStatus(context, toolCall) ?? toolStatus(toolCall.status); + stoppedStatus ?? + (flavor.isCancelledToolCall === undefined + ? stoppedToolStatus(context, toolCall) + : undefined) ?? + toolStatus(toolCall.status); const now = yield* DateTime.now; const nativeItemId = `${context.nativeThreadId}:tool:${toolCall.toolCallId}`; const ordinal = yield* resolveItemOrdinal(context, nativeItemId); @@ -5497,7 +5531,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") { @@ -8153,6 +8187,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(), diff --git a/packages/provider-core/src/server/maintenanceResolver.test.ts b/packages/provider-core/src/server/maintenanceResolver.test.ts index 9447742ca354b..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"), @@ -1095,10 +1099,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"); @@ -1111,7 +1115,7 @@ it.layer( } else { const target = NodePath.join( tempDir, - "mise", + launcher === "custom-data" ? "managed-tools" : "mise", "installs", "native-package-tool", "1.0.0", @@ -1124,7 +1128,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 364b4cee9bf42..19049d996ce6c 100644 --- a/packages/provider-core/src/server/maintenanceResolver.ts +++ b/packages/provider-core/src/server/maintenanceResolver.ts @@ -245,13 +245,34 @@ 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, 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_SYSTEM_INSTALLS_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 +502,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 ( @@ -537,7 +561,7 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( nativeUpdate?.isCommandPath && commandPaths.some(nativeUpdate.isCommandPath) && !npmGlobalPrefixFromCommandPath(context.realCommandPath, packageName) && - !commandPaths.some(isMiseCommandPath) && + !miseManaged && !(yield* isMiseWrapperScript(context)) ) { return native; @@ -634,7 +658,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)) @@ -709,7 +733,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/acpSupport.test.ts b/packages/provider-fx/src/server/acpSupport.test.ts index ae40665cb031c..2f70a70a777dc 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,136 @@ 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 }, + { 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 }, + { content: ["Cancelled"], followingItems: [diff], cancelled: true }, + { content: ["Cancelled"], precedingItems: [diff], 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, precedingItems = [], followingItems = [] }) => { + assert.equal( + fxToolCallWasCancelled({ + update: { + content: [ + ...precedingItems, + ...content.map((text) => ({ content: { text } })), + ...followingItems, + ], + }, + }), + 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",', + 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, + 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, followingContent = [], followingItems = [], ...commandResult }) => { + assert.equal( + fxToolCallWasCancelled({ + update: { + command_result: commandResult, + 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 b77a4431faf1a..79e0778374bfa 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 SecureRandom from "effect/crypto/SecureRandom"; 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,58 @@ 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 decodeFxToolUpdate = Schema.decodeUnknownOption( + Schema.Struct({ + 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({ + error: Schema.Union([ + Schema.Struct({ details: Schema.Struct({ error: Schema.Literal("Cancelled") }) }), + Schema.Struct({ code: Schema.Literal("Cancelled") }), + ]), + }), + ), +); + +export function fxToolCallWasCancelled(payload: unknown): boolean { + const update = decodeFxToolUpdate(payload); + const texts = Option.isSome(update) + ? update.value.update.content.map( + (item) => Option.getOrUndefined(decodeFxContentText(item))?.content.text, + ) + : []; + const firstText = texts[0]; + if ( + firstText !== undefined && + (/^Cancelled\.?$/.test(firstText) || Option.isSome(decodeFxCancelledError(firstText))) + ) { + return true; + } + // fx also uses indeterminate status for real failures, whose error JSON can be clipped. + return ( + isCancelledFxCommand(payload) && + texts.every((text) => text === undefined || !/^\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 868b13f497de3..1ebf6fe4db8aa 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, @@ -97,6 +98,17 @@ 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.optional(Schema.String), + code: Schema.optional(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 +137,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 +156,15 @@ 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 ?? + failure?.code ?? + text ?? + null), }; } @@ -185,6 +209,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/driver.ts b/packages/provider-fx/src/server/driver.ts index 48384c8054b9a..e5773a01c2b43 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 = (() => { @@ -66,8 +67,10 @@ export type FxDriverEnv = | ChildProcessSpawner.ChildProcessSpawner | SecureRandom.SecureRandom | FileSystem.FileSystem + | HttpClient.HttpClient | Path.Path - | ProviderHost.ProviderHost; + | ProviderHost.ProviderHost + | ProviderLatestVersions.ProviderLatestVersions; export const FxDriver: ProviderDriver = { driverKind: FX_DRIVER_KIND, @@ -88,6 +91,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, @@ -104,9 +109,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, @@ -116,6 +133,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..071e4c408e703 --- /dev/null +++ b/packages/provider-fx/src/server/maintenance.test.ts @@ -0,0 +1,278 @@ +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 Layer from "effect/Layer"; +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"; + +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))); + +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; + 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")); + 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"; + 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`, + }); + + 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-" }); + const wrapperPath = path.join(directory, "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/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" }, + }, + ]) { + for (const paths of [ + { resolvedCommandPath: managedPath, realCommandPath: binaryPath }, + { resolvedCommandPath: binaryPath, realCommandPath: managedPath }, + ]) { + 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`); + const managed = yield* resolver.resolve({ + binaryPath: wrapperPath, + resolvedCommandPath: wrapperPath, + realCommandPath: wrapperPath, + platform: "linux", + 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({ + binaryPath: outsideMise, + resolvedCommandPath: outsideMise, + realCommandPath: outsideMise, + platform: "linux", + env: { MISE_DATA_DIR: "/opt/tools" }, + })).update, + ).not.toBeNull(); + }).pipe(Effect.scoped), + ); + + 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(); + }), + ); + + 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")) + .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 new file mode 100644 index 0000000000000..ce6c0a8861ad3 --- /dev/null +++ b/packages/provider-fx/src/server/maintenance.ts @@ -0,0 +1,71 @@ +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"; + +import { + homebrewOwnershipFromCommandPath, + isMiseCommandPath, + isMiseWrapperScript, + makeManualOnlyProviderMaintenanceCapabilities, + makeProviderMaintenanceCapabilities, + resolveHomebrewProviderMaintenance, + 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 after 0.0.13 needs Darwin 24, but its updater does not check the host version. +export const makeFxMaintenance = ( + lookupLatestVersion: Effect.Effect, +): 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(yield* HostProcess.KernelRelease, 10) < 24) + ) { + return manualOnly; + } + if ( + isMiseCommandPath(context.resolvedCommandPath, context.env) || + isMiseCommandPath(context.realCommandPath, context.env) || + (yield* isMiseWrapperScript(context)) + ) { + return makeManualOnlyProviderMaintenanceCapabilities({ + ...input, + installPath: context.realCommandPath, + lookupLatestVersion, + }); + } + 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, + }); + }), +}); 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, diff --git a/packages/shared/src/HostProcess.ts b/packages/shared/src/HostProcess.ts index e5e2f8e97f108..50490bfb78b5f 100644 --- a/packages/shared/src/HostProcess.ts +++ b/packages/shared/src/HostProcess.ts @@ -16,6 +16,13 @@ export const Architecture = Context.Reference( }, ); +export const KernelRelease = Context.Reference( + "@t3tools/shared/HostProcess/KernelRelease", + { + defaultValue: () => NodeOS.release(), + }, +); + export const LinuxLibc = Context.Reference<"gnu" | "musl">( "@t3tools/shared/HostProcess/LinuxLibc", {