Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 46 additions & 3 deletions apps/server/scripts/acp-mock-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
89 changes: 89 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ProviderAdapter.ProviderAdapterV2Event>();
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) =>
Expand Down
97 changes: 97 additions & 0 deletions packages/provider-grok/src/server/xaiAcpExtension.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 23 additions & 9 deletions packages/provider-grok/src/server/xaiAcpExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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-<uuid>-<index>`; subagent ids stay UUID-only.
const resultTaskId =
structuredTaskIdFromGetOutputTool(toolCall) ?? resultTaskIdFromGetOutputTool(toolCall, text);
if (resultTaskId !== undefined) {
return [{ taskId: resultTaskId, status, appendOutput }];
}
Expand Down Expand Up @@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
if (structured !== undefined && new RegExp(`^${XAI_UUID_RE}$`, "i").test(structured)) {
return structured;
}
Expand Down Expand Up @@ -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) ??
Expand Down
Loading