diff --git a/apps/server/scripts/acp-mock-agent.ts b/apps/server/scripts/acp-mock-agent.ts index c2d29e98047e8..64c7b7a3012f4 100644 --- a/apps/server/scripts/acp-mock-agent.ts +++ b/apps/server/scripts/acp-mock-agent.ts @@ -49,6 +49,7 @@ const emitPlanThenHang = process.env.T3_ACP_EMIT_PLAN_THEN_HANG === "1"; const emitActiveToolThenHang = process.env.T3_ACP_EMIT_ACTIVE_TOOL_THEN_HANG === "1"; const emitGrokMonitorPostTurnPoll = process.env.T3_ACP_EMIT_GROK_MONITOR_POST_TURN_POLL === "1"; const emitGrokBackgroundTaskStarted = process.env.T3_ACP_EMIT_GROK_BACKGROUND_TASK_STARTED === "1"; +const emitGrokBackgroundTaskPoll = process.env.T3_ACP_GROK_BACKGROUND_TASK_COMPLETION === "poll"; const emitForeignSessionUpdates = process.env.T3_ACP_EMIT_FOREIGN_SESSION_UPDATES === "1"; const waitForResumeRelease = process.env.T3_ACP_WAIT_FOR_RESUME_RELEASE === "1"; const completeFirstPromptOnCancel = process.env.T3_ACP_COMPLETE_FIRST_PROMPT_ON_CANCEL === "1"; @@ -1678,13 +1679,15 @@ const program = Effect.gen(function* () { return yield* Effect.never; } - if (emitGrokBackgroundTaskStarted) { - const toolCallId = "call-fb9d0000-0000-0000-0000-000000000026"; + if (emitGrokBackgroundTaskStarted || emitGrokBackgroundTaskPoll) { + const toolCallId = !emitGrokBackgroundTaskPoll + ? "call-fb9d0000-0000-0000-0000-000000000026" + : "call-fb9d0000-0000-0000-0000-000000000026-29"; const command = "sleep 40; echo done-a"; writeJsonRpcNotification("session/update", { sessionId: requestedSessionId, update: { - sessionUpdate: "tool_call", + sessionUpdate: !emitGrokBackgroundTaskPoll ? "tool_call" : "tool_call_update", toolCallId, title: "run_terminal_command", kind: "execute", @@ -1707,6 +1710,46 @@ const program = Effect.gen(function* () { }, }, }); + if (emitGrokBackgroundTaskPoll) { + writeJsonRpcNotification("session/update", { + sessionId: requestedSessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "call-background-poll", + title: "get_command_or_subagent_output", + kind: "other", + status: "completed", + rawOutput: { + type: "TaskOutput", + Result: { + task_id: toolCallId, + command, + status: "completed", + exit_code: 0, + output: "done-a", + }, + }, + }, + }); + + yield* agent.client.sessionUpdate({ + sessionId: requestedSessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "The background command finished." }, + }, + }); + writeJsonRpcNotification("_x.ai/session/update", { + sessionId: requestedSessionId, + update: { + sessionUpdate: "turn_completed", + prompt_id: promptIdFromRequestMeta(request), + stop_reason: "end_turn", + }, + }); + // Neither the prompt RPC nor the process ends, matching the Grok incident. + return yield* Effect.never; + } writeJsonRpcNotification("_x.ai/session/prompt_complete", { sessionId: requestedSessionId, promptId: promptIdFromRequestMeta(request) ?? "mock-xai-prompt-1", diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 82cd3d7a3a127..6b40386bb256b 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -3992,6 +3992,95 @@ describe("AcpAdapterV2", () => { }).pipe(Effect.provide(layerTest)), ); + // Oracle: Grok incident frames. Command completion must release the turn even + // when the CLI keeps the prompt RPC and process alive. Command polls stay visible. + it.live( + "completes a structured Grok background command from poll with an unanswered prompt RPC", + () => + Effect.gen(function* () { + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const path = yield* Path.Path; + const mockAgentPath = yield* path.fromFileUrl( + new URL("../../../scripts/acp-mock-agent.ts", import.meta.url), + ); + const instanceId = ProviderInstanceId.make("grok-background-completion"); + const adapter = yield* makeGrokAdapterV2({ + instanceId, + settings: DEFAULT_GROK_SETTINGS, + environment: {}, + hostPlatform: yield* HostProcess.Platform, + selfInvocation: yield* resolveSelfInvocation(), + makeRuntime: (input) => + makeMockRuntime({ + childProcessSpawner, + mockAgentPath, + environment: { T3_ACP_GROK_BACKGROUND_TASK_COMPLETION: "poll" }, + })(input).pipe(Effect.flatMap(makeXAiPromptCompletionRuntime)), + }); + const threadId = ThreadId.make("grok-background-completion"); + const modelSelection = { instanceId, model: "default" } as const; + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: process.cwd(), + }); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("grok-background-completion"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const events = yield* Queue.unbounded(); + yield* runtime.events.pipe( + Stream.runForEach((event) => Queue.offer(events, event)), + Effect.forkScoped, + ); + yield* runtime.startTurn( + makeTurnInput({ + threadId, + providerThread, + instanceId, + runtimePolicy, + now: yield* DateTime.now, + }), + ); + const observed = yield* Stream.fromQueue(events).pipe( + Stream.takeUntil((event) => event.type === "turn.terminal"), + Stream.runCollect, + Effect.timeout("15 seconds"), + ); + const allEvents = [...observed]; + let next = yield* Queue.poll(events); + while (Option.isSome(next)) { + allEvents.push(next.value); + next = yield* Queue.poll(events); + } + const terminals = allEvents.filter((event) => event.type === "turn.terminal"); + assert.deepEqual( + terminals.map((event) => event.status), + ["completed"], + ); + const items = allEvents.flatMap((event) => + event.type === "turn_item.updated" ? [event.turnItem] : [], + ); + assert.isFalse(items.some((item) => item.type === "subagent")); + const command = items.findLast( + (item) => item.nativeItemRef?.nativeId === "call-fb9d0000-0000-0000-0000-000000000026-29", + ); + assert.equal(command?.type, "command_execution"); + assert.equal(command?.status, "completed"); + assert.isTrue( + items.some((item) => item.nativeItemRef?.nativeId === "call-background-poll"), + "command TaskOutput must retain its normal tool frame", + ); + }).pipe(Effect.provide(layerTest), Effect.scoped), + ); + it.effect.each(["grok-build", "composer-2"])( "Grok configures the native session for %s", (model) => diff --git a/packages/provider-grok/src/server/xaiAcpExtension.test.ts b/packages/provider-grok/src/server/xaiAcpExtension.test.ts index c31d9438972c4..bd4c44e836312 100644 --- a/packages/provider-grok/src/server/xaiAcpExtension.test.ts +++ b/packages/provider-grok/src/server/xaiAcpExtension.test.ts @@ -664,6 +664,103 @@ describe("XAiAcpExtension", () => { ]); }); + it("completes structured command TaskOutput ids without treating them as subagents", () => { + // Oracle: BackgroundTaskStarted and TaskOutput from the Grok hang incident. + const toolCall: AcpToolCallState = { + toolCallId: "call-poll", + title: "get_command_or_subagent_output", + status: "completed", + data: { + rawInput: { + variant: "TaskOutput", + task_ids: ["call-7c835a60-7bf5-4284-a5c7-cf42a4212ece-29"], + }, + rawOutput: { + type: "TaskOutput", + Result: { + task_id: "call-7c835a60-7bf5-4284-a5c7-cf42a4212ece-29", + status: "completed", + exit_code: 0, + output: "done", + }, + }, + }, + }; + expect(extractXAiBackgroundTaskCompletion(toolCall)).toEqual([ + { + taskId: "call-7c835a60-7bf5-4284-a5c7-cf42a4212ece-29", + status: "completed", + appendOutput: "done", + }, + ]); + expect(extractXAiAcpSubagentUpdate(toolCall)).toBeUndefined(); + }); + + it("keeps the normal tool frame for a command result from a mixed-ID poll", () => { + // Oracle: review 4325739061 requires the result ID to override requested subagent IDs. + const toolCall: AcpToolCallState = { + toolCallId: "call-mixed-poll", + title: "get_command_or_subagent_output", + status: "completed", + data: { + rawInput: { + variant: "TaskOutput", + task_ids: [ + "019f44a6-4820-7402-925d-bc862ee711dd", + "call-7c835a60-7bf5-4284-a5c7-cf42a4212ece-29", + ], + }, + rawOutput: { + type: "TaskOutput", + Result: { + task_id: "call-7c835a60-7bf5-4284-a5c7-cf42a4212ece-29", + status: "completed", + exit_code: 0, + output: "done", + }, + }, + }, + }; + expect(extractXAiAcpSubagentUpdate(toolCall)).toBeUndefined(); + expect(extractXAiBackgroundTaskCompletion(toolCall)).toEqual([ + { + taskId: "call-7c835a60-7bf5-4284-a5c7-cf42a4212ece-29", + status: "completed", + appendOutput: "done", + }, + ]); + }); + + it.each(["", " "])("ignores an empty structured task id %j", (taskId) => { + // An empty result is not a completion identity, even with a terminal status. + expect( + extractXAiBackgroundTaskCompletion({ + toolCallId: "call-empty-poll", + status: "completed", + data: { + rawOutput: { type: "TaskOutput", Result: { task_id: taskId, status: "completed" } }, + }, + }), + ).toEqual([]); + }); + + it("keeps command ids in free-text task headers UUID-only", () => { + // Only structured ids are trusted; prose must retain the existing UUID contract. + expect( + extractXAiBackgroundTaskCompletion({ + toolCallId: "call-text-poll", + title: "get_command_or_subagent_output", + status: "completed", + data: { + rawOutput: { + type: "Text", + text: "=== Task call-7c835a60-7bf5-4284-a5c7-cf42a4212ece-29 ===\nStatus: completed\n=== Output ===\ndone", + }, + }, + }), + ).toEqual([]); + }); + it("hydrates monitor completion from a standalone TaskOutput frame without rawInput", () => { // Post-settle shape observed live (2026-07-12): the final // get_command_or_subagent_output update carries only rawOutput; the frame diff --git a/packages/provider-grok/src/server/xaiAcpExtension.ts b/packages/provider-grok/src/server/xaiAcpExtension.ts index 2ad11521abb3f..f0e676625e136 100644 --- a/packages/provider-grok/src/server/xaiAcpExtension.ts +++ b/packages/provider-grok/src/server/xaiAcpExtension.ts @@ -272,8 +272,8 @@ export function isXAiMonitorTool(toolCall: AcpToolCallState): boolean { * A shell command Grok runs in the background (asked for, or moved there after * its auto-background timeout) acknowledges with * `{ type: "BackgroundTaskStarted", task_id }` and completes its tool call - * while the process still runs. Like a monitor, it ends only with - * `x.ai/task_completed` for that task id. + * while the process still runs. It ends with a task completion notification + * or completed TaskOutput for that task id. */ export function xAiBackgroundShellTaskId(toolCall: AcpToolCallState): string | undefined { const rawOutput = unknownRecord(toolCall.data.rawOutput); @@ -333,7 +333,9 @@ export function extractXAiBackgroundTaskCompletion(toolCall: AcpToolCallState): const appendOutput = resultFromGetOutputTool(toolCall, text) ?? ""; // Prefer the completed identity from the result/header over the request list // (one call can poll multiple task_ids). - const resultTaskId = resultTaskIdFromGetOutputTool(toolCall, text); + // Command task ids can be `call--`; subagent ids stay UUID-only. + const resultTaskId = + structuredTaskIdFromGetOutputTool(toolCall) ?? resultTaskIdFromGetOutputTool(toolCall, text); if (resultTaskId !== undefined) { return [{ taskId: resultTaskId, status, appendOutput }]; } @@ -742,17 +744,22 @@ function taskIdsFromGetOutputTool( return [...new Set(ids)]; } -function resultTaskIdFromGetOutputTool( - toolCall: AcpToolCallState, - output: string | undefined, -): string | undefined { +function structuredTaskIdFromGetOutputTool(toolCall: AcpToolCallState): string | undefined { const rawOutput = unknownRecord(toolCall.data.rawOutput); const result = unknownRecord(rawOutput?.Result) ?? unknownRecord(rawOutput?.result); - const structured = + return ( nonEmptyString(result?.task_id) ?? nonEmptyString(result?.taskId) ?? nonEmptyString(rawOutput?.task_id) ?? - nonEmptyString(rawOutput?.taskId); + nonEmptyString(rawOutput?.taskId) + ); +} + +function resultTaskIdFromGetOutputTool( + toolCall: AcpToolCallState, + output: string | undefined, +): string | undefined { + const structured = structuredTaskIdFromGetOutputTool(toolCall); if (structured !== undefined && new RegExp(`^${XAI_UUID_RE}$`, "i").test(structured)) { return structured; } @@ -831,6 +838,13 @@ export function extractXAiAcpSubagentUpdate( const output = xAiToolOutputText(toolCall); if (isXAiGetSubagentOutputTool(toolCall, rawInput)) { + const structuredTaskId = structuredTaskIdFromGetOutputTool(toolCall); + if ( + structuredTaskId !== undefined && + !new RegExp(`^${XAI_UUID_RE}$`, "i").test(structuredTaskId) + ) { + return undefined; + } const taskIds = taskIdsFromGetOutputTool(toolCall, rawInput); const childSessionId = resultTaskIdFromGetOutputTool(toolCall, output) ??