diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index a10f9855cbbbf..1939d1fd48dde 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -1235,6 +1235,15 @@ const program = Effect.gen(function* () { content: [{ type: "content", content: { type: "text", text: "Cancelled." } }], }, }); + // A later output-only update must not undo the interrupted status. + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "native-cancel-tool", + content: [{ type: "content", content: { type: "text", text: "Cancelled. exit 130" } }], + }, + }); yield* agent.client.sessionUpdate({ sessionId: requestedSessionId, update: { diff --git a/apps/server/scripts/record-fx-acp-replay-fixture.ts b/apps/server/scripts/record-fx-acp-replay-fixture.ts index 73ab4023f732c..9deb40d90cafc 100644 --- a/apps/server/scripts/record-fx-acp-replay-fixture.ts +++ b/apps/server/scripts/record-fx-acp-replay-fixture.ts @@ -1,8 +1,8 @@ /** - * Records an fx ACP replay transcript through the real orchestrator and - * FxAdapterV2, teeing only the protocol logger. Run from apps/server with - * fx 0.0.13 on PATH (or T3_FX_BIN); HOME stays the user's real login: + * Records an fx ACP replay transcript through the real orchestrator and FxAdapterV2. + * Run from apps/server with fx on PATH (or T3_FX_BIN), signed in under HOME: * node scripts/record-fx-acp-replay-fixture.ts --scenario simple + * fx 0.0.14+ saves each recorded mode as the default in ~/.fx/settings.json; restore it after. */ import * as NodeServices from "@effect/platform-node/NodeServices"; import { ProviderInstanceId, type ProviderReplayEntry } from "@t3tools/contracts"; diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 8a68e92b89160..ec022d1f1f7b0 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -4233,15 +4233,27 @@ 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); + assert.equal(command?.status, "interrupted"); + assert.include(command?.type === "command_execution" ? command.output : "", "exit 130"); yield* runtime.startTurn( makeTurnInput({ threadId, providerThread, instanceId, runtimePolicy, now, ordinal: 2 }), ); diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/fx_subagent_stop/fx_output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/fx_subagent_stop/fx_output.ts new file mode 100644 index 0000000000000..99676f19a77d3 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/fx_subagent_stop/fx_output.ts @@ -0,0 +1,40 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertBaseProjection, + assertSemanticProjectionIntegrity, + assertUserMessagesInclude, + FX_SUBAGENT_STOP_PROMPT, + projectionFor, +} from "../shared.ts"; + +/** fx 0.0.15 fails a sub-agent that Stop cut off; T3 shows it, and its child thread, as interrupted. */ +export function assertFxSubagentStopOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["interrupted"] }); + + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + assertUserMessagesInclude(projection, [FX_SUBAGENT_STOP_PROMPT]); + assert.notInclude( + projection.turnItems.map((item) => item.type), + "dynamic_tool", + "the subagent call must not also show as a tool row", + ); + assert.lengthOf(projection.subagents, 1); + const subagent = projection.subagents[0]!; + assert.equal(subagent.status, "interrupted"); + assert.include(subagent.prompt, "stopped.txt"); + assert.equal(subagent.result, "Cancelled", "fx's error, not its JSON envelope"); + if (subagent.childThreadId === null) throw new Error("The subagent has no child thread"); + assert.equal( + result.projections.get(subagent.childThreadId)?.thread.lineage.parentThreadId, + projection.thread.id, + ); + assert.equal(projection.providerTurns[0]?.status, "interrupted"); + assert.equal(projection.providerThreads[0]?.status, "idle"); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/fx_subagent_stop/fx_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/fx_subagent_stop/fx_transcript.ndjson new file mode 100644 index 0000000000000..5c6aebc238ac4 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/fx_subagent_stop/fx_transcript.ndjson @@ -0,0 +1,41 @@ +{"type":"transcript_start","provider":"fx","protocol":"acp.ndjson-jsonrpc","version":"1","scenario":"fx_subagent_stop","metadata":{"generatedBy":"live-fx-recorder","fxVersion":"0.0.15","normalization":"Session ids are fx-session-NN, the workspace is , HOME is /home/fx-replay and the recording user is fx-replay. T3-owned prompt text, MCP servers and initialize params other than clientCapabilities and _meta are . User skill names, emails and token-shaped strings are removed. Timestamps are kept as recorded.","droppedFrames":0}} +{"type":"expect_outbound","label":"request:initialize","frame":{"kind":"request","method":"initialize","params":{"protocolVersion":2,"info":"","capabilities":"","clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false,"session":{"notices":{}},"elicitation":{"form":{}}},"clientInfo":""}}} +{"type":"emit_inbound","label":"response:initialize","frame":{"kind":"response","method":"initialize","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":true,"audio":false,"embeddedContext":true},"mcpCapabilities":{"http":true,"sse":false,"acp":false},"sessionCapabilities":{"list":{},"resume":{},"close":{},"systemPrompt":{}},"_meta":{"fx":{"steering":true}}},"agentInfo":{"name":"fx","title":"fx","version":"0.0.15"},"authMethods":[]}}} +{"type":"expect_outbound","label":"request:session/new","frame":{"kind":"request","method":"session/new","params":{"cwd":"","mcpServers":""}}} +{"type":"emit_inbound","label":"response:session/new","frame":{"kind":"response","method":"session/new","result":{"sessionId":"fx-session-01","configOptions":[{"id":"provider","name":"Provider","category":"model","type":"select","currentValue":"codex","options":[{"value":"gateway","name":"Vercel AI Gateway"},{"value":"codex","name":"Codex subscription"},{"value":"grok","name":"Grok subscription"}]},{"id":"model","name":"Model","category":"model","type":"select","currentValue":"gpt-6.1-sol","options":[{"value":"gpt-6.1-sol","name":"gpt-6.1-sol"},{"value":"gpt-6-astra","name":"gpt-6-astra"},{"value":"gpt-6.1-terra","name":"gpt-6.1-terra"},{"value":"gpt-6-sol","name":"gpt-6-sol"},{"value":"gpt-6-luna","name":"gpt-6-luna"},{"value":"gpt-5.6-sol","name":"gpt-5.6-sol"},{"value":"gpt-5.6-terra","name":"gpt-5.6-terra"},{"value":"gpt-5.6-luna","name":"gpt-5.6-luna"},{"value":"gpt-daybreak-blue-latest","name":"gpt-daybreak-blue-latest"}]},{"id":"mode","name":"Session Mode","description":"Controls how the agent requests permission","category":"mode","type":"select","currentValue":"ask","options":[{"value":"auto","name":"Auto","description":"Check actions with an automatic safety review instead of asking","_meta":{"fx":{"permissionMode":"auto"}}},{"value":"ask","name":"Ask","description":"Request permission before making any changes","_meta":{"fx":{"permissionMode":"ask"}}},{"value":"full-access","name":"Full access","description":"Run every action without asking, outside the sandbox","_meta":{"fx":{"permissionMode":"yolo"}}}]},{"id":"effort","name":"Reasoning Effort","description":"Controls how much the model thinks before responding","category":"thought_level","type":"select","currentValue":"auto","options":[{"value":"auto","name":"default"},{"value":"low","name":"low"},{"value":"medium","name":"medium"},{"value":"high","name":"high"},{"value":"xhigh","name":"xhigh"},{"value":"max","name":"max"},{"value":"ultra","name":"ultra"}]},{"id":"fast","name":"Fast Mode","description":"Uses the model's fast lane","category":"model","type":"select","currentValue":"false","options":[{"value":"false","name":"off"},{"value":"true","name":"on"}]}],"modes":{"currentModeId":"ask","availableModes":[{"id":"auto","name":"Auto","description":"Check actions with an automatic safety review instead of asking"},{"id":"ask","name":"Ask","description":"Request permission before making any changes"},{"id":"full-access","name":"Full access","description":"Run every action without asking, outside the sandbox"}]}}}} +{"type":"emit_inbound","label":"notification:session/update:available_commands_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}} +{"type":"expect_outbound","label":"request:session/set_config_option","frame":{"kind":"request","method":"session/set_config_option","params":{"sessionId":"fx-session-01","configId":"effort","value":"medium"}}} +{"type":"emit_inbound","label":"response:session/set_config_option","frame":{"kind":"response","method":"session/set_config_option","result":{"configOptions":[{"id":"provider","name":"Provider","category":"model","type":"select","currentValue":"codex","options":[{"value":"gateway","name":"Vercel AI Gateway"},{"value":"codex","name":"Codex subscription"},{"value":"grok","name":"Grok subscription"}]},{"id":"model","name":"Model","category":"model","type":"select","currentValue":"gpt-6.1-sol","options":[{"value":"gpt-6.1-sol","name":"gpt-6.1-sol"},{"value":"gpt-6-astra","name":"gpt-6-astra"},{"value":"gpt-6.1-terra","name":"gpt-6.1-terra"},{"value":"gpt-6-sol","name":"gpt-6-sol"},{"value":"gpt-6-luna","name":"gpt-6-luna"},{"value":"gpt-5.6-sol","name":"gpt-5.6-sol"},{"value":"gpt-5.6-terra","name":"gpt-5.6-terra"},{"value":"gpt-5.6-luna","name":"gpt-5.6-luna"},{"value":"gpt-daybreak-blue-latest","name":"gpt-daybreak-blue-latest"}]},{"id":"mode","name":"Session Mode","description":"Controls how the agent requests permission","category":"mode","type":"select","currentValue":"ask","options":[{"value":"auto","name":"Auto","description":"Check actions with an automatic safety review instead of asking","_meta":{"fx":{"permissionMode":"auto"}}},{"value":"ask","name":"Ask","description":"Request permission before making any changes","_meta":{"fx":{"permissionMode":"ask"}}},{"value":"full-access","name":"Full access","description":"Run every action without asking, outside the sandbox","_meta":{"fx":{"permissionMode":"yolo"}}}]},{"id":"effort","name":"Reasoning Effort","description":"Controls how much the model thinks before responding","category":"thought_level","type":"select","currentValue":"medium","options":[{"value":"auto","name":"default"},{"value":"low","name":"low"},{"value":"medium","name":"medium"},{"value":"high","name":"high"},{"value":"xhigh","name":"xhigh"},{"value":"max","name":"max"},{"value":"ultra","name":"ultra"}]},{"id":"fast","name":"Fast Mode","description":"Uses the model's fast lane","category":"model","type":"select","currentValue":"false","options":[{"value":"false","name":"off"},{"value":"true","name":"on"}]}]}}} +{"type":"expect_outbound","label":"request:session/set_config_option","frame":{"kind":"request","method":"session/set_config_option","params":{"sessionId":"fx-session-01","configId":"mode","value":"ask"}}} +{"type":"emit_inbound","label":"response:session/set_config_option","frame":{"kind":"response","method":"session/set_config_option","result":{"configOptions":[{"id":"provider","name":"Provider","category":"model","type":"select","currentValue":"codex","options":[{"value":"gateway","name":"Vercel AI Gateway"},{"value":"codex","name":"Codex subscription"},{"value":"grok","name":"Grok subscription"}]},{"id":"model","name":"Model","category":"model","type":"select","currentValue":"gpt-6.1-sol","options":[{"value":"gpt-6.1-sol","name":"gpt-6.1-sol"},{"value":"gpt-6-astra","name":"gpt-6-astra"},{"value":"gpt-6.1-terra","name":"gpt-6.1-terra"},{"value":"gpt-6-sol","name":"gpt-6-sol"},{"value":"gpt-6-luna","name":"gpt-6-luna"},{"value":"gpt-5.6-sol","name":"gpt-5.6-sol"},{"value":"gpt-5.6-terra","name":"gpt-5.6-terra"},{"value":"gpt-5.6-luna","name":"gpt-5.6-luna"},{"value":"gpt-daybreak-blue-latest","name":"gpt-daybreak-blue-latest"}]},{"id":"mode","name":"Session Mode","description":"Controls how the agent requests permission","category":"mode","type":"select","currentValue":"ask","options":[{"value":"auto","name":"Auto","description":"Check actions with an automatic safety review instead of asking","_meta":{"fx":{"permissionMode":"auto"}}},{"value":"ask","name":"Ask","description":"Request permission before making any changes","_meta":{"fx":{"permissionMode":"ask"}}},{"value":"full-access","name":"Full access","description":"Run every action without asking, outside the sandbox","_meta":{"fx":{"permissionMode":"yolo"}}}]},{"id":"effort","name":"Reasoning Effort","description":"Controls how much the model thinks before responding","category":"thought_level","type":"select","currentValue":"medium","options":[{"value":"auto","name":"default"},{"value":"low","name":"low"},{"value":"medium","name":"medium"},{"value":"high","name":"high"},{"value":"xhigh","name":"xhigh"},{"value":"max","name":"max"},{"value":"ultra","name":"ultra"}]},{"id":"fast","name":"Fast Mode","description":"Uses the model's fast lane","category":"model","type":"select","currentValue":"false","options":[{"value":"false","name":"off"},{"value":"true","name":"on"}]}]}}} +{"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"fx-session-01","prompt":[{"type":"text","text":"\n\n\n\n\nUse exactly one subagent to create a file named stopped.txt containing STOP. Do not create the file yourself. Wait for the subagent, then reply DONE.\n"}]}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":"I’ll have one"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":" sub"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":"agent"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":" create"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":" `"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":"st"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":"opped"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":".txt"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":"`"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":" containing"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":" `"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":"STOP"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":"`,"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":" then"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":" wait"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":" for"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":" it"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":" to"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":" finish"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"0aed77e99fd86a770413f2ec005c9dee","content":{"type":"text","text":".\n"}}}}} +{"type":"emit_inbound","label":"notification:session/update:tool_call","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"tool_call","toolCallId":"call_77315bade0584581bc46d00bd72efcc7","name":"subagent","title":"Subagent · Create a file named stopped.txt in the current workspace containing exactly STOP (no newline). Use local tools to cre...","kind":"other","status":"pending","rawInput":{"action":"run","task":"Create a file named stopped.txt in the current workspace containing exactly STOP (no newline). Use local tools to create it and verify its contents. Do not spawn any subagents. Report completion."},"_meta":{"fx":{"toolCall":{"internal":false}}}}}}} +{"type":"emit_inbound","label":"notification:session/update:tool_call_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_77315bade0584581bc46d00bd72efcc7","status":"in_progress"}}}} +{"type":"expect_outbound","label":"notification:session/cancel","frame":{"kind":"notification","method":"session/cancel","params":{"sessionId":"fx-session-01"}}} +{"type":"emit_inbound","label":"notification:session/update:tool_call_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_77315bade0584581bc46d00bd72efcc7","status":"failed","content":[{"type":"content","content":{"type":"text","text":"{\"error\":{\"type\":\"tool_execution_failed\",\"tool_name\":\"subagent\",\"message\":\"Tool execution failed\",\"details\":{\"error\":\"Cancelled\"}}}"}}]}}}} +{"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"session_info_update","title":"Create stopped.txt via subagent","updatedAt":"2026-10-29T14:23:01Z"}}}} +{"type":"emit_inbound","label":"notification:session/update:usage_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"usage_update","used":6410,"size":500000}}}} +{"type":"emit_inbound","label":"response:session/prompt","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"cancelled","usage":{"inputTokens":6321,"outputTokens":89,"cacheReadTokens":3328,"cacheWriteTokens":0,"reasoningTokens":0}}}} +{"type":"expect_outbound","label":"request:session/close","frame":{"kind":"request","method":"session/close","params":{"sessionId":"fx-session-01"}}} +{"type":"emit_inbound","label":"response:session/close","frame":{"kind":"response","method":"session/close","result":{}}} +{"type":"runtime_exit","status":"success"} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/fx_subagent_stop/input.ts b/apps/server/src/orchestration-v2/testkit/fixtures/fx_subagent_stop/input.ts new file mode 100644 index 0000000000000..a4f664a6d3737 --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/fx_subagent_stop/input.ts @@ -0,0 +1,15 @@ +import { FX_SUBAGENT_STOP_PROMPT, type OrchestratorFixtureInput } from "../shared.ts"; + +/** + * Supervised: fx never forwards a sub-agent's permission request over ACP, + * so the sub-agent waits until Stop. + */ +export function fxSubagentStopInput(): OrchestratorFixtureInput { + return { + runtimeMode: "approval-required", + steps: [ + { type: "message", text: FX_SUBAGENT_STOP_PROMPT }, + { type: "interrupt", targetRunIndex: 1, waitForTurnItemType: "subagent" }, + ], + }; +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts index 1782f6f072a1f..0043340d1b87a 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/index.ts +++ b/apps/server/src/orchestration-v2/testkit/fixtures/index.ts @@ -196,9 +196,12 @@ import { fxEarlyStopInput } from "./fx_early_stop/input.ts"; import { assertFxEarlyStopOutput } from "./fx_early_stop/fx_output.ts"; import { fxSubagentInput } from "./fx_subagent/input.ts"; import { assertFxSubagentOutput } from "./fx_subagent/fx_output.ts"; +import { fxSubagentStopInput } from "./fx_subagent_stop/input.ts"; +import { assertFxSubagentStopOutput } from "./fx_subagent_stop/fx_output.ts"; import { assertTurnInterruptMidToolClaudeOutput } from "./turn_interrupt_mid_tool/claude_output.ts"; import { assertTurnInterruptMidToolCodexOutput } from "./turn_interrupt_mid_tool/codex_output.ts"; import { assertTurnInterruptMidToolCursorOutput } from "./turn_interrupt_mid_tool/cursor_output.ts"; +import { assertTurnInterruptMidToolFxOutput } from "./turn_interrupt_mid_tool/fx_output.ts"; import { turnInterruptMidToolInput } from "./turn_interrupt_mid_tool/input.ts"; import { assertTurnInterruptMidToolPiOutput } from "./turn_interrupt_mid_tool/pi_output.ts"; import { assertTurnInterruptRestartClaudeOutput } from "./turn_interrupt_restart/claude_output.ts"; @@ -1644,6 +1647,18 @@ export const ORCHESTRATOR_REPLAY_FIXTURES: ReadonlyArray {}, 30000)\"`. Do not answer until it completes, then respond exactly: interrupt fixture should not finish naturally."; const COMMAND_OUTPUT_STREAMING_COMMAND = diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/fx_output.ts b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/fx_output.ts new file mode 100644 index 0000000000000..54e3c806df07a --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/fx_output.ts @@ -0,0 +1,64 @@ +import { assert } from "@effect/vitest"; +import type { ProviderReplayTranscript } from "@t3tools/contracts"; + +import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts"; +import { + assertBaseProjection, + assertSemanticProjectionIntegrity, + assertTurnItemTypes, + assertUserMessagesInclude, + assertVisibleTurnItemsMirrorLocalTurnItems, + projectionFor, + TURN_INTERRUPT_MID_TOOL_PROMPT, +} from "../shared.ts"; + +function acpFrame(entry: ProviderReplayTranscript["entries"][number]) { + return entry.type === "runtime_exit" + ? undefined + : (entry.frame as { + readonly method?: string; + readonly params?: { readonly update?: { sessionUpdate?: string; status?: string } }; + }); +} + +/** fx 0.0.15 answers Stop by failing the running command itself; T3 still shows it interrupted. */ +export function assertTurnInterruptMidToolFxOutput( + result: OrchestratorV2ScenarioResult, + transcript: ProviderReplayTranscript, +) { + const cancelIndex = transcript.entries.findIndex( + (entry) => entry.type === "expect_outbound" && acpFrame(entry)?.method === "session/cancel", + ); + const failedAfterCancel = transcript.entries.findIndex( + (entry, index) => + index > cancelIndex && + entry.type === "emit_inbound" && + acpFrame(entry)?.params?.update?.sessionUpdate === "tool_call_update" && + acpFrame(entry)?.params?.update?.status === "failed", + ); + assert.isAtLeast(cancelIndex, 0); + assert.isAbove(failedAfterCancel, cancelIndex, "fx fails the cut-off command after Stop"); + assertBaseProjection({ result, transcript, runCount: 1, runStatuses: ["interrupted"] }); + + const projection = projectionFor(result, transcript.scenario); + assertSemanticProjectionIntegrity(projection); + assertVisibleTurnItemsMirrorLocalTurnItems(projection); + assertTurnItemTypes(projection, [ + "user_message", + "command_execution", + "run_interrupt_request", + "run_interrupt_result", + ]); + assertUserMessagesInclude(projection, [TURN_INTERRUPT_MID_TOOL_PROMPT]); + + const commandItem = projection.turnItems.find((item) => item.type === "command_execution"); + assert.isDefined(commandItem); + assert.include(commandItem.input, "node -e"); + assert.equal(commandItem.status, "interrupted"); + assert.equal( + projection.turnItems.find((item) => item.type === "run_interrupt_result")?.status, + "interrupted", + ); + assert.equal(projection.providerTurns[0]?.status, "interrupted"); + assert.equal(projection.providerThreads[0]?.status, "idle"); +} diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/fx_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/fx_transcript.ndjson new file mode 100644 index 0000000000000..0b4e36962f7bb --- /dev/null +++ b/apps/server/src/orchestration-v2/testkit/fixtures/turn_interrupt_mid_tool/fx_transcript.ndjson @@ -0,0 +1,33 @@ +{"type":"transcript_start","provider":"fx","protocol":"acp.ndjson-jsonrpc","version":"1","scenario":"turn_interrupt_mid_tool","metadata":{"generatedBy":"live-fx-recorder","fxVersion":"0.0.15","normalization":"Session ids are fx-session-NN, the workspace is , HOME is /home/fx-replay and the recording user is fx-replay. T3-owned prompt text, MCP servers and initialize params other than clientCapabilities and _meta are . User skill names, emails and token-shaped strings are removed. Timestamps are kept as recorded.","droppedFrames":0}} +{"type":"expect_outbound","label":"request:initialize","frame":{"kind":"request","method":"initialize","params":{"protocolVersion":2,"info":"","capabilities":"","clientCapabilities":{"fs":{"readTextFile":false,"writeTextFile":false},"terminal":false,"session":{"notices":{}},"elicitation":{"form":{}}},"clientInfo":""}}} +{"type":"emit_inbound","label":"response:initialize","frame":{"kind":"response","method":"initialize","result":{"protocolVersion":1,"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":true,"audio":false,"embeddedContext":true},"mcpCapabilities":{"http":true,"sse":false,"acp":false},"sessionCapabilities":{"list":{},"resume":{},"close":{},"systemPrompt":{}},"_meta":{"fx":{"steering":true}}},"agentInfo":{"name":"fx","title":"fx","version":"0.0.15"},"authMethods":[]}}} +{"type":"expect_outbound","label":"request:session/new","frame":{"kind":"request","method":"session/new","params":{"cwd":"","mcpServers":""}}} +{"type":"emit_inbound","label":"response:session/new","frame":{"kind":"response","method":"session/new","result":{"sessionId":"fx-session-01","configOptions":[{"id":"provider","name":"Provider","category":"model","type":"select","currentValue":"codex","options":[{"value":"gateway","name":"Vercel AI Gateway"},{"value":"codex","name":"Codex subscription"},{"value":"grok","name":"Grok subscription"}]},{"id":"model","name":"Model","category":"model","type":"select","currentValue":"gpt-6.1-sol","options":[{"value":"gpt-6.1-sol","name":"gpt-6.1-sol"},{"value":"gpt-6-astra","name":"gpt-6-astra"},{"value":"gpt-6.1-terra","name":"gpt-6.1-terra"},{"value":"gpt-6-sol","name":"gpt-6-sol"},{"value":"gpt-6-luna","name":"gpt-6-luna"},{"value":"gpt-5.6-sol","name":"gpt-5.6-sol"},{"value":"gpt-5.6-terra","name":"gpt-5.6-terra"},{"value":"gpt-5.6-luna","name":"gpt-5.6-luna"},{"value":"gpt-daybreak-blue-latest","name":"gpt-daybreak-blue-latest"}]},{"id":"mode","name":"Session Mode","description":"Controls how the agent requests permission","category":"mode","type":"select","currentValue":"auto","options":[{"value":"auto","name":"Auto","description":"Check actions with an automatic safety review instead of asking","_meta":{"fx":{"permissionMode":"auto"}}},{"value":"ask","name":"Ask","description":"Request permission before making any changes","_meta":{"fx":{"permissionMode":"ask"}}},{"value":"full-access","name":"Full access","description":"Run every action without asking, outside the sandbox","_meta":{"fx":{"permissionMode":"yolo"}}}]},{"id":"effort","name":"Reasoning Effort","description":"Controls how much the model thinks before responding","category":"thought_level","type":"select","currentValue":"auto","options":[{"value":"auto","name":"default"},{"value":"low","name":"low"},{"value":"medium","name":"medium"},{"value":"high","name":"high"},{"value":"xhigh","name":"xhigh"},{"value":"max","name":"max"},{"value":"ultra","name":"ultra"}]},{"id":"fast","name":"Fast Mode","description":"Uses the model's fast lane","category":"model","type":"select","currentValue":"false","options":[{"value":"false","name":"off"},{"value":"true","name":"on"}]}],"modes":{"currentModeId":"auto","availableModes":[{"id":"auto","name":"Auto","description":"Check actions with an automatic safety review instead of asking"},{"id":"ask","name":"Ask","description":"Request permission before making any changes"},{"id":"full-access","name":"Full access","description":"Run every action without asking, outside the sandbox"}]}}}} +{"type":"emit_inbound","label":"notification:session/update:available_commands_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"available_commands_update","availableCommands":[]}}}} +{"type":"expect_outbound","label":"request:session/set_config_option","frame":{"kind":"request","method":"session/set_config_option","params":{"sessionId":"fx-session-01","configId":"effort","value":"medium"}}} +{"type":"emit_inbound","label":"response:session/set_config_option","frame":{"kind":"response","method":"session/set_config_option","result":{"configOptions":[{"id":"provider","name":"Provider","category":"model","type":"select","currentValue":"codex","options":[{"value":"gateway","name":"Vercel AI Gateway"},{"value":"codex","name":"Codex subscription"},{"value":"grok","name":"Grok subscription"}]},{"id":"model","name":"Model","category":"model","type":"select","currentValue":"gpt-6.1-sol","options":[{"value":"gpt-6.1-sol","name":"gpt-6.1-sol"},{"value":"gpt-6-astra","name":"gpt-6-astra"},{"value":"gpt-6.1-terra","name":"gpt-6.1-terra"},{"value":"gpt-6-sol","name":"gpt-6-sol"},{"value":"gpt-6-luna","name":"gpt-6-luna"},{"value":"gpt-5.6-sol","name":"gpt-5.6-sol"},{"value":"gpt-5.6-terra","name":"gpt-5.6-terra"},{"value":"gpt-5.6-luna","name":"gpt-5.6-luna"},{"value":"gpt-daybreak-blue-latest","name":"gpt-daybreak-blue-latest"}]},{"id":"mode","name":"Session Mode","description":"Controls how the agent requests permission","category":"mode","type":"select","currentValue":"auto","options":[{"value":"auto","name":"Auto","description":"Check actions with an automatic safety review instead of asking","_meta":{"fx":{"permissionMode":"auto"}}},{"value":"ask","name":"Ask","description":"Request permission before making any changes","_meta":{"fx":{"permissionMode":"ask"}}},{"value":"full-access","name":"Full access","description":"Run every action without asking, outside the sandbox","_meta":{"fx":{"permissionMode":"yolo"}}}]},{"id":"effort","name":"Reasoning Effort","description":"Controls how much the model thinks before responding","category":"thought_level","type":"select","currentValue":"medium","options":[{"value":"auto","name":"default"},{"value":"low","name":"low"},{"value":"medium","name":"medium"},{"value":"high","name":"high"},{"value":"xhigh","name":"xhigh"},{"value":"max","name":"max"},{"value":"ultra","name":"ultra"}]},{"id":"fast","name":"Fast Mode","description":"Uses the model's fast lane","category":"model","type":"select","currentValue":"false","options":[{"value":"false","name":"off"},{"value":"true","name":"on"}]}]}}} +{"type":"expect_outbound","label":"request:session/set_config_option","frame":{"kind":"request","method":"session/set_config_option","params":{"sessionId":"fx-session-01","configId":"mode","value":"ask"}}} +{"type":"emit_inbound","label":"response:session/set_config_option","frame":{"kind":"response","method":"session/set_config_option","result":{"configOptions":[{"id":"provider","name":"Provider","category":"model","type":"select","currentValue":"codex","options":[{"value":"gateway","name":"Vercel AI Gateway"},{"value":"codex","name":"Codex subscription"},{"value":"grok","name":"Grok subscription"}]},{"id":"model","name":"Model","category":"model","type":"select","currentValue":"gpt-6.1-sol","options":[{"value":"gpt-6.1-sol","name":"gpt-6.1-sol"},{"value":"gpt-6-astra","name":"gpt-6-astra"},{"value":"gpt-6.1-terra","name":"gpt-6.1-terra"},{"value":"gpt-6-sol","name":"gpt-6-sol"},{"value":"gpt-6-luna","name":"gpt-6-luna"},{"value":"gpt-5.6-sol","name":"gpt-5.6-sol"},{"value":"gpt-5.6-terra","name":"gpt-5.6-terra"},{"value":"gpt-5.6-luna","name":"gpt-5.6-luna"},{"value":"gpt-daybreak-blue-latest","name":"gpt-daybreak-blue-latest"}]},{"id":"mode","name":"Session Mode","description":"Controls how the agent requests permission","category":"mode","type":"select","currentValue":"ask","options":[{"value":"auto","name":"Auto","description":"Check actions with an automatic safety review instead of asking","_meta":{"fx":{"permissionMode":"auto"}}},{"value":"ask","name":"Ask","description":"Request permission before making any changes","_meta":{"fx":{"permissionMode":"ask"}}},{"value":"full-access","name":"Full access","description":"Run every action without asking, outside the sandbox","_meta":{"fx":{"permissionMode":"yolo"}}}]},{"id":"effort","name":"Reasoning Effort","description":"Controls how much the model thinks before responding","category":"thought_level","type":"select","currentValue":"medium","options":[{"value":"auto","name":"default"},{"value":"low","name":"low"},{"value":"medium","name":"medium"},{"value":"high","name":"high"},{"value":"xhigh","name":"xhigh"},{"value":"max","name":"max"},{"value":"ultra","name":"ultra"}]},{"id":"fast","name":"Fast Mode","description":"Uses the model's fast lane","category":"model","type":"select","currentValue":"false","options":[{"value":"false","name":"off"},{"value":"true","name":"on"}]}]}}} +{"type":"expect_outbound","label":"request:session/prompt","frame":{"kind":"request","method":"session/prompt","params":{"sessionId":"fx-session-01","prompt":[{"type":"text","text":"\n\n\n\n\nRun this exact local command: `node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"`. Do not answer until it completes, then respond exactly: interrupt fixture should not finish naturally.\n"}]}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"c6368a3ae8b2d57bec270c4e9afcaa54","content":{"type":"text","text":"I’ll run the"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"c6368a3ae8b2d57bec270c4e9afcaa54","content":{"type":"text","text":" exact"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"c6368a3ae8b2d57bec270c4e9afcaa54","content":{"type":"text","text":" command"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"c6368a3ae8b2d57bec270c4e9afcaa54","content":{"type":"text","text":" and"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"c6368a3ae8b2d57bec270c4e9afcaa54","content":{"type":"text","text":" wait"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"c6368a3ae8b2d57bec270c4e9afcaa54","content":{"type":"text","text":" for"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"c6368a3ae8b2d57bec270c4e9afcaa54","content":{"type":"text","text":" it"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"c6368a3ae8b2d57bec270c4e9afcaa54","content":{"type":"text","text":" to"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"c6368a3ae8b2d57bec270c4e9afcaa54","content":{"type":"text","text":" complete"}}}}} +{"type":"emit_inbound","label":"notification:session/update:agent_message_chunk","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"agent_message_chunk","messageId":"c6368a3ae8b2d57bec270c4e9afcaa54","content":{"type":"text","text":".\n"}}}}} +{"type":"emit_inbound","label":"notification:session/update:tool_call","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"tool_call","toolCallId":"call_573669dc43cb4393aeceab4ea77cffbb","name":"shell","title":"Running","kind":"execute","status":"pending","rawInput":{"action":"run","command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","yield_time_ms":1000},"_meta":{"fx":{"toolCall":{"internal":false}}}}}}} +{"type":"emit_inbound","label":"request:session/request_permission","frame":{"kind":"request","method":"session/request_permission","params":{"sessionId":"fx-session-01","toolCall":{"toolCallId":"call_573669dc43cb4393aeceab4ea77cffbb","name":"shell","title":"shell.run node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","kind":"execute","status":"pending","rawInput":{"action":"run","command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","yield_time_ms":1000}},"options":[{"optionId":"allow_once","name":"Allow once","kind":"allow_once"},{"optionId":"allow_always","name":"Allow for this session","kind":"allow_always"},{"optionId":"reject_once","name":"Reject","kind":"reject_once"}]}}} +{"type":"expect_outbound","label":"response:session/request_permission","frame":{"kind":"response","method":"session/request_permission","result":{"outcome":{"outcome":"selected","optionId":"allow_once"}}}} +{"type":"emit_inbound","label":"notification:session/update:tool_call_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_573669dc43cb4393aeceab4ea77cffbb","status":"in_progress"}}}} +{"type":"expect_outbound","label":"notification:session/cancel","frame":{"kind":"notification","method":"session/cancel","params":{"sessionId":"fx-session-01"}}} +{"type":"emit_inbound","label":"notification:session/update:tool_call_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_573669dc43cb4393aeceab4ea77cffbb","status":"failed","content":[{"type":"content","content":{"type":"text","text":""}}],"command_result":{"kind":"command","command":"node -e \"console.log('interrupt fixture tool started'); setTimeout(() => {}, 30000)\"","cwd":"","exit_code":null,"signal":null,"timed_out":false,"termination_indeterminate":true,"duration_ms":null,"stdout_bytes":0,"stderr_bytes":0,"truncated":false,"output_file":null,"stdout_file":null,"stderr_file":null}}}}} +{"type":"emit_inbound","label":"notification:session/update:session_info_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"session_info_update","title":"Run Interrupt Fixture Tool Locally","updatedAt":"2026-10-29T14:22:39Z"}}}} +{"type":"emit_inbound","label":"notification:session/update:usage_update","frame":{"kind":"notification","method":"session/update","params":{"sessionId":"fx-session-01","update":{"sessionUpdate":"usage_update","used":6405,"size":500000}}}} +{"type":"emit_inbound","label":"response:session/prompt","frame":{"kind":"response","method":"session/prompt","result":{"stopReason":"cancelled","usage":{"inputTokens":6336,"outputTokens":69,"cacheReadTokens":0,"cacheWriteTokens":0,"reasoningTokens":0}}}} +{"type":"expect_outbound","label":"request:session/close","frame":{"kind":"request","method":"session/close","params":{"sessionId":"fx-session-01"}}} +{"type":"emit_inbound","label":"response:session/close","frame":{"kind":"response","method":"session/close","result":{}}} +{"type":"runtime_exit","status":"success"} diff --git a/docs/user/providers-fx.md b/docs/user/providers-fx.md index 430f7a705a499..0dec44680c0a5 100644 --- a/docs/user/providers-fx.md +++ b/docs/user/providers-fx.md @@ -15,8 +15,14 @@ T3 Code can run [fx](https://fx.sh). fx uses the login on the machine that runs If `fx` is not on the server's `PATH`, set **Binary path** to the executable. +T3 Code supports fx 0.0.13 and later. Use the latest release when you can; macOS needs version 15 or later for fx releases after 0.0.13. + Every fx instance on the same machine shares that machine's fx login. T3 Code does not keep a separate login per instance. +## Update fx + +When a newer fx release is out, the fx card in **Settings → Providers** shows it. **Update now** runs `fx upgrade` on the binary that instance uses, including one set in **Binary path**. You can also run `fx upgrade` on that machine yourself. T3 Code checks for new stable releases only; on fx's dev channel, run `fx upgrade` yourself to get newer dev builds. 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 +44,7 @@ fx offers two composer modes. T3 Code does not offer **Full access** or **Auto-accept edits** for fx. A thread already set to one of those runs in **Supervised**. Changing the permission mode restarts the connection and reloads the same fx session. -On fx versions after [vercel-labs/fx#1189](https://github.com/vercel-labs/fx/pull/1189), fx saves the mode T3 Code sets as its default permission mode. T3 Code sets it each time it opens an fx thread, so fx in the terminal then starts in that thread's mode. +On fx 0.0.14 and later, fx saves the mode T3 Code sets as its default permission mode. T3 Code sets it each time it opens an fx thread, so fx in the terminal then starts in that thread's mode. A message steered into a running turn stops that turn, and fx answers the new message in the same session. diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 398eac2170e70..3af300061f1ab 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -1058,6 +1058,8 @@ interface ActiveAcpTurn { contextUsage: ThreadTokenUsageSnapshot | null; nativeMetadata: OrchestrationV2ProviderThreadNativeMetadata | null; readonly tools: Map; + /** Tools the agent failed after Stop; later updates keep them interrupted. */ + readonly stoppedToolCallIds: Set; readonly toolUpdatesSkipped: Map; readonly toolStartedAt: Map; readonly subagents: Map; @@ -3441,6 +3443,22 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ? toolCall : { ...toolCall, backgroundCommandTaskId }, ); + // fx 0.0.15 fails the tools a Stop cut off; they read as interrupted, like ones left open. + const previousStatus = previous === undefined ? undefined : toolStatus(previous.status); + if ( + context.interrupted && + toolCall.status === "failed" && + (previousStatus === "pending" || + previousStatus === "running" || + previousStatus === "waiting") + ) { + context.stoppedToolCallIds.add(toolCall.toolCallId); + } + const stoppedStatus = + projectedStatus ?? + (toolCall.status === "failed" && context.stoppedToolCallIds.has(toolCall.toolCallId) + ? "interrupted" + : undefined); if ( followsAgentWorkState && projectedStatus === undefined && @@ -3545,7 +3563,12 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ? undefined : extractSubagentUpdate(toolCall); if (subagentUpdate !== undefined) { - yield* emitSubagent(context, subagentUpdate); + yield* emitSubagent( + context, + stoppedStatus === "interrupted" && subagentUpdate.status === "failed" + ? { ...subagentUpdate, status: "interrupted" } + : subagentUpdate, + ); if (followsAgentWorkState) yield* syncNativeChildren(context); if (subagentUpdate.suppressNormalTool !== false) { yield* rearmDeferredFinalize(context); @@ -3553,7 +3576,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } } if ( - projectedStatus === undefined && + stoppedStatus === undefined && !shouldPersistToolUpdate( context, toolCall.toolCallId, @@ -3565,7 +3588,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( yield* rearmDeferredFinalize(context); return; } - const status = projectedStatus ?? toolStatus(toolCall.status); + const status = stoppedStatus ?? toolStatus(toolCall.status); const now = yield* DateTime.now; const nativeItemId = `${context.nativeThreadId}:tool:${toolCall.toolCallId}`; const ordinal = yield* resolveItemOrdinal(context, nativeItemId); @@ -7439,6 +7462,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( contextUsage: rememberedContextUsage ?? turnInput.providerThread.contextUsage ?? null, nativeMetadata: initialNativeMetadata, tools: new Map(), + stoppedToolCallIds: new Set(), toolUpdatesSkipped: new Map(), toolStartedAt: new Map(), subagents: new Map(), diff --git a/packages/provider-fx/src/server/adapter.ts b/packages/provider-fx/src/server/adapter.ts index dfb956e6d13f1..433f800035400 100644 --- a/packages/provider-fx/src/server/adapter.ts +++ b/packages/provider-fx/src/server/adapter.ts @@ -97,6 +97,16 @@ const decodeFxSubagentResult = Schema.decodeUnknownOption( ), ); const decodeJsonString = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.String)); +const decodeFxToolError = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + error: Schema.Struct({ + message: Schema.String, + details: Schema.optional(Schema.Struct({ error: Schema.optional(Schema.String) })), + }), + }), + ), +); /** * fx clips every tool result to 199 bytes, so a long sub-agent reply arrives @@ -125,6 +135,10 @@ function extractFxSubagentUpdate(tool: AcpToolCallState): AcpAdapterV2SubagentUp const request = "request" in input.value ? input.value.request : input.value; const text = Option.getOrUndefined(decodeFxToolContent(tool.data.content))?.[0]?.content.text; const outcome = text === undefined ? undefined : fxSubagentOutcome(text); + // A failed call carries fx's error envelope, e.g. `Cancelled` after Stop. + const failure = Option.getOrUndefined( + text === undefined ? Option.none() : decodeFxToolError(text), + )?.error; const status = tool.status === "completed" ? outcome?.ok === false @@ -140,7 +154,10 @@ function extractFxSubagentUpdate(tool: AcpToolCallState): AcpAdapterV2SubagentUp title: request.action === "message" ? request.agent : null, model: request.model ?? null, status, - result: status === "running" ? null : (outcome?.result ?? text ?? null), + result: + status === "running" + ? null + : (outcome?.result ?? failure?.details?.error ?? failure?.message ?? text ?? null), }; } diff --git a/packages/provider-fx/src/server/driver.ts b/packages/provider-fx/src/server/driver.ts index c922b315b56ce..e9cbc2cf7a226 100644 --- a/packages/provider-fx/src/server/driver.ts +++ b/packages/provider-fx/src/server/driver.ts @@ -6,9 +6,11 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import * as HttpClient from "effect/http/HttpClient"; import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; import type * as ProviderHost from "@t3tools/provider-core/server/ProviderHost"; +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; import { type FxAdapterV2DriverEnv, makeFxAdapterV2Driver } from "./adapter.ts"; import { ProviderDriverError } from "@t3tools/provider-core/server/errors"; import { @@ -25,7 +27,12 @@ import { import { withInstanceIdentity } from "@t3tools/provider-core/server/instanceIdentity"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { discoverFxSkills } from "./skills.ts"; -import { makeManualOnlyProviderMaintenanceCapabilities } from "@t3tools/provider-core/server/maintenanceResolver"; +import { + enrichProviderSnapshotWithVersionAdvisory, + makeCachedProviderMaintenanceResolution, + resolveProviderMaintenanceCapabilitiesEffect, +} from "@t3tools/provider-core/server/maintenanceResolver"; +import { fetchLatestFxVersion, makeFxMaintenance } from "./maintenance.ts"; import { haveProviderSnapshotSettingsChanged, makeProviderSnapshotSettingsSource, @@ -35,12 +42,6 @@ import type { ProviderTextGeneration } from "@t3tools/provider-core/server/textG import { FX_DRIVER_KIND } from "./acpSupport.ts"; const decodeFxSettings = Schema.decodeSync(FxSettings); -// fx installs from its own setup script and updates with `fx upgrade`; there -// is no npm package to read a latest version from. -const MAINTENANCE = makeManualOnlyProviderMaintenanceCapabilities({ - provider: FX_DRIVER_KIND, - packageName: null, -}); // See `supportsTextGeneration` in status.ts. const unsupportedTextGeneration: ProviderTextGeneration = (() => { @@ -65,8 +66,10 @@ export type FxDriverEnv = | ChildProcessSpawner.ChildProcessSpawner | Crypto.Crypto | FileSystem.FileSystem + | HttpClient.HttpClient | Path.Path - | ProviderHost.ProviderHost; + | ProviderHost.ProviderHost + | ProviderLatestVersions.ProviderLatestVersions; export const FxDriver: ProviderDriver = { driverKind: FX_DRIVER_KIND, @@ -87,6 +90,8 @@ export const FxDriver: ProviderDriver = { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const httpClient = yield* HttpClient.HttpClient; + const latestVersions = yield* ProviderLatestVersions.ProviderLatestVersions; const processEnv = yield* mergeProviderInstanceEnvironment( environment, yield* HostProcess.Environment, @@ -103,9 +108,21 @@ export const FxDriver: ProviderDriver = { continuationGroupKey: continuationIdentity.continuationKey, }); const effectiveConfig = { ...config, enabled } satisfies FxSettings; + const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( + resolveProviderMaintenanceCapabilitiesEffect( + makeFxMaintenance( + fetchLatestFxVersion().pipe(Effect.provideService(HttpClient.HttpClient, httpClient)), + ), + { binaryPath: effectiveConfig.binaryPath, env: processEnv }, + ).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), + ); const snapshotSettings = yield* makeProviderSnapshotSettingsSource(effectiveConfig); const snapshot = yield* makeManagedServerProvider>({ - resolveMaintenance: () => Effect.succeed(MAINTENANCE), + resolveMaintenance, getSettings: snapshotSettings.getSettings, streamSettings: snapshotSettings.streamSettings, haveSettingsChanged: haveProviderSnapshotSettingsChanged, @@ -115,6 +132,17 @@ export const FxDriver: ProviderDriver = { Effect.map(stampIdentity), Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), ), + enrichSnapshot: ({ settings, snapshot: currentSnapshot, publishSnapshot }) => + resolveMaintenance().pipe( + Effect.flatMap((maintenanceCapabilities) => + enrichProviderSnapshotWithVersionAdvisory(currentSnapshot, maintenanceCapabilities, { + enableProviderUpdateChecks: settings.enableProviderUpdateChecks, + }), + ), + Effect.flatMap(publishSnapshot), + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.provideService(ProviderLatestVersions.ProviderLatestVersions, latestVersions), + ), }).pipe( Effect.mapError( (cause) => diff --git a/packages/provider-fx/src/server/maintenance.test.ts b/packages/provider-fx/src/server/maintenance.test.ts new file mode 100644 index 0000000000000..044049bd4db8e --- /dev/null +++ b/packages/provider-fx/src/server/maintenance.test.ts @@ -0,0 +1,98 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; + +import * as ProviderLatestVersions from "@t3tools/provider-core/server/ProviderLatestVersions"; +import { enrichProviderSnapshotWithVersionAdvisory } from "@t3tools/provider-core/server/maintenanceResolver"; +import { fetchLatestFxVersion, makeFxMaintenance } from "./maintenance.ts"; + +const snapshot: ServerProvider = { + instanceId: ProviderInstanceId.make("fx"), + driver: ProviderDriverKind.make("fx"), + enabled: true, + installed: true, + version: "0.0.13", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-10-29T00:00:00.000Z", + models: [], + skills: [], + slashCommands: [], +}; + +const serving = (response: Response) => + HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, response))); + +it.layer(NodeServices.layer)("fx maintenance", (it) => { + it.effect("offers `fx upgrade` on the instance's own binary once a newer release is out", () => + Effect.gen(function* () { + const binaryPath = "/opt/tools/custom fx"; + const capabilities = yield* makeFxMaintenance( + fetchLatestFxVersion().pipe( + Effect.provideService(HttpClient.HttpClient, serving(new Response("v0.0.15\n"))), + ), + "24.0.0", + ).resolve({ + binaryPath, + resolvedCommandPath: binaryPath, + realCommandPath: "/opt/tools/fx-real", + platform: "darwin", + env: { HOME: "/home/fx" }, + }); + expect(capabilities.update).toMatchObject({ + executable: binaryPath, + args: ["upgrade"], + command: `'${binaryPath}' upgrade`, + lockKey: "fx:/opt/tools/fx-real", + env: { HOME: "/home/fx" }, + }); + + const enriched = yield* enrichProviderSnapshotWithVersionAdvisory(snapshot, capabilities, { + enableProviderUpdateChecks: true, + }).pipe( + Effect.provideService(HttpClient.HttpClient, serving(new Response("unused"))), + Effect.provide(ProviderLatestVersions.layer), + ); + expect(enriched.versionAdvisory).toMatchObject({ + status: "behind_latest", + currentVersion: "0.0.13", + latestVersion: "0.0.15", + canUpdate: true, + updateCommand: `'${binaryPath}' upgrade`, + }); + }), + ); + + it.effect("leaves the latest version unknown when the release file is unusable", () => + Effect.gen(function* () { + for (const response of [ + new Response("not found"), + new Response("v0.0.15", { status: 503 }), + ]) { + const latest = yield* fetchLatestFxVersion().pipe( + Effect.provideService(HttpClient.HttpClient, serving(response)), + ); + expect(latest).toBeNull(); + } + expect((yield* makeFxMaintenance(Effect.succeed("0.0.15")).resolve(null)).update).toBeNull(); + }), + ); + + it.effect("offers no update or latest version on macOS 14, which newer fx cannot run on", () => + Effect.gen(function* () { + const binaryPath = "/opt/tools/fx"; + const capabilities = yield* makeFxMaintenance(Effect.succeed("0.0.15"), "23.6.0").resolve({ + binaryPath, + resolvedCommandPath: binaryPath, + realCommandPath: binaryPath, + platform: "darwin", + env: {}, + }); + expect(capabilities.update).toBeNull(); + expect(capabilities.lookupLatestVersion).toBeUndefined(); + }), + ); +}); diff --git a/packages/provider-fx/src/server/maintenance.ts b/packages/provider-fx/src/server/maintenance.ts new file mode 100644 index 0000000000000..20452f755110e --- /dev/null +++ b/packages/provider-fx/src/server/maintenance.ts @@ -0,0 +1,57 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeOS from "node:os"; +import * as Effect from "effect/Effect"; +import * as HttpClient from "effect/http/HttpClient"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; + +import { + makeManualOnlyProviderMaintenanceCapabilities, + makeProviderMaintenanceCapabilities, + type ProviderMaintenanceCapabilitiesResolver, +} from "@t3tools/provider-core/server/maintenanceResolver"; +import { FX_DRIVER_KIND } from "./acpSupport.ts"; + +/** The stable release `fx upgrade` and fx's setup script install. */ +const FX_LATEST_RELEASE_URL = "https://releases.fx.sh/latest.txt"; + +export const fetchLatestFxVersion = Effect.fn("fetchLatestFxVersion")(function* () { + const client = yield* HttpClient.HttpClient; + return yield* client.execute(HttpClientRequest.get(FX_LATEST_RELEASE_URL)).pipe( + Effect.flatMap((response) => + response.status >= 200 && response.status < 300 ? response.text : Effect.succeed(""), + ), + Effect.map((body) => body.trim().match(/^v?(\d+\.\d+\.\d+)$/)?.[1] ?? null), + Effect.timeout(4_000), + Effect.orElseSucceed(() => null), + ); +}); + +/** + * `fx upgrade` replaces the executable it runs from, so the update runs the + * instance's resolved binary, wherever `binaryPath` points. fx after 0.0.13 + * needs macOS 15 (Darwin 24) and `fx upgrade` does not check, so older Macs + * get no update offer or latest-version check. + */ +export const makeFxMaintenance = ( + lookupLatestVersion: Effect.Effect, + osRelease: string = NodeOS.release(), +): ProviderMaintenanceCapabilitiesResolver => ({ + resolve: (context) => + Effect.succeed( + context === null || (context.platform === "darwin" && Number.parseInt(osRelease, 10) < 24) + ? makeManualOnlyProviderMaintenanceCapabilities({ + provider: FX_DRIVER_KIND, + packageName: null, + }) + : makeProviderMaintenanceCapabilities({ + provider: FX_DRIVER_KIND, + packageName: null, + updateExecutable: context.resolvedCommandPath, + updateArgs: ["upgrade"], + updateLockKey: `fx:${context.realCommandPath}`, + platform: context.platform, + env: context.env, + lookupLatestVersion, + }), + ), +}); diff --git a/packages/provider-fx/src/server/status.ts b/packages/provider-fx/src/server/status.ts index ec8ad5bce9f8e..5139b4353cc37 100644 --- a/packages/provider-fx/src/server/status.ts +++ b/packages/provider-fx/src/server/status.ts @@ -78,7 +78,6 @@ const snapshot = ( listed?: ReadonlyArray, ): ServerProviderDraft => ({ ...buildServerProvider({ - // Gives the snapshot its version advisory; fx runs no enrichment pass. driver: FX_DRIVER_KIND, presentation: FX_PRESENTATION, enabled: settings.enabled,