Skip to content
Merged
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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { OrchestratorFixtureInput } from "../shared.ts";

export const GROK_BACKGROUND_BASH_TICKS = ["tock 1", "tock 2", "tock 3"] as const;

export const GROK_BACKGROUND_BASH_PROMPT =
"Start this shell command as a background task (run it in the background, do not wait for it, do not use the Monitor tool): 'for i in 1 2 3; do sleep 8; echo tock $i; done'. As soon as it has started, end your turn by replying exactly ROOT_DONE. When it finishes, reply exactly with its last line.";

/** The first frame of Grok's own `task-completed-*` wake turn after the command ended. */
const GROK_BACKGROUND_BASH_WAKE_LABEL =
"notification:session/update:agent_thought_chunk:task-completed-00000000-0000-4000-8000-000000000002";

// The root prompt settles while the backgrounded command still runs, so run 1
// is held open until `x.ai/task_completed`. Grok's own reply to the finished
// command replays as a continuation run.
export function grokBackgroundBashInput(): OrchestratorFixtureInput {
return {
steps: [
{ type: "message", text: GROK_BACKGROUND_BASH_PROMPT },
{ type: "finish_held_run", targetRunIndex: 1, status: "completed" },
{ type: "release_replay_gate", label: GROK_BACKGROUND_BASH_WAKE_LABEL },
{ type: "finish_held_run", targetRunIndex: 2, status: "completed" },
],
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { assert } from "@effect/vitest";
import type { ProviderReplayTranscript } from "@t3tools/contracts";

import type { OrchestratorV2ScenarioResult } from "../../OrchestratorScenario.ts";
import {
assertBaseProjection,
assertSemanticProjectionIntegrity,
assertUserMessagesInclude,
assertVisibleTurnItemsMirrorLocalTurnItems,
projectionFor,
} from "../shared.ts";
import { GROK_BACKGROUND_BASH_PROMPT, GROK_BACKGROUND_BASH_TICKS } from "./input.ts";

// Grok completes a background shell command's tool call with a
// `BackgroundTaskStarted` acknowledgement while the process keeps running,
// streams its output afterwards, and ends it only with `x.ai/task_completed`.
// Like a monitor, the command stays in the run that started it and holds that
// run open; Grok's own reply to the finished command is a continuation run.
export function assertGrokBackgroundBashOutput(
result: OrchestratorV2ScenarioResult,
transcript: ProviderReplayTranscript,
) {
assertBaseProjection({
result,
transcript,
runCount: 2,
runStatuses: ["completed", "completed"],
});
const projection = projectionFor(result, transcript.scenario);
assertSemanticProjectionIntegrity(projection);
assertVisibleTurnItemsMirrorLocalTurnItems(projection);
assertUserMessagesInclude(projection, [GROK_BACKGROUND_BASH_PROMPT]);

const [rootRun, wakeRun] = projection.runs;
const commands = projection.turnItems.filter((item) => item.type === "command_execution");
assert.lengthOf(commands, 1);
const command = commands[0];
assert.equal(command?.runId, rootRun?.id, "the command belongs to the run that started it");
assert.equal(command?.status, "completed");
assert.equal(command?.output, GROK_BACKGROUND_BASH_TICKS.map((tick) => `${tick}\n`).join(""));

// The start acknowledgement does not finish the command: every tick before
// the end is shown running.
const commandUpdates = result.domainEvents.flatMap((event) =>
event.type === "turn-item.updated" &&
event.payload.id === command?.id &&
event.payload.type === "command_execution"
? [event.payload]
: [],
);
const firstCompleted = commandUpdates.findIndex((update) => update.status === "completed");
for (const tick of GROK_BACKGROUND_BASH_TICKS.slice(0, -1)) {
const tickUpdate = commandUpdates.findIndex((update) => update.output?.includes(tick));
assert.isAtLeast(tickUpdate, 0, `missing command update for ${tick}`);
assert.isBelow(tickUpdate, firstCompleted, `the command completed before ${tick}`);
assert.equal(commandUpdates[tickUpdate]?.status, "running", `${tick} must show running`);
}

// Run 1 settles only after the command's structured end.
const commandCompletedAt = result.domainEvents.findIndex(
(event) =>
event.type === "turn-item.updated" &&
event.payload.id === command?.id &&
event.payload.status === "completed",
);
const rootCompletedAt = result.domainEvents.findIndex(
(event) =>
event.type === "run.updated" &&
event.payload.id === rootRun?.id &&
event.payload.status === "completed",
);
assert.isAtLeast(commandCompletedAt, 0);
assert.isAbove(rootCompletedAt, commandCompletedAt, "run 1 completed before the command ended");

const rootTexts = projection.turnItems.flatMap((item) =>
item.runId === rootRun?.id && item.type === "assistant_message" ? [item.text.trim()] : [],
);
assert.include(rootTexts, "ROOT_DONE");

// Grok's own wake reply is a provider continuation, not more of run 1.
const wakeMessage = projection.messages.find((message) => message.id === wakeRun?.userMessageId);
assert.equal(`${wakeMessage?.createdBy}:${wakeMessage?.creationSource}`, "agent:provider");
assert.isNotEmpty(
projection.turnItems.filter(
(item) => item.runId === wakeRun?.id && item.type === "assistant_message",
),
"Grok's reply to the finished command must land in the continuation run",
);
}
15 changes: 15 additions & 0 deletions apps/server/src/orchestration-v2/testkit/fixtures/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import { claudeResultIsErrorInput } from "./claude_result_is_error/input.ts";
import { assertClaudeResultIsErrorOutput } from "./claude_result_is_error/output.ts";
import { grokAutoBlockedCommandInput } from "./grok_auto_blocked_command/input.ts";
import { assertGrokAutoBlockedCommandOutput } from "./grok_auto_blocked_command/output.ts";
import { grokBackgroundBashInput } from "./grok_background_bash/input.ts";
import { assertGrokBackgroundBashOutput } from "./grok_background_bash/output.ts";
import { grokBackgroundSubagentInput } from "./grok_background_subagent/input.ts";
import { assertGrokBackgroundSubagentOutput } from "./grok_background_subagent/output.ts";
import { grokMonitorInput } from "./grok_monitor/input.ts";
Expand Down Expand Up @@ -308,6 +310,19 @@ export const ORCHESTRATOR_REPLAY_FIXTURES: ReadonlyArray<OrchestratorReplayFixtu
},
],
},
{
name: "grok_background_bash",
buildInput: grokBackgroundBashInput,
providers: [
{
driver: ProviderDriverKind.make("grok"),
transcriptFile: new URL("./grok_background_bash/grok_transcript.ndjson", import.meta.url),
modelSelection: { ...GROK_MODEL_SELECTION, model: "grok-4.7-build-fast" },
runContinuationWorker: true,
assertOutput: assertGrokBackgroundBashOutput,
},
],
},
{
name: "grok_background_subagent",
buildInput: grokBackgroundSubagentInput,
Expand Down
20 changes: 20 additions & 0 deletions apps/server/src/provider/acp/XAiAcpExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,23 @@ export function isXAiMonitorTool(toolCall: AcpToolCallState): boolean {
return isXAiMonitorStartAck(xAiToolOutputText(toolCall));
}

/**
* 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.
*/
function xAiBackgroundShellTaskId(toolCall: AcpToolCallState): string | undefined {
const rawOutput = unknownRecord(toolCall.data.rawOutput);
return nonEmptyString(rawOutput?.type) === "BackgroundTaskStarted"
? nonEmptyString(rawOutput?.task_id)
: undefined;
}

export function extractXAiMonitorTaskId(toolCall: AcpToolCallState): string | undefined {
const backgroundShellTaskId = xAiBackgroundShellTaskId(toolCall);
if (backgroundShellTaskId !== undefined) return backgroundShellTaskId;
if (!isXAiMonitorTool(toolCall)) return undefined;
const rawOutput = unknownRecord(toolCall.data.rawOutput);
// Live ACP start ACK: { type: "Monitor", taskId, timeoutMs, persistent }
Expand Down Expand Up @@ -592,6 +608,10 @@ export function normalizeXAiAcpToolCallState(toolCall: AcpToolCallState): AcpToo
};
}
}
if (xAiBackgroundShellTaskId(withTitle) !== undefined) {
// Start acknowledgement only; the command still runs in the background.
return { ...withTitle, status: "inProgress" };
}
if (!isXAiMonitorTool(withTitle)) {
return withTitle;
}
Expand Down
Loading