From 06d1c57e1d1dcf6e49acd368a84c6ebec310751e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 31 Oct 2026 22:27:03 +0000 Subject: [PATCH 1/8] test(acp-registry): reproduce #28383 MiniMax Code work after prompt resolution MiniMax Code resolves session/prompt with end_turn and keeps streaming root-session updates from a native turn of its own. The test scripts that shape over ACP v1 and expects a continuation run that projects the late reply. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/AcpRegistryAdapterV2.test.ts | 98 ++++++++++++++++++- 1 file changed, 96 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 49ac36c553214..65c6d2d479e8d 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -204,6 +204,7 @@ const openClaudeReplaySession = Effect.fn("openClaudeReplaySession")(function* ( entries: ReadonlyArray, replayGate?: ProviderReplayGate, continuationEnabled = true, + agentId = "claude-acp", ) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -244,7 +245,7 @@ const openClaudeReplaySession = Effect.fn("openClaudeReplaySession")(function* ( const adapterOptions = { selfInvocation: yield* resolveSelfInvocation(), instanceId, - settings: yield* decodeAcpRegistryAdapterSettings({ agentId: "claude-acp" }), + settings: yield* decodeAcpRegistryAdapterSettings({ agentId }), environment: {}, ...(continuationEnabled ? { continuationRequests } : {}), makeRuntime: (runtimeInput: Parameters[0]) => { @@ -270,7 +271,7 @@ const openClaudeReplaySession = Effect.fn("openClaudeReplaySession")(function* ( runtimePolicy, }); yield* Effect.addFinalizer(() => Effect.sync(() => replayGate?.releaseAll())); - if (continuationEnabled) { + if (continuationEnabled && agentId === "claude-acp") { assert.isDefined( runtime.hasPendingBackgroundWork, "Claude ACP v2 must report background work after foreground idle", @@ -1983,6 +1984,99 @@ describe("AcpRegistryAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + it.effect( + "offers a continuation for MiniMax Code work that streams after its prompt resolves", + () => + Effect.gen(function* () { + const session = "minimax-session"; + const { update } = claudeFrames; + const gate = makeProviderReplayGate(["late"]); + const minimax = yield* openClaudeReplaySession( + [ + { + type: "expect_outbound", + frame: { kind: "request", method: "initialize", params: "" }, + }, + { + type: "emit_inbound", + frame: { + kind: "response", + method: "initialize", + result: { + protocolVersion: 1, + agentCapabilities: { loadSession: false }, + authMethods: [], + }, + }, + }, + { + type: "expect_outbound", + frame: { kind: "request", method: "session/new", params: "" }, + }, + { + type: "emit_inbound", + frame: { kind: "response", method: "session/new", result: { sessionId: session } }, + }, + { + type: "expect_outbound", + frame: { kind: "request", method: "session/prompt", params: "" }, + }, + { + type: "emit_inbound", + frame: { + kind: "response", + method: "session/prompt", + result: { stopReason: "end_turn" }, + }, + }, + // MiniMax keeps working in a native turn of its own after resolving the prompt. + { + ...update(session, { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: "Applying the chosen option." }, + }), + label: "late", + }, + update(session, { + sessionUpdate: "tool_call", + toolCallId: "late-edit", + title: "Edit src/app.ts", + kind: "edit", + status: "completed", + }), + update(session, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Applied the change." }, + }), + update(session, { sessionUpdate: "session_info_update", title: "late output sent" }), + ], + gate, + true, + "minimax-code", + ); + yield* minimax.runtime.startTurn(minimax.turnInput(1)); + assert.equal((yield* minimax.terminal).status, "completed"); + gate.release("late"); + yield* minimax.throughMarker("late output sent"); + assert.lengthOf( + minimax.offers, + 1, + "MiniMax output after the prompt resolved must offer a continuation run", + ); + yield* minimax.offers[0]!.dispatchIfCurrent!(Effect.void); + yield* minimax.runtime.startTurn(minimax.turnInput(2, { continuation: true })); + const continuation = yield* minimax.until( + (event) => + event.type === "message.updated" && + event.message.role === "assistant" && + event.message.text.includes("Applied the change."), + ); + const reply = continuation.at(-1)!; + if (reply.type !== "message.updated") throw new Error("Missing late reply"); + assert.equal(reply.message.runId, minimax.turnInput(2).runId); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.effect("fails active Claude work and releases its children when the process exits", () => Effect.gen(function* () { const session = yield* openClaudeReplaySession([ From b403cbeceb21908bf3c509169c1fd6f74ec5f63e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 31 Oct 2026 22:30:03 +0000 Subject: [PATCH 2/8] fix(acp-registry): MiniMax Code work after its prompt resolves shows as a continuation run MiniMax Code resolves `session/prompt` with end_turn after a questionnaire answer or background-task delivery, then keeps streaming a native turn and rejects new prompts until it ends. T3 dropped that output and showed the thread idle, so the user's follow-up hit MiniMax's busy error. Enable the shared post-settle continuation path for the `minimax-code` registry agent, the same flag pair Grok uses, so late output becomes a provider continuation run and follow-ups queue behind it. Fixes pingdotgg/t3code#28383 Co-Authored-By: Claude Opus 5.5 --- packages/provider-acp-registry/src/server/adapter.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/provider-acp-registry/src/server/adapter.ts b/packages/provider-acp-registry/src/server/adapter.ts index 6c2b4a9f6a149..1be33d9ae0483 100644 --- a/packages/provider-acp-registry/src/server/adapter.ts +++ b/packages/provider-acp-registry/src/server/adapter.ts @@ -227,6 +227,10 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu ...(registryAgentId === "mistral-vibe" ? { registerExtensions: registerMistralVibeAcpExtensions } : {}), + // MiniMax Code keeps working in a native turn after it resolves `session/prompt`. + ...(registryAgentId === "minimax-code" + ? { deferFinalizeForBackgroundWork: true, enablePostSettleContinuation: true } + : {}), ...(isClaude ? { followAgentWorkState: true, From 30035dfbc3768944ca7136325d60137dfa71b81a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 31 Oct 2026 22:50:08 +0000 Subject: [PATCH 3/8] fix(acp-registry): MiniMax Code continuation stays open while its tool runs MiniMax Code keeps a native turn running after `session/prompt` resolves and sends no turn-end signal over ACP. Two gaps remained in the shared continuation path: - Thoughts and still-running tool calls after the prompt resolved never offered a continuation, so the thread looked idle while MiniMax was busy. - A continuation run finalized after 3 s of quiet even while an ordinary tool was still running, showing the tool as completed and dispatching queued follow-ups to a busy agent. Add `ownsNativeTurnAfterPrompt` to the ACP flavor, set only for minimax-code. With it, any post-settle wake evidence offers the continuation, and the deferred finalize (rearm, schedule, and the timer's own re-check) holds while a foreground tool is pending or running. The hold only applies once the prompt settled, so prompt responses still end their turns, and Stop still finalizes the run as interrupted and sends `session/cancel`. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/AcpRegistryAdapterV2.test.ts | 251 +++++++++++++++--- .../src/server/adapter.ts | 6 +- packages/provider-acp/src/server/adapter.ts | 28 +- 3 files changed, 242 insertions(+), 43 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 65c6d2d479e8d..4e57c0f7510bb 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -19,6 +19,7 @@ import * as Fiber from "effect/Fiber"; import * as DateTime from "effect/DateTime"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -200,6 +201,34 @@ const claudeFrames = { }, }; +const MINIMAX_ROOT_SESSION = "minimax-session"; + +/** MiniMax Code (@minimax-ai/code 0.2.7, ACP v1) resolves its prompt and keeps a native turn running. */ +const minimaxResolvedPrompt = [ + { type: "expect_outbound", frame: { kind: "request", method: "initialize", params: "" } }, + { + type: "emit_inbound", + frame: { + kind: "response", + method: "initialize", + result: { protocolVersion: 1, agentCapabilities: { loadSession: false }, authMethods: [] }, + }, + }, + { type: "expect_outbound", frame: { kind: "request", method: "session/new", params: "" } }, + { + type: "emit_inbound", + frame: { kind: "response", method: "session/new", result: { sessionId: MINIMAX_ROOT_SESSION } }, + }, + { + type: "expect_outbound", + frame: { kind: "request", method: "session/prompt", params: "" }, + }, + { + type: "emit_inbound", + frame: { kind: "response", method: "session/prompt", result: { stopReason: "end_turn" } }, + }, +]; + const openClaudeReplaySession = Effect.fn("openClaudeReplaySession")(function* ( entries: ReadonlyArray, replayGate?: ProviderReplayGate, @@ -1988,48 +2017,12 @@ describe("AcpRegistryAdapterV2", () => { "offers a continuation for MiniMax Code work that streams after its prompt resolves", () => Effect.gen(function* () { - const session = "minimax-session"; + const session = MINIMAX_ROOT_SESSION; const { update } = claudeFrames; const gate = makeProviderReplayGate(["late"]); const minimax = yield* openClaudeReplaySession( [ - { - type: "expect_outbound", - frame: { kind: "request", method: "initialize", params: "" }, - }, - { - type: "emit_inbound", - frame: { - kind: "response", - method: "initialize", - result: { - protocolVersion: 1, - agentCapabilities: { loadSession: false }, - authMethods: [], - }, - }, - }, - { - type: "expect_outbound", - frame: { kind: "request", method: "session/new", params: "" }, - }, - { - type: "emit_inbound", - frame: { kind: "response", method: "session/new", result: { sessionId: session } }, - }, - { - type: "expect_outbound", - frame: { kind: "request", method: "session/prompt", params: "" }, - }, - { - type: "emit_inbound", - frame: { - kind: "response", - method: "session/prompt", - result: { stopReason: "end_turn" }, - }, - }, - // MiniMax keeps working in a native turn of its own after resolving the prompt. + ...minimaxResolvedPrompt, { ...update(session, { sessionUpdate: "agent_thought_chunk", @@ -2077,6 +2070,188 @@ describe("AcpRegistryAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + it.effect("offers a continuation for MiniMax Code thoughts and running tools alone", () => + Effect.gen(function* () { + const { update } = claudeFrames; + const gate = makeProviderReplayGate(["late"]); + const minimax = yield* openClaudeReplaySession( + [ + ...minimaxResolvedPrompt, + { + ...update(MINIMAX_ROOT_SESSION, { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: "Running the checks." }, + }), + label: "late", + }, + update(MINIMAX_ROOT_SESSION, { + sessionUpdate: "tool_call", + toolCallId: "late-check", + title: "Run tests", + kind: "execute", + status: "in_progress", + }), + update(MINIMAX_ROOT_SESSION, { + sessionUpdate: "session_info_update", + title: "late output sent", + }), + ], + gate, + true, + "minimax-code", + ); + yield* minimax.runtime.startTurn(minimax.turnInput(1)); + assert.equal((yield* minimax.terminal).status, "completed"); + gate.release("late"); + yield* minimax.throughMarker("late output sent"); + assert.lengthOf(minimax.offers, 1, "a busy MiniMax turn must not look idle"); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + + const openMiniMaxContinuationWithRunningTool = Effect.fn( + "openMiniMaxContinuationWithRunningTool", + )(function* (afterToolStarts: ReadonlyArray, labels: ReadonlyArray) { + const { update } = claudeFrames; + const gate = makeProviderReplayGate(["late", "tool", ...labels]); + const minimax = yield* openClaudeReplaySession( + [ + ...minimaxResolvedPrompt, + { + ...update(MINIMAX_ROOT_SESSION, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Running the checks." }, + }), + label: "late", + }, + update(MINIMAX_ROOT_SESSION, { + sessionUpdate: "session_info_update", + title: "late output sent", + }), + { + ...update(MINIMAX_ROOT_SESSION, { + sessionUpdate: "tool_call", + toolCallId: "check", + title: "Run tests", + kind: "execute", + status: "in_progress", + }), + label: "tool", + }, + update(MINIMAX_ROOT_SESSION, { + sessionUpdate: "session_info_update", + title: "tool running", + }), + ...afterToolStarts, + ], + gate, + true, + "minimax-code", + ); + yield* minimax.runtime.startTurn(minimax.turnInput(1)); + assert.equal((yield* minimax.terminal).status, "completed"); + gate.release("late"); + yield* minimax.throughMarker("late output sent"); + assert.lengthOf(minimax.offers, 1); + yield* minimax.offers[0]!.dispatchIfCurrent!(Effect.void); + yield* minimax.runtime.startTurn(minimax.turnInput(2, { continuation: true })); + const started = yield* minimax.until( + (event) => + event.type === "message.updated" && + event.message.runId === minimax.turnInput(2).runId && + event.message.text.includes("Running the checks."), + ); + gate.release("tool"); + const running = [...started, ...(yield* minimax.throughMarker("tool running"))]; + const providerTurn = running.findLast((event) => event.type === "provider_turn.updated"); + if (providerTurn?.type !== "provider_turn.updated") throw new Error("Missing continuation"); + return { minimax, gate, running, providerTurnId: providerTurn.providerTurn.id }; + }); + + const isCheckTool = (event: ProviderAdapter.ProviderAdapterV2Event, status: string) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId === "check" && + event.turnItem.status === status; + + it.effect( + "keeps a MiniMax Code continuation open while its tool runs past the quiet window", + () => + Effect.gen(function* () { + const { update } = claudeFrames; + const { minimax, gate, running } = yield* openMiniMaxContinuationWithRunningTool( + [ + { + ...update(MINIMAX_ROOT_SESSION, { + sessionUpdate: "session_info_update", + title: "before completion", + }), + label: "done", + }, + update(MINIMAX_ROOT_SESSION, { + sessionUpdate: "tool_call_update", + toolCallId: "check", + status: "completed", + }), + update(MINIMAX_ROOT_SESSION, { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Checks passed." }, + }), + update(MINIMAX_ROOT_SESSION, { + sessionUpdate: "session_info_update", + title: "tool done", + }), + ], + ["done"], + ); + assert.isTrue(running.some((event) => isCheckTool(event, "running"))); + yield* TestClock.adjust("10 seconds"); + gate.release("done"); + const quiet = [...running, ...(yield* minimax.throughMarker("before completion"))]; + assert.isFalse( + quiet.some((event) => event.type === "turn.terminal"), + "a running tool keeps the continuation run open", + ); + assert.isFalse( + quiet.some((event) => isCheckTool(event, "completed")), + "a running tool must not be shown completed", + ); + const done = yield* minimax.throughMarker("tool done"); + assert.isTrue(done.some((event) => isCheckTool(event, "completed"))); + assert.isFalse(done.some((event) => event.type === "turn.terminal")); + yield* TestClock.adjust("3 seconds"); + const terminal = yield* minimax.terminal; + assert.equal(terminal.status, "completed"); + assert.equal(terminal.runOrdinal, 2); + assert.lengthOf(minimax.offers, 1, "the tool's completion must not start another run"); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + + it.effect("stops a MiniMax Code continuation whose tool never finishes", () => + Effect.gen(function* () { + const { update } = claudeFrames; + const { minimax, providerTurnId } = yield* openMiniMaxContinuationWithRunningTool( + [ + { + type: "expect_outbound", + frame: { kind: "notification", method: "session/cancel", params: "" }, + }, + update(MINIMAX_ROOT_SESSION, { + sessionUpdate: "session_info_update", + title: "cancel received", + }), + ], + [], + ); + yield* minimax.runtime.interruptTurn({ + providerThread: minimax.providerThread, + providerTurnId, + }); + const stopped = yield* minimax.terminal; + assert.equal(stopped.status, "interrupted"); + assert.equal(stopped.runOrdinal, 2); + yield* minimax.throughMarker("cancel received"); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.effect("fails active Claude work and releases its children when the process exits", () => Effect.gen(function* () { const session = yield* openClaudeReplaySession([ diff --git a/packages/provider-acp-registry/src/server/adapter.ts b/packages/provider-acp-registry/src/server/adapter.ts index 1be33d9ae0483..0851600f9995f 100644 --- a/packages/provider-acp-registry/src/server/adapter.ts +++ b/packages/provider-acp-registry/src/server/adapter.ts @@ -229,7 +229,11 @@ export const makeAcpRegistryAdapterV2 = Effect.fn("makeAcpRegistryAdapterV2")(fu : {}), // MiniMax Code keeps working in a native turn after it resolves `session/prompt`. ...(registryAgentId === "minimax-code" - ? { deferFinalizeForBackgroundWork: true, enablePostSettleContinuation: true } + ? { + deferFinalizeForBackgroundWork: true, + enablePostSettleContinuation: true, + ownsNativeTurnAfterPrompt: true, + } : {}), ...(isClaude ? { diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index bd2552ab42183..e6eb994abb80c 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -411,6 +411,11 @@ export interface AcpAdapterV2Flavor { readonly terminateRuntimeProcessGroupOnInterrupt?: boolean; readonly preserveRuntimeOnSettledInterrupt?: boolean; readonly enablePostSettleContinuation?: boolean; + /** + * The agent keeps a native turn running after `session/prompt` resolves: any post-settle + * output offers a continuation, and a continuation stays open while a foreground tool runs. + */ + readonly ownsNativeTurnAfterPrompt?: boolean; /** Settle on native idle after the accepted user echo, only on negotiated ACP v2. */ readonly followAgentWorkState?: boolean; /** @@ -3415,12 +3420,24 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( acpSubagentStatusBlocksTurnSettlement(subagent.task.status), ); + // Only consulted once the prompt settled, so a prompt response still ends its turn. + const holdsDeferredFinalize = (context: ActiveAcpTurn): boolean => { + if (hasDeferredBackgroundWork(context)) return true; + if (flavor.ownsNativeTurnAfterPrompt !== true) return false; + for (const tool of context.tools.values()) { + if (acpCompletedTurnShouldTerminalizeTool(tool, { ...flavor, extractSubagentUpdate })) { + return true; + } + } + return false; + }; + const rearmDeferredFinalize = /* Provider events include streaming deltas; avoid a span for each update. */ Effect.fnUntraced(function* (context: ActiveAcpTurn) { if (!flavor.deferFinalizeForBackgroundWork) return; if (!context.promptSettled || context.finalized) return; - if (hasDeferredBackgroundWork(context)) return; + if (holdsDeferredFinalize(context)) return; yield* scheduleDeferredFinalize(context); }); @@ -4413,7 +4430,10 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( yield* Ref.update(wakeBuffer, (current) => [...current, notification]); buffered = true; } - if (!acpPostSettleContinuationOfferEvidence(notification, flavor)) { + if ( + flavor.ownsNativeTurnAfterPrompt !== true && + !acpPostSettleContinuationOfferEvidence(notification, flavor) + ) { return { buffered, offerContinuation: false, @@ -7295,7 +7315,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( scheduleDeferredFinalize = Effect.fn("scheduleDeferredFinalize")(function* (context) { if (!flavor.deferFinalizeForBackgroundWork) return; if (!context.promptSettled || context.finalized || context.interrupted) return; - if (hasDeferredBackgroundWork(context)) return; + if (holdsDeferredFinalize(context)) return; context.backgroundFinalizeGeneration += 1; const generation = context.backgroundFinalizeGeneration; yield* Effect.gen(function* () { @@ -7307,7 +7327,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( ) { return; } - if (hasDeferredBackgroundWork(context)) return; + if (holdsDeferredFinalize(context)) return; const status = context.promptSettledStatus ?? "completed"; yield* finalizeTurn(context, status); }).pipe(Effect.forkIn(sessionScope), Effect.asVoid); From fd861a830b3db1b4578a08bb8badc0736504f326 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 31 Oct 2026 23:11:21 +0000 Subject: [PATCH 4/8] fix(acp-registry): end a held MiniMax Code continuation on exit and hold it for open requests A MiniMax continuation held open by a running tool never ended when the agent process exited, because only ACP v2 agents fail their turn on termination. It now fails with a transport error and cancels any pending request. A pending permission or elicitation request now holds the continuation too. Before, the 3 s quiet window completed the run while the request was open, which dismissed the question card while MiniMax kept waiting for an answer. Resolving the request re-arms the quiet window. Both changes are gated on ownsNativeTurnAfterPrompt. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/AcpRegistryAdapterV2.test.ts | 142 ++++++++++++++++++ packages/provider-acp/src/server/adapter.ts | 49 ++++-- 2 files changed, 177 insertions(+), 14 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 4e57c0f7510bb..73452a8da1ca7 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -2252,6 +2252,148 @@ describe("AcpRegistryAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + it.effect("fails a MiniMax Code continuation whose process exits while its tool runs", () => + Effect.gen(function* () { + const { update } = claudeFrames; + const { minimax, gate } = yield* openMiniMaxContinuationWithRunningTool( + [ + { + ...update(MINIMAX_ROOT_SESSION, { + sessionUpdate: "session_info_update", + title: "after exit", + }), + label: "after-exit", + }, + ], + ["after-exit"], + ); + yield* minimax.terminate(); + gate.release("after-exit"); + const seen = yield* minimax.until( + (event) => + event.type === "turn.terminal" || + (event.type === "provider_thread.updated" && + event.providerThread.nativeMetadata?.title === "after exit"), + ); + const terminal = seen.at(-1)!; + if (terminal.type !== "turn.terminal") throw new Error("Process exit left the run open"); + assert.equal(terminal.status, "failed"); + assert.equal(terminal.failure?.class, "transport_error"); + assert.equal(terminal.runOrdinal, 2); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + + it.effect.each([ + { + kind: "approval", + method: "session/request_permission", + params: { + sessionId: MINIMAX_ROOT_SESSION, + toolCall: { toolCallId: "write-config", title: "Write config", kind: "edit" }, + options: [{ optionId: "allow", name: "Allow", kind: "allow_once" }], + }, + answer: { decision: "accept" as const }, + response: { outcome: { outcome: "selected", optionId: "allow" } }, + }, + { + kind: "question", + method: "elicitation/create", + params: { + sessionId: MINIMAX_ROOT_SESSION, + mode: "form", + message: "Which file?", + requestedSchema: { + type: "object", + properties: { file: { type: "string", title: "File", enum: ["a.ts", "b.ts"] } }, + required: ["file"], + }, + }, + answer: { answers: { file: "a.ts" } }, + response: { action: "accept", content: { file: "a.ts" } }, + }, + ] as const)("keeps a MiniMax Code continuation open while its $kind waits", (testCase) => + Effect.gen(function* () { + const { update } = claudeFrames; + const gate = makeProviderReplayGate(["late", "request", "quiet"]); + const minimax = yield* openClaudeReplaySession( + [ + ...minimaxResolvedPrompt, + { + ...update(MINIMAX_ROOT_SESSION, { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: "Asking before I continue." }, + }), + label: "late", + }, + update(MINIMAX_ROOT_SESSION, { + sessionUpdate: "session_info_update", + title: "late output sent", + }), + { + type: "emit_inbound", + label: "request", + frame: { kind: "request", method: testCase.method, params: testCase.params }, + }, + { + ...update(MINIMAX_ROOT_SESSION, { + sessionUpdate: "session_info_update", + title: "still waiting", + }), + label: "quiet", + }, + { + type: "expect_outbound", + frame: { kind: "response", method: testCase.method, result: testCase.response }, + }, + update(MINIMAX_ROOT_SESSION, { + sessionUpdate: "session_info_update", + title: "answered", + }), + ], + gate, + true, + "minimax-code", + ); + yield* minimax.runtime.startTurn(minimax.turnInput(1)); + assert.equal((yield* minimax.terminal).status, "completed"); + gate.release("late"); + yield* minimax.throughMarker("late output sent"); + assert.lengthOf(minimax.offers, 1); + yield* minimax.offers[0]!.dispatchIfCurrent!(Effect.void); + yield* minimax.runtime.startTurn(minimax.turnInput(2, { continuation: true })); + gate.release("request"); + const asked = yield* minimax.until( + (event) => + event.type === "runtime_request.updated" && event.runtimeRequest.status === "pending", + ); + const request = asked.at(-1)!; + if (request.type !== "runtime_request.updated") throw new Error("Missing request"); + yield* TestClock.adjust("10 seconds"); + gate.release("quiet"); + const waiting = [...asked, ...(yield* minimax.throughMarker("still waiting"))]; + assert.isFalse( + waiting.some((event) => event.type === "turn.terminal"), + "a pending request keeps the continuation run open", + ); + assert.isFalse( + waiting.some( + (event) => + event.type === "runtime_request.updated" && event.runtimeRequest.status !== "pending", + ), + ); + yield* minimax.runtime.respondToRuntimeRequest({ + requestId: request.runtimeRequest.id, + ...testCase.answer, + }); + const answered = yield* minimax.throughMarker("answered"); + assert.isFalse(answered.some((event) => event.type === "turn.terminal")); + yield* TestClock.adjust("3 seconds"); + const terminal = yield* minimax.terminal; + assert.equal(terminal.status, "completed"); + assert.equal(terminal.runOrdinal, 2); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.effect("fails active Claude work and releases its children when the process exits", () => Effect.gen(function* () { const session = yield* openClaudeReplaySession([ diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index e6eb994abb80c..171085bf0ccf6 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -1645,6 +1645,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( flavor.extractSubagentSessionEvent !== undefined && !followsAgentWorkState ? undefined : flavor.extractSubagentUpdate?.(tool); + const foregroundToolFlavor = { ...flavor, extractSubagentUpdate }; let turnAttached = yield* Deferred.make(); const activeSessionId = yield* Ref.make(null); const contextUsageBySessionId = yield* Ref.make( @@ -2028,7 +2029,19 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( runtimeGeneration, Effect.gen(function* () { yield* Ref.set(runtimeRestartRequired, true); - if (!followsAgentWorkState) return; + if (!followsAgentWorkState) { + // A held native turn has no prompt fiber left to fail it. + if (flavor.ownsNativeTurnAfterPrompt !== true) return; + const context = yield* Ref.get(activeTurn); + if (context === null || !context.promptSettled || context.finalized) return; + yield* cancelPendingRuntimeRequests(); + yield* finalizeTurn( + context, + "failed", + makeProviderFailure({ class: "transport_error" }), + ); + return; + } agentWork = { sessionUpdate: "state_update", state: "idle", stopReason: "error" }; for (const child of nativeChildren.values()) { if (child.status !== "running") continue; @@ -3425,10 +3438,13 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( if (hasDeferredBackgroundWork(context)) return true; if (flavor.ownsNativeTurnAfterPrompt !== true) return false; for (const tool of context.tools.values()) { - if (acpCompletedTurnShouldTerminalizeTool(tool, { ...flavor, extractSubagentUpdate })) { + if (acpCompletedTurnShouldTerminalizeTool(tool, foregroundToolFlavor)) { return true; } } + for (const request of Ref.getUnsafe(pendingRuntimeRequests).values()) { + if (request.runtimeRequest.providerTurnId === context.providerTurnId) return true; + } return false; }; @@ -3441,6 +3457,20 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( yield* scheduleDeferredFinalize(context); }); + const releasePendingRuntimeRequest = Effect.fn("AcpAdapterV2.releasePendingRuntimeRequest")( + function* (requestId: RuntimeRequestId) { + const released = yield* Ref.modify(pendingRuntimeRequests, (current) => { + const updated = new Map(current); + updated.delete(String(requestId)); + return [current.get(String(requestId)), updated] as const; + }); + if (flavor.ownsNativeTurnAfterPrompt !== true || released === undefined) return; + const context = yield* Ref.get(activeTurn); + if (context?.providerTurnId !== released.runtimeRequest.providerTurnId) return; + yield* rearmDeferredFinalize(context); + }, + ); + // let breaks circular inference from monitor hydration re-entry. let emitTool: ( context: ActiveAcpTurn, @@ -5506,11 +5536,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( Effect.ensuring( runRuntimeCallbackAtGeneration( generation, - Ref.update(pendingRuntimeRequests, (current) => { - const updated = new Map(current); - updated.delete(String(requestId)); - return updated; - }), + releasePendingRuntimeRequest(requestId), ).pipe(Effect.asVoid), ), ); @@ -6208,11 +6234,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( Effect.ensuring( runRuntimeCallbackAtGeneration( handlerGeneration, - Ref.update(pendingRuntimeRequests, (current) => { - const updated = new Map(current); - updated.delete(String(requestId)); - return updated; - }), + releasePendingRuntimeRequest(requestId), ).pipe(Effect.asVoid), ), ); @@ -7110,8 +7132,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( status: "completed" | "failed" = "completed", ) { for (const tool of context.tools.values()) { - if (!acpCompletedTurnShouldTerminalizeTool(tool, { ...flavor, extractSubagentUpdate })) - continue; + if (!acpCompletedTurnShouldTerminalizeTool(tool, foregroundToolFlavor)) continue; yield* emitTool(context, tool, status); } }); From aeea62b5c4e719c7d564135c173c343d9206364f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 31 Oct 2026 23:14:46 +0000 Subject: [PATCH 5/8] fix(acp-registry): a MiniMax Code tool cut off by a process exit shows failed Co-Authored-By: Claude Opus 5.5 --- .../orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts | 4 ++++ packages/provider-acp/src/server/adapter.ts | 1 + 2 files changed, 5 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 73452a8da1ca7..f5e90d3d7fe2c 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -2280,6 +2280,10 @@ describe("AcpRegistryAdapterV2", () => { assert.equal(terminal.status, "failed"); assert.equal(terminal.failure?.class, "transport_error"); assert.equal(terminal.runOrdinal, 2); + assert.isTrue( + seen.some((event) => isCheckTool(event, "failed")), + "the tool cut off by the exit must not stay running", + ); }).pipe(Effect.provide(layerTest), Effect.scoped), ); diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 171085bf0ccf6..06ac9fd4a62c5 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -2035,6 +2035,7 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( const context = yield* Ref.get(activeTurn); if (context === null || !context.promptSettled || context.finalized) return; yield* cancelPendingRuntimeRequests(); + yield* terminalizeOpenForegroundTools(context, "failed"); yield* finalizeTurn( context, "failed", From c1595cac259c767852124ac7336cfa62bfaea9af Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 31 Oct 2026 23:32:06 +0000 Subject: [PATCH 6/8] fix(acp-registry): a MiniMax Code process exit mid-drain fails its continuation Co-Authored-By: Claude Opus 5.5 --- .../Adapters/AcpRegistryAdapterV2.test.ts | 61 +++++++++++++++++++ packages/provider-acp/src/server/adapter.ts | 20 ++++-- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index f5e90d3d7fe2c..248adcf7a6fa7 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -2287,6 +2287,67 @@ describe("AcpRegistryAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); + it.effect("fails a MiniMax Code continuation whose process exits while it drains", () => + Effect.gen(function* () { + const { update } = claudeFrames; + const gate = makeProviderReplayGate(["late", "after-exit"]); + const burst = Array.from({ length: 300 }, (_, index) => + update(MINIMAX_ROOT_SESSION, { + sessionUpdate: "tool_call", + toolCallId: `tool-${index}`, + title: `Tool ${index}`, + kind: "execute", + status: "in_progress", + }), + ); + const minimax = yield* openClaudeReplaySession( + [ + ...minimaxResolvedPrompt, + { ...burst[0], label: "late" }, + ...burst.slice(1), + update(MINIMAX_ROOT_SESSION, { sessionUpdate: "session_info_update", title: "burst" }), + { + ...update(MINIMAX_ROOT_SESSION, { + sessionUpdate: "session_info_update", + title: "after exit", + }), + label: "after-exit", + }, + ], + gate, + true, + "minimax-code", + ); + yield* minimax.runtime.startTurn(minimax.turnInput(1)); + assert.equal((yield* minimax.terminal).status, "completed"); + gate.release("late"); + yield* minimax.throughMarker("burst"); + yield* minimax.offers[0]!.dispatchIfCurrent!(Effect.void); + const draining = yield* minimax.runtime + .startTurn(minimax.turnInput(2, { continuation: true })) + .pipe(Effect.forkScoped); + yield* minimax.until( + (event) => event.type === "provider_turn.updated" && event.providerTurn.ordinal === 2, + ); + yield* minimax.terminate(); + yield* Fiber.join(draining); + yield* TestClock.adjust("10 seconds"); + gate.release("after-exit"); + const seen = yield* minimax.until( + (event) => + event.type === "turn.terminal" || + (event.type === "provider_thread.updated" && + event.providerThread.nativeMetadata?.title === "after exit"), + ); + const terminal = seen.at(-1)!; + if (terminal.type !== "turn.terminal") + throw new Error("Exit during the drain left the run open"); + assert.equal(terminal.status, "failed"); + assert.equal(terminal.runOrdinal, 2); + assert.lengthOf(minimax.offers, 1, "a dead process must not offer another continuation"); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.effect.each([ { kind: "approval", diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 06ac9fd4a62c5..410401bb63848 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -2033,7 +2033,12 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( // A held native turn has no prompt fiber left to fail it. if (flavor.ownsNativeTurnAfterPrompt !== true) return; const context = yield* Ref.get(activeTurn); - if (context === null || !context.promptSettled || context.finalized) return; + if (context === null || context.finalized) return; + if ( + !context.promptSettled && + !acpIsProviderContinuationMessage(context.input.message) + ) + return; yield* cancelPendingRuntimeRequests(); yield* terminalizeOpenForegroundTools(context, "failed"); yield* finalizeTurn( @@ -7750,10 +7755,15 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( } const drainedWakeCount = yield* takeWakeBuffer.pipe( Effect.tap((drained) => - Effect.forEach(drained, handleSessionUpdate, { - concurrency: 1, - discard: true, - }), + Effect.forEach( + drained, + // A process exit can finalize the run mid-drain; its rest must not offer again. + (notification) => + flavor.ownsNativeTurnAfterPrompt === true && context.finalized + ? Effect.void + : handleSessionUpdate(notification), + { concurrency: 1, discard: true }, + ), ), Effect.map((drained) => drained.length), ); From 4fa3dfdc7a553c0187107a5bfa62ac74346ea682 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 23:50:06 +0000 Subject: [PATCH 7/8] test(acp-registry): fail the MiniMax drain-exit test if the exit stops landing mid-drain AI-assisted; verified with the tests and tools listed above. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/AcpRegistryAdapterV2.test.ts | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index eb4301fcea57a..a8c5bb9a20f68 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -2731,24 +2731,37 @@ describe("AcpRegistryAdapterV2", () => { const draining = yield* minimax.runtime .startTurn(minimax.turnInput(2, { continuation: true })) .pipe(Effect.forkScoped); - yield* minimax.until( + const started = yield* minimax.until( (event) => event.type === "provider_turn.updated" && event.providerTurn.ordinal === 2, ); yield* minimax.terminate(); yield* Fiber.join(draining); yield* TestClock.adjust("10 seconds"); gate.release("after-exit"); - const seen = yield* minimax.until( - (event) => - event.type === "turn.terminal" || - (event.type === "provider_thread.updated" && - event.providerThread.nativeMetadata?.title === "after exit"), - ); + const seen = [ + ...started, + ...(yield* minimax.until( + (event) => + event.type === "turn.terminal" || + (event.type === "provider_thread.updated" && + event.providerThread.nativeMetadata?.title === "after exit"), + )), + ]; const terminal = seen.at(-1)!; if (terminal.type !== "turn.terminal") throw new Error("Exit during the drain left the run open"); assert.equal(terminal.status, "failed"); assert.equal(terminal.runOrdinal, 2); + const drainedTools = new Set( + seen.flatMap((event) => + event.type === "turn_item.updated" && + event.turnItem.nativeItemRef?.nativeId?.startsWith("tool-") === true + ? [event.turnItem.nativeItemRef.nativeId] + : [], + ), + ); + // Without this the test passes silently if the exit lands after the drain instead of during it. + assert.isBelow(drainedTools.size, burst.length, "the exit must land before the drain ends"); assert.lengthOf(minimax.offers, 1, "a dead process must not offer another continuation"); }).pipe(Effect.provide(layerTest), Effect.scoped), ); From 5396ccf4a3f7e8c551e999a6bc676f37a433e924 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 11 Nov 2026 00:43:42 +0000 Subject: [PATCH 8/8] test(acp-registry): drop MiniMax Code cases that guard no new behavior Co-Authored-By: Claude Opus 5.5 --- .../Adapters/AcpRegistryAdapterV2.test.ts | 69 +------------------ 1 file changed, 1 insertion(+), 68 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index eb4301fcea57a..5b9d8ffdf3fde 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -2475,44 +2475,6 @@ describe("AcpRegistryAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); - it.effect("offers a continuation for MiniMax Code thoughts and running tools alone", () => - Effect.gen(function* () { - const { update } = claudeFrames; - const gate = makeProviderReplayGate(["late"]); - const minimax = yield* openClaudeReplaySession( - [ - ...minimaxResolvedPrompt, - { - ...update(MINIMAX_ROOT_SESSION, { - sessionUpdate: "agent_thought_chunk", - content: { type: "text", text: "Running the checks." }, - }), - label: "late", - }, - update(MINIMAX_ROOT_SESSION, { - sessionUpdate: "tool_call", - toolCallId: "late-check", - title: "Run tests", - kind: "execute", - status: "in_progress", - }), - update(MINIMAX_ROOT_SESSION, { - sessionUpdate: "session_info_update", - title: "late output sent", - }), - ], - gate, - true, - "minimax-code", - ); - yield* minimax.runtime.startTurn(minimax.turnInput(1)); - assert.equal((yield* minimax.terminal).status, "completed"); - gate.release("late"); - yield* minimax.throughMarker("late output sent"); - assert.lengthOf(minimax.offers, 1, "a busy MiniMax turn must not look idle"); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - const openMiniMaxContinuationWithRunningTool = Effect.fn( "openMiniMaxContinuationWithRunningTool", )(function* (afterToolStarts: ReadonlyArray, labels: ReadonlyArray) { @@ -2567,9 +2529,7 @@ describe("AcpRegistryAdapterV2", () => { ); gate.release("tool"); const running = [...started, ...(yield* minimax.throughMarker("tool running"))]; - const providerTurn = running.findLast((event) => event.type === "provider_turn.updated"); - if (providerTurn?.type !== "provider_turn.updated") throw new Error("Missing continuation"); - return { minimax, gate, running, providerTurnId: providerTurn.providerTurn.id }; + return { minimax, gate, running }; }); const isCheckTool = (event: ProviderAdapter.ProviderAdapterV2Event, status: string) => @@ -2630,33 +2590,6 @@ describe("AcpRegistryAdapterV2", () => { }).pipe(Effect.provide(layerTest), Effect.scoped), ); - it.effect("stops a MiniMax Code continuation whose tool never finishes", () => - Effect.gen(function* () { - const { update } = claudeFrames; - const { minimax, providerTurnId } = yield* openMiniMaxContinuationWithRunningTool( - [ - { - type: "expect_outbound", - frame: { kind: "notification", method: "session/cancel", params: "" }, - }, - update(MINIMAX_ROOT_SESSION, { - sessionUpdate: "session_info_update", - title: "cancel received", - }), - ], - [], - ); - yield* minimax.runtime.interruptTurn({ - providerThread: minimax.providerThread, - providerTurnId, - }); - const stopped = yield* minimax.terminal; - assert.equal(stopped.status, "interrupted"); - assert.equal(stopped.runOrdinal, 2); - yield* minimax.throughMarker("cancel received"); - }).pipe(Effect.provide(layerTest), Effect.scoped), - ); - it.effect("fails a MiniMax Code continuation whose process exits while its tool runs", () => Effect.gen(function* () { const { update } = claudeFrames;