Skip to content
Merged
125 changes: 122 additions & 3 deletions apps/server/scripts/acp-mock-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,11 @@ const emitXAiAskUserQuestionThenHang =
process.env.T3_ACP_EMIT_XAI_ASK_USER_QUESTION_THEN_HANG === "1";
const emitContentThenHang = process.env.T3_ACP_EMIT_CONTENT_THEN_HANG === "1";
const emitPlanThenHang = process.env.T3_ACP_EMIT_PLAN_THEN_HANG === "1";
const grokKillTaskShape = process.env.T3_ACP_GROK_KILL_TASK_SHAPE;
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 @@ -461,6 +463,71 @@ function modelState(): AcpCompat.SessionModelState {
};
}

function emitGrokKillTaskScenario(
sessionId: string,
promptId: string | undefined,
shape: string,
): void {
const taskId = "call-fb9d0000-0000-0000-0000-000000000026-29";
const outcome =
shape === "already_exited" || shape === "known_failure" ? "already_exited" : "killed";
writeJsonRpcNotification("session/update", {
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: "kill-target-command",
title: "run_terminal_command",
kind: "execute",
status: "in_progress",
rawInput: { command: "sleep 40" },
},
});
writeJsonRpcNotification("session/update", {
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: "kill-target-command",
status: "completed",
rawOutput: {
type: "BackgroundTaskStarted",
task_id: taskId,
task_type: "bash",
command: "sleep 40",
},
},
});
if (shape === "known_failure") {
writeJsonRpcNotification("_x.ai/task_completed", {
sessionId,
update: {
sessionUpdate: "task_completed",
task_snapshot: { task_id: taskId, kind: "bash", exit_code: 7, output: "known failure\n" },
},
});
}
writeJsonRpcNotification("session/update", {
sessionId,
update: {
sessionUpdate: "tool_call_update",
toolCallId: "kill-command",
title: `kill ${taskId} (${outcome})`,
kind: "other",
status: "completed",
rawInput: {
task_id: taskId,
...(shape === "output_only" ? {} : { variant: "KillTask" }),
},
...(shape === "variant_only" ? {} : { rawOutput: { type: "KillTask", outcome } }),
},
});
writeJsonRpcNotification("_x.ai/session/prompt_complete", {
sessionId,
promptId,
stopReason: "end_turn",
agentResult: null,
});
}

const program = Effect.gen(function* () {
const agent = yield* EffectAcpAgent.AcpAgent;
const resumeRelease = yield* Deferred.make<void>();
Expand Down Expand Up @@ -1498,6 +1565,16 @@ const program = Effect.gen(function* () {
return yield* Effect.never;
}

if (grokKillTaskShape !== undefined) {
emitGrokKillTaskScenario(
requestedSessionId,
promptIdFromRequestMeta(request),
grokKillTaskShape,
);
// Keep the prompt RPC and peer alive after the kill result.
return yield* Effect.never;
}

if (emitPlanThenHang) {
yield* agent.client.sessionUpdate({
sessionId: requestedSessionId,
Expand Down Expand Up @@ -1685,13 +1762,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 @@ -1714,6 +1793,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
189 changes: 189 additions & 0 deletions apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ import {
} from "@t3tools/provider-acp/server/sessionConfig";
import {
extractXAiAcpSubagentUpdate,
makeGrokAdapterV2,
makeXAiPromptCompletionRuntime,
normalizeXAiAcpToolCallState,
registerXAiBackgroundTaskTracking,
} from "@t3tools/provider-grok/testing";
Expand Down Expand Up @@ -3079,6 +3081,193 @@ describe("AcpAdapterV2", () => {
}).pipe(Effect.provide(layerTest), Effect.scoped),
);

// 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,
);
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),
);

// Oracle: #15888 KillTask reports. Command completion must release the turn even
// when the CLI keeps the prompt RPC and process alive. Kill calls stay visible.
it.live.each([
{ shape: "output_only", expectedCommand: { type: "command_execution", status: "completed" } },
{ shape: "variant_only", expectedCommand: { type: "command_execution", status: "completed" } },
{ shape: "killed", expectedCommand: { type: "command_execution", status: "completed" } },
{
shape: "already_exited",
expectedCommand: { type: "command_execution", status: "completed" },
},
{
shape: "known_failure",
expectedCommand: { type: "command_execution", status: "failed", output: "known failure\n" },
},
] as const)(
"completes a Grok KillTask command ($shape) and preserves any known result",
({ shape, expectedCommand }) =>
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-kill-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_KILL_TASK_SHAPE: shape },
})(input).pipe(Effect.flatMap(makeXAiPromptCompletionRuntime)),
});
const threadId = ThreadId.make("grok-kill-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-kill-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,
);
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 === "kill-target-command",
);
assert.deepInclude(command, expectedCommand);
assert.isTrue(
items.some((item) => item.nativeItemRef?.nativeId === "kill-command"),
"KillTask must retain its normal tool frame",
);
}).pipe(Effect.provide(layerTest), Effect.scoped),
);

it.effect("keeps an agent's empty-valued default choice selectable", () =>
Effect.gen(function* () {
const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
Expand Down
Loading
Loading