diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts index 1767c0dfceca4..ff4047a04b9bf 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts @@ -20,6 +20,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"; @@ -204,6 +205,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, @@ -275,7 +304,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", @@ -2389,6 +2418,398 @@ 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_ROOT_SESSION; + const { update } = claudeFrames; + const gate = makeProviderReplayGate(["late"]); + const minimax = yield* openClaudeReplaySession( + [ + ...minimaxResolvedPrompt, + { + ...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), + ); + + 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"))]; + return { minimax, gate, running }; + }); + + 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("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); + 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), + ); + + 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); + 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 = [ + ...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), + ); + + 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-registry/src/server/adapter.ts b/packages/provider-acp-registry/src/server/adapter.ts index 1b8b4240fd00e..f9f7ea0e3114c 100644 --- a/packages/provider-acp-registry/src/server/adapter.ts +++ b/packages/provider-acp-registry/src/server/adapter.ts @@ -228,6 +228,14 @@ 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, + ownsNativeTurnAfterPrompt: true, + } + : {}), ...(isClaude ? { followAgentWorkState: true, diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index a2c2c429fa88c..148fb8566fc28 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -545,6 +545,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; /** @@ -1900,6 +1905,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( @@ -2317,7 +2323,25 @@ 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.finalized) return; + if ( + !context.promptSettled && + !acpIsProviderContinuationMessage(context.input.message) + ) + return; + yield* cancelPendingRuntimeRequests(); + yield* terminalizeOpenForegroundTools(context, "failed"); + 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; @@ -3771,15 +3795,44 @@ 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, foregroundToolFlavor)) { + return true; + } + } + for (const request of Ref.getUnsafe(pendingRuntimeRequests).values()) { + if (request.runtimeRequest.providerTurnId === context.providerTurnId) 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); }); + 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, @@ -4781,7 +4834,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, @@ -5868,11 +5924,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), ), ); @@ -6609,11 +6661,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), ), ); @@ -7557,8 +7605,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); } }); @@ -7791,7 +7838,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* () { @@ -7803,7 +7850,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); @@ -8320,10 +8367,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), );