Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
1898 lines
63 KiB
JavaScript
1898 lines
63 KiB
JavaScript
const test = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const { EventEmitter } = require("node:events");
|
|
const path = require("node:path");
|
|
const {
|
|
ACP_PROTOCOL_VERSION,
|
|
authenticateGrokAcp,
|
|
buildGrokAcpInitializeParams,
|
|
buildGrokAcpPermissionResponse,
|
|
buildGrokAcpPromptParams,
|
|
buildGrokAcpSessionNewParams,
|
|
buildGrokAcpSessionResumeOrLoadParams,
|
|
buildGrokAcpSpawnArgs,
|
|
createJsonRpcClient,
|
|
establishGrokAcpSession,
|
|
handleGrokAcpMessage,
|
|
listGrokAcpModels,
|
|
parseGrokAcpAgentCapabilities,
|
|
parseGrokAcpModelCatalog,
|
|
planGrokAcpSessionEstablish,
|
|
resolveGrokAcpCwd,
|
|
runGrokAcpTurn,
|
|
selectGrokAcpAuthMethodId,
|
|
toAcpMcpEnvPairs,
|
|
toAcpMcpServers,
|
|
translateGrokAcpUpdate,
|
|
} = require("./grokAcpDriver.cjs");
|
|
const {
|
|
GROK_MCP_MODE_DISALLOWED_LOCAL_TOOLS,
|
|
shouldReportGrokProcessExitFailure,
|
|
} = require("./grokDriver.cjs");
|
|
const { getDriver, listBackends } = require("./index.cjs");
|
|
|
|
function makeEmitter() {
|
|
const calls = [];
|
|
return {
|
|
calls,
|
|
text: (value) => calls.push(["text", value]),
|
|
reasoning: (value) => calls.push(["reasoning", value]),
|
|
reasoningEnd: () => calls.push(["reasoningEnd"]),
|
|
toolCall: (name, args, id) => calls.push(["toolCall", name, args, id]),
|
|
toolResult: (id, result, name) => calls.push(["toolResult", id, result, name]),
|
|
sessionId: (id) => calls.push(["sessionId", id]),
|
|
planUpdate: (itemId, items, status) => calls.push(["planUpdate", itemId, items, status]),
|
|
usage: (usage) => calls.push(["usage", usage]),
|
|
emitDone: () => calls.push(["done"]),
|
|
emitError: (message) => calls.push(["error", message]),
|
|
};
|
|
}
|
|
|
|
test("buildGrokAcpSpawnArgs uses agent stdio, no-auto-update, and always-approve for non-observer", () => {
|
|
const args = buildGrokAcpSpawnArgs({
|
|
model: "grok-4.5",
|
|
permissionMode: "auto",
|
|
toolIntegrationMode: "skills",
|
|
});
|
|
assert.deepEqual(args, [
|
|
"--no-auto-update",
|
|
"agent",
|
|
"--always-approve",
|
|
"-m",
|
|
"grok-4.5",
|
|
"stdio",
|
|
]);
|
|
const observer = buildGrokAcpSpawnArgs({
|
|
permissionMode: "observer",
|
|
toolIntegrationMode: "skills",
|
|
});
|
|
assert.deepEqual(observer, ["--no-auto-update", "agent", "stdio"]);
|
|
});
|
|
|
|
test("buildGrokAcpSpawnArgs passes a selected reasoning effort separately from the model", () => {
|
|
assert.deepEqual(buildGrokAcpSpawnArgs({
|
|
model: "grok-4.6/high",
|
|
permissionMode: "auto",
|
|
toolIntegrationMode: "skills",
|
|
}), [
|
|
"--no-auto-update",
|
|
"agent",
|
|
"--always-approve",
|
|
"-m",
|
|
"grok-4.6",
|
|
"--reasoning-effort",
|
|
"high",
|
|
"stdio",
|
|
]);
|
|
});
|
|
|
|
test("parseGrokAcpModelCatalog exposes only each model's advertised reasoning efforts", () => {
|
|
const catalog = parseGrokAcpModelCatalog({
|
|
_meta: {
|
|
modelState: {
|
|
currentModelId: "grok-4.6",
|
|
availableModels: [
|
|
{
|
|
modelId: "grok-4.6",
|
|
name: "Grok 4.6",
|
|
description: "Latest",
|
|
_meta: {
|
|
supportsReasoningEffort: true,
|
|
reasoningEffort: "high",
|
|
reasoningEfforts: [
|
|
{ id: "deep", value: "xhigh", label: "Extra High" },
|
|
{ id: "high", value: "high", label: "High" },
|
|
{ id: "medium", value: "medium", label: "Medium" },
|
|
{ id: "low", value: "low", label: "Low" },
|
|
],
|
|
},
|
|
},
|
|
{
|
|
modelId: "ocx-fast",
|
|
name: "OCX Fast",
|
|
_meta: { supportsReasoningEffort: false },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
assert.deepEqual(catalog, {
|
|
currentModelId: "grok-4.6/high",
|
|
models: [
|
|
{
|
|
id: "grok-4.6",
|
|
name: "Grok 4.6",
|
|
description: "Latest",
|
|
thinkingLevels: ["xhigh", "high", "medium", "low"],
|
|
defaultThinkingLevel: "high",
|
|
},
|
|
{ id: "ocx-fast", name: "OCX Fast" },
|
|
],
|
|
});
|
|
});
|
|
|
|
test("parseGrokAcpModelCatalog fills known legacy models without overriding explicit no-reasoning metadata", () => {
|
|
const catalog = parseGrokAcpModelCatalog({
|
|
_meta: {
|
|
modelState: {
|
|
currentModelId: "grok-4.5",
|
|
availableModels: [
|
|
{ modelId: "grok-4.5", name: "Grok 4.5" },
|
|
{
|
|
modelId: "grok-4.6",
|
|
name: "Grok 4.6",
|
|
_meta: { supportsReasoningEffort: false },
|
|
},
|
|
],
|
|
},
|
|
},
|
|
});
|
|
|
|
assert.deepEqual(catalog.models, [
|
|
{
|
|
id: "grok-4.5",
|
|
name: "Grok 4.5",
|
|
thinkingLevels: ["high", "medium", "low"],
|
|
defaultThinkingLevel: "high",
|
|
},
|
|
{ id: "grok-4.6", name: "Grok 4.6" },
|
|
]);
|
|
assert.equal(catalog.currentModelId, "grok-4.5/high");
|
|
});
|
|
|
|
test("parseGrokAcpModelCatalog uses a model-specific fallback when advertised options are empty", () => {
|
|
const catalog = parseGrokAcpModelCatalog({
|
|
_meta: {
|
|
modelState: {
|
|
currentModelId: "grok-4.5",
|
|
availableModels: [{
|
|
modelId: "grok-4.5",
|
|
name: "Grok 4.5",
|
|
_meta: {
|
|
supportsReasoningEffort: true,
|
|
reasoningEfforts: [],
|
|
},
|
|
}],
|
|
},
|
|
},
|
|
});
|
|
|
|
assert.equal(catalog.currentModelId, "grok-4.5/high");
|
|
assert.deepEqual(catalog.models[0].thinkingLevels, ["high", "medium", "low"]);
|
|
assert.ok(!catalog.models[0].thinkingLevels.includes("xhigh"));
|
|
});
|
|
|
|
test("listGrokAcpModels reads reasoning metadata from the initialize response", async () => {
|
|
const { EventEmitter } = require("node:events");
|
|
const child = new EventEmitter();
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
child.stdin = new EventEmitter();
|
|
child.stdin.end = () => {};
|
|
child.exitCode = null;
|
|
child.killed = false;
|
|
child.pid = 42;
|
|
child.kill = () => { child.killed = true; };
|
|
child.stdin.write = (line, callback) => {
|
|
const request = JSON.parse(String(line));
|
|
assert.equal(request.method, "initialize");
|
|
queueMicrotask(() => {
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: request.id,
|
|
result: {
|
|
_meta: {
|
|
modelState: {
|
|
currentModelId: "grok-4.6",
|
|
availableModels: [{
|
|
modelId: "grok-4.6",
|
|
name: "Grok 4.6",
|
|
_meta: {
|
|
supportsReasoningEffort: true,
|
|
reasoningEffort: "high",
|
|
reasoningEfforts: [
|
|
{ id: "high", value: "high" },
|
|
{ id: "low", value: "low" },
|
|
],
|
|
},
|
|
}],
|
|
},
|
|
},
|
|
},
|
|
})}\n`));
|
|
callback?.();
|
|
});
|
|
return true;
|
|
};
|
|
|
|
const signals = [];
|
|
const catalog = await listGrokAcpModels({
|
|
binPath: "/usr/bin/grok",
|
|
spawnImpl: (bin, args) => {
|
|
assert.equal(bin, "/usr/bin/grok");
|
|
assert.deepEqual(args, ["--no-auto-update", "agent", "stdio"]);
|
|
return child;
|
|
},
|
|
forceKillImpl: (_child, signal) => {
|
|
signals.push(signal);
|
|
if (signal === "SIGTERM") {
|
|
queueMicrotask(() => {
|
|
child.exitCode = 0;
|
|
child.emit("close", 0);
|
|
});
|
|
}
|
|
},
|
|
});
|
|
|
|
assert.equal(catalog.currentModelId, "grok-4.6/high");
|
|
assert.deepEqual(catalog.models[0].thinkingLevels, ["high", "low"]);
|
|
assert.equal(catalog.models[0].defaultThinkingLevel, "high");
|
|
assert.deepEqual(signals, ["SIGTERM"]);
|
|
});
|
|
|
|
test("listGrokAcpModels force-kills a model probe that ignores timeout shutdown", async () => {
|
|
const child = new EventEmitter();
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
child.stdin = new EventEmitter();
|
|
child.stdin.write = () => true;
|
|
child.stdin.end = () => {};
|
|
child.exitCode = null;
|
|
child.killed = false;
|
|
child.pid = 43;
|
|
const signals = [];
|
|
|
|
const keepAlive = setTimeout(() => {}, 50);
|
|
let catalog;
|
|
try {
|
|
catalog = await listGrokAcpModels({
|
|
binPath: "/usr/bin/grok",
|
|
spawnImpl: () => child,
|
|
timeoutMs: 2,
|
|
abortGraceMs: 2,
|
|
forceKillImpl: (_child, signal) => signals.push(signal),
|
|
});
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
} finally {
|
|
clearTimeout(keepAlive);
|
|
}
|
|
|
|
assert.deepEqual(catalog, { currentModelId: null, models: [] });
|
|
assert.deepEqual(signals, ["SIGTERM", "SIGKILL"]);
|
|
});
|
|
|
|
test("listGrokAcpModels force-kills a model probe that ignores abort shutdown", async () => {
|
|
const child = new EventEmitter();
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
child.stdin = new EventEmitter();
|
|
child.stdin.write = () => true;
|
|
child.stdin.end = () => {};
|
|
child.exitCode = null;
|
|
child.killed = false;
|
|
child.pid = 44;
|
|
const signals = [];
|
|
const controller = new AbortController();
|
|
|
|
const pending = listGrokAcpModels({
|
|
binPath: "/usr/bin/grok",
|
|
spawnImpl: () => child,
|
|
signal: controller.signal,
|
|
timeoutMs: 100,
|
|
abortGraceMs: 2,
|
|
forceKillImpl: (_child, signal) => signals.push(signal),
|
|
});
|
|
controller.abort();
|
|
const catalog = await pending;
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
|
|
assert.deepEqual(catalog, { currentModelId: null, models: [] });
|
|
assert.deepEqual(signals, ["SIGTERM", "SIGKILL"]);
|
|
});
|
|
|
|
test("registry Grok model discovery enriches legacy fallback reasoning levels", async () => {
|
|
const grokModule = require("./grokDriver.cjs");
|
|
const grokAcpModule = require("./grokAcpDriver.cjs");
|
|
const originalAcp = grokAcpModule.listGrokAcpModels;
|
|
const originalLegacy = grokModule.listGrokModels;
|
|
grokAcpModule.listGrokAcpModels = async () => ({ currentModelId: null, models: [] });
|
|
grokModule.listGrokModels = async () => ({
|
|
currentModelId: "grok-4.6",
|
|
models: [grokModule.applyGrokReasoningFallback({ id: "grok-4.6", name: "Grok 4.6" })],
|
|
});
|
|
try {
|
|
const catalog = await getDriver("grok").listModels({});
|
|
assert.equal(catalog.currentModelId, "grok-4.6/high");
|
|
assert.deepEqual(catalog.models[0].thinkingLevels, ["xhigh", "high", "medium", "low"]);
|
|
assert.equal(catalog.models[0].defaultThinkingLevel, "high");
|
|
} finally {
|
|
grokAcpModule.listGrokAcpModels = originalAcp;
|
|
grokModule.listGrokModels = originalLegacy;
|
|
}
|
|
});
|
|
|
|
test("registry Grok model discovery keeps ACP current model when its list is incomplete", async () => {
|
|
const grokModule = require("./grokDriver.cjs");
|
|
const grokAcpModule = require("./grokAcpDriver.cjs");
|
|
const originalAcp = grokAcpModule.listGrokAcpModels;
|
|
const originalLegacy = grokModule.listGrokModels;
|
|
grokAcpModule.listGrokAcpModels = async () => ({ currentModelId: "grok-4.6", models: [] });
|
|
grokModule.listGrokModels = async () => ({ currentModelId: "grok-4.5", models: [] });
|
|
try {
|
|
const catalog = await getDriver("grok").listModels({});
|
|
assert.equal(catalog.currentModelId, "grok-4.6/high");
|
|
assert.deepEqual(catalog.models, [{
|
|
id: "grok-4.6",
|
|
name: "grok-4.6",
|
|
thinkingLevels: ["xhigh", "high", "medium", "low"],
|
|
defaultThinkingLevel: "high",
|
|
}]);
|
|
} finally {
|
|
grokAcpModule.listGrokAcpModels = originalAcp;
|
|
grokModule.listGrokModels = originalLegacy;
|
|
}
|
|
});
|
|
|
|
test("buildGrokAcpSpawnArgs places global MCP lockdown before agent subcommand", () => {
|
|
const args = buildGrokAcpSpawnArgs({
|
|
model: "grok-4.5",
|
|
permissionMode: "auto",
|
|
toolIntegrationMode: "mcp",
|
|
});
|
|
const agentIdx = args.indexOf("agent");
|
|
const denyIdx = args.indexOf("--disallowed-tools");
|
|
const noUpdateIdx = args.indexOf("--no-auto-update");
|
|
const alwaysIdx = args.indexOf("--always-approve");
|
|
const modelIdx = args.indexOf("-m");
|
|
assert.ok(agentIdx >= 0, "must include agent subcommand");
|
|
assert.ok(denyIdx >= 0, "MCP mode must pass --disallowed-tools on ACP spawn");
|
|
// Live grok rejects --disallowed-tools AFTER `agent` (unexpected argument).
|
|
assert.ok(noUpdateIdx < agentIdx, "--no-auto-update must be before agent");
|
|
assert.ok(denyIdx < agentIdx, "--disallowed-tools must be before agent");
|
|
assert.ok(alwaysIdx > agentIdx, "--always-approve is agent-local (after agent)");
|
|
assert.ok(modelIdx > agentIdx, "-m is agent-local (after agent)");
|
|
assert.equal(args[args.length - 1], "stdio");
|
|
assert.deepEqual(args, [
|
|
"--no-auto-update",
|
|
"--disallowed-tools",
|
|
GROK_MCP_MODE_DISALLOWED_LOCAL_TOOLS.join(","),
|
|
"agent",
|
|
"--always-approve",
|
|
"-m",
|
|
"grok-4.5",
|
|
"stdio",
|
|
]);
|
|
const denied = String(args[denyIdx + 1] || "");
|
|
assert.match(denied, /run_terminal_command/);
|
|
assert.match(denied, /search_replace/);
|
|
assert.match(denied, /write/);
|
|
assert.doesNotMatch(denied, /mcp|netcatty/i);
|
|
// Skills mode: no lockdown list; global flags still precede agent.
|
|
const skills = buildGrokAcpSpawnArgs({
|
|
permissionMode: "auto",
|
|
toolIntegrationMode: "skills",
|
|
});
|
|
assert.ok(!skills.includes("--disallowed-tools"));
|
|
assert.ok(skills.indexOf("--no-auto-update") < skills.indexOf("agent"));
|
|
});
|
|
|
|
test("toAcpMcpEnvPairs keeps Grok session/new pair-array shape", () => {
|
|
// Live Grok rejects plain object env (Invalid params / McpServer enum).
|
|
assert.deepEqual(
|
|
toAcpMcpEnvPairs([{ name: "NETCATTY_MCP_PORT", value: "9" }]),
|
|
[{ name: "NETCATTY_MCP_PORT", value: "9" }],
|
|
);
|
|
// If a plain object sneaks in, still emit pairs (not a map).
|
|
assert.deepEqual(
|
|
toAcpMcpEnvPairs({ NETCATTY_MCP_PORT: "9", NETCATTY_MCP_TOKEN: "t" }),
|
|
[
|
|
{ name: "NETCATTY_MCP_PORT", value: "9" },
|
|
{ name: "NETCATTY_MCP_TOKEN", value: "t" },
|
|
],
|
|
);
|
|
assert.deepEqual(toAcpMcpEnvPairs(undefined), []);
|
|
});
|
|
|
|
test("toAcpMcpServers maps injectMcp env as name/value pairs for session/new", () => {
|
|
assert.deepEqual(
|
|
toAcpMcpServers([{
|
|
name: "netcatty-remote-hosts",
|
|
command: "node",
|
|
args: ["mcp.cjs"],
|
|
env: [{ name: "NETCATTY_MCP_PORT", value: "9" }, { name: "NETCATTY_MCP_TOKEN", value: "t" }],
|
|
}]),
|
|
[{
|
|
name: "netcatty-remote-hosts",
|
|
type: "stdio",
|
|
command: "node",
|
|
args: ["mcp.cjs"],
|
|
env: [
|
|
{ name: "NETCATTY_MCP_PORT", value: "9" },
|
|
{ name: "NETCATTY_MCP_TOKEN", value: "t" },
|
|
],
|
|
}],
|
|
);
|
|
// Must never emit object-map env (Grok session/new rejects it).
|
|
const mapped = toAcpMcpServers([{
|
|
name: "x",
|
|
command: "node",
|
|
args: [],
|
|
env: { A: "1" },
|
|
}]);
|
|
assert.ok(Array.isArray(mapped[0].env));
|
|
assert.equal(mapped[0].type, "stdio");
|
|
assert.deepEqual(mapped[0].env, [{ name: "A", value: "1" }]);
|
|
});
|
|
|
|
test("buildGrokAcpSessionNewParams injects MCP servers and MCP-mode rules", () => {
|
|
const params = buildGrokAcpSessionNewParams({
|
|
cwd: "/repo",
|
|
permissionMode: "auto",
|
|
toolIntegrationMode: "mcp",
|
|
injectedMcpServers: [{
|
|
name: "netcatty-remote-hosts",
|
|
command: "node",
|
|
args: ["mcp.cjs"],
|
|
env: [{ name: "NETCATTY_MCP_PORT", value: "1" }],
|
|
}],
|
|
});
|
|
assert.equal(params.cwd, resolveGrokAcpCwd("/repo"));
|
|
assert.equal(params.mcpServers[0].name, "netcatty-remote-hosts");
|
|
assert.equal(params.mcpServers[0].type, "stdio");
|
|
assert.ok(Array.isArray(params.mcpServers[0].env));
|
|
assert.deepEqual(params.mcpServers[0].env, [{ name: "NETCATTY_MCP_PORT", value: "1" }]);
|
|
// Explicitly forbid the broken object-map shape in the shipped builder output.
|
|
assert.equal(typeof params.mcpServers[0].env.NETCATTY_MCP_PORT, "undefined");
|
|
assert.equal(params._meta.yoloMode, true);
|
|
assert.match(String(params._meta.rules || ""), /netcatty-remote-hosts|MCP mode/i);
|
|
assert.match(String(params._meta.rules || ""), /run_terminal_command|search_replace|write/);
|
|
// Soft rules list the same local tools as the hard CLI deny list.
|
|
for (const tool of GROK_MCP_MODE_DISALLOWED_LOCAL_TOOLS) {
|
|
assert.match(String(params._meta.rules || ""), new RegExp(tool));
|
|
}
|
|
|
|
const skills = buildGrokAcpSessionNewParams({
|
|
cwd: "/repo",
|
|
permissionMode: "auto",
|
|
toolIntegrationMode: "skills",
|
|
injectedMcpServers: [],
|
|
});
|
|
assert.equal(skills._meta.yoloMode, true);
|
|
assert.equal(skills._meta.rules, undefined);
|
|
});
|
|
|
|
test("planGrokAcpSessionEstablish prefers resume then load then new", () => {
|
|
assert.deepEqual(planGrokAcpSessionEstablish({ resumeSessionId: null }), ["new"]);
|
|
assert.deepEqual(planGrokAcpSessionEstablish({
|
|
resumeSessionId: "s1",
|
|
agentCapabilities: { resume: true, loadSession: true, hasCapabilityInfo: true },
|
|
}), ["resume", "load", "new"]);
|
|
assert.deepEqual(planGrokAcpSessionEstablish({
|
|
resumeSessionId: "s1",
|
|
agentCapabilities: { resume: true, loadSession: false, hasCapabilityInfo: true },
|
|
}), ["resume", "new"]);
|
|
assert.deepEqual(planGrokAcpSessionEstablish({
|
|
resumeSessionId: "s1",
|
|
agentCapabilities: { resume: false, loadSession: true, hasCapabilityInfo: true },
|
|
}), ["load", "new"]);
|
|
// Unknown capabilities: try resume + load then new (Grok versions vary).
|
|
assert.deepEqual(planGrokAcpSessionEstablish({
|
|
resumeSessionId: "s1",
|
|
agentCapabilities: { resume: false, loadSession: false, hasCapabilityInfo: false },
|
|
}), ["resume", "load", "new"]);
|
|
});
|
|
|
|
test("parseGrokAcpAgentCapabilities reads loadSession and sessionCapabilities.resume", () => {
|
|
assert.deepEqual(parseGrokAcpAgentCapabilities({
|
|
agentCapabilities: {
|
|
loadSession: true,
|
|
sessionCapabilities: { resume: {} },
|
|
},
|
|
}), { loadSession: true, resume: true, hasCapabilityInfo: true });
|
|
assert.deepEqual(parseGrokAcpAgentCapabilities({}), {
|
|
loadSession: false,
|
|
resume: false,
|
|
hasCapabilityInfo: false,
|
|
});
|
|
});
|
|
|
|
test("selectGrokAcpAuthMethodId follows xAI sample precedence", () => {
|
|
assert.deepEqual(
|
|
selectGrokAcpAuthMethodId({ authMethods: [] }, {}),
|
|
{ methodId: null, required: false },
|
|
);
|
|
assert.deepEqual(
|
|
selectGrokAcpAuthMethodId({
|
|
authMethods: [{ id: "xai.api_key" }, { id: "cached_token" }],
|
|
}, { XAI_API_KEY: "xai-test" }),
|
|
{ methodId: "xai.api_key", required: true },
|
|
);
|
|
assert.deepEqual(
|
|
selectGrokAcpAuthMethodId({
|
|
authMethods: [{ id: "xai.api_key" }, { id: "cached_token" }],
|
|
}, {}),
|
|
{ methodId: "cached_token", required: true },
|
|
);
|
|
assert.deepEqual(
|
|
selectGrokAcpAuthMethodId({ authMethods: [{ id: "other" }] }, {}),
|
|
{ methodId: null, required: true },
|
|
);
|
|
});
|
|
|
|
test("authenticateGrokAcp skips when no methods; errors when required method missing", async () => {
|
|
const calls = [];
|
|
const rpc = {
|
|
async request(method, params) {
|
|
calls.push([method, params]);
|
|
return {};
|
|
},
|
|
};
|
|
const skipped = await authenticateGrokAcp(rpc, { authMethods: [] }, {});
|
|
assert.equal(skipped.skipped, true);
|
|
assert.deepEqual(calls, []);
|
|
|
|
await assert.rejects(
|
|
() => authenticateGrokAcp(rpc, { authMethods: [{ id: "unknown" }] }, {}),
|
|
/grok login|XAI_API_KEY/i,
|
|
);
|
|
|
|
const ok = await authenticateGrokAcp(
|
|
rpc,
|
|
{ authMethods: [{ id: "cached_token" }] },
|
|
{},
|
|
);
|
|
assert.equal(ok.methodId, "cached_token");
|
|
assert.equal(calls[0][0], "authenticate");
|
|
assert.equal(calls[0][1].methodId, "cached_token");
|
|
assert.equal(calls[0][1]._meta.headless, true);
|
|
});
|
|
|
|
test("establishGrokAcpSession tries resume then falls back to load then new", async () => {
|
|
const methods = [];
|
|
const rpc = {
|
|
async request(method, params) {
|
|
methods.push([method, params]);
|
|
if (method === "session/resume") throw new Error("resume unsupported");
|
|
if (method === "session/load") {
|
|
// Simulate history replay side-channel; caller keeps acceptUpdates false.
|
|
return { sessionId: "loaded-1" };
|
|
}
|
|
throw new Error(`unexpected ${method}`);
|
|
},
|
|
};
|
|
const result = await establishGrokAcpSession(rpc, {
|
|
resumeSessionId: "prev-1",
|
|
cwd: "/repo",
|
|
injectedMcpServers: [],
|
|
agentCapabilities: { resume: true, loadSession: true, hasCapabilityInfo: true },
|
|
});
|
|
assert.equal(result.method, "load");
|
|
assert.equal(result.sessionId, "loaded-1");
|
|
assert.deepEqual(methods.map((m) => m[0]), ["session/resume", "session/load"]);
|
|
assert.equal(methods[0][1].sessionId, "prev-1");
|
|
assert.equal(methods[0][1].cwd, resolveGrokAcpCwd("/repo"));
|
|
});
|
|
|
|
test("establishGrokAcpSession prefers resume when it succeeds", async () => {
|
|
const methods = [];
|
|
const rpc = {
|
|
async request(method, params) {
|
|
methods.push(method);
|
|
if (method === "session/resume") return {};
|
|
throw new Error(`unexpected ${method}`);
|
|
},
|
|
};
|
|
const result = await establishGrokAcpSession(rpc, {
|
|
resumeSessionId: "prev-2",
|
|
cwd: path.resolve("/repo"),
|
|
injectedMcpServers: [],
|
|
agentCapabilities: { resume: true, loadSession: true, hasCapabilityInfo: true },
|
|
});
|
|
assert.equal(result.method, "resume");
|
|
assert.equal(result.sessionId, "prev-2");
|
|
assert.deepEqual(methods, ["session/resume"]);
|
|
});
|
|
|
|
test("buildGrokAcpSessionResumeOrLoadParams keeps absolute cwd and MCP pairs", () => {
|
|
const params = buildGrokAcpSessionResumeOrLoadParams({
|
|
sessionId: "s1",
|
|
cwd: "relative-dir",
|
|
injectedMcpServers: [{
|
|
name: "netcatty-remote-hosts",
|
|
command: "node",
|
|
args: ["x"],
|
|
env: { A: "1" },
|
|
}],
|
|
});
|
|
assert.equal(params.sessionId, "s1");
|
|
assert.equal(params.cwd, resolveGrokAcpCwd("relative-dir"));
|
|
assert.ok(path.isAbsolute(params.cwd));
|
|
assert.deepEqual(params.mcpServers[0].env, [{ name: "A", value: "1" }]);
|
|
});
|
|
|
|
test("buildGrokAcpInitializeParams and prompt params follow ACP shapes", () => {
|
|
const init = buildGrokAcpInitializeParams();
|
|
assert.equal(init.protocolVersion, ACP_PROTOCOL_VERSION);
|
|
assert.equal(init.clientInfo.name, "netcatty");
|
|
assert.deepEqual(
|
|
buildGrokAcpPromptParams("sess-1", "hello"),
|
|
{ sessionId: "sess-1", prompt: [{ type: "text", text: "hello" }] },
|
|
);
|
|
});
|
|
|
|
test("translateGrokAcpUpdate maps text, thought, tools to canonical emitter events", () => {
|
|
const emitter = makeEmitter();
|
|
const state = {};
|
|
translateGrokAcpUpdate({
|
|
sessionUpdate: "agent_thought_chunk",
|
|
content: { text: "plan" },
|
|
}, emitter, state);
|
|
translateGrokAcpUpdate({
|
|
sessionUpdate: "agent_message_chunk",
|
|
content: { text: "Hi" },
|
|
}, emitter, state);
|
|
translateGrokAcpUpdate({
|
|
sessionUpdate: "tool_call",
|
|
toolCallId: "c1",
|
|
toolName: "mcp__netcatty-remote-hosts__get_environment",
|
|
rawInput: { x: 1 },
|
|
}, emitter, state);
|
|
translateGrokAcpUpdate({
|
|
sessionUpdate: "tool_call_update",
|
|
toolCallId: "c1",
|
|
status: "completed",
|
|
rawOutput: { ok: true },
|
|
}, emitter, state);
|
|
|
|
assert.deepEqual(emitter.calls, [
|
|
["reasoning", "plan"],
|
|
["reasoningEnd"],
|
|
["text", "Hi"],
|
|
["toolCall", "mcp__netcatty-remote-hosts__get_environment", { x: 1 }, "c1"],
|
|
["toolResult", "c1", "{\"ok\":true}", "mcp__netcatty-remote-hosts__get_environment"],
|
|
]);
|
|
});
|
|
|
|
test("translateGrokAcpUpdate plan uses shared { text, completed } shape not content/status", () => {
|
|
const emitter = makeEmitter();
|
|
translateGrokAcpUpdate({
|
|
sessionUpdate: "plan",
|
|
entries: [
|
|
{ content: "Map APIs", status: "completed" },
|
|
{ text: "Write tests", status: "pending" },
|
|
],
|
|
}, emitter, {});
|
|
const planCall = emitter.calls.find((c) => c[0] === "planUpdate");
|
|
assert.ok(planCall, "plan must emit planUpdate");
|
|
assert.deepEqual(planCall[2], [
|
|
{ text: "Map APIs", completed: true },
|
|
{ text: "Write tests", completed: false },
|
|
]);
|
|
assert.equal(planCall[3], "running");
|
|
// Adapter only treats top-level "completed" as done; "updated" stuck as running forever.
|
|
assert.notEqual(planCall[3], "updated");
|
|
});
|
|
|
|
test("createJsonRpcClient correlates request ids and parses lines", async () => {
|
|
const written = [];
|
|
const client = createJsonRpcClient({
|
|
write: (line) => written.push(line),
|
|
onMessage: (message, pending) => {
|
|
if (message.id != null && pending.has(message.id)) {
|
|
const waiter = pending.get(message.id);
|
|
pending.delete(message.id);
|
|
waiter.resolve(message.result);
|
|
}
|
|
},
|
|
});
|
|
const pending = client.request("initialize", { protocolVersion: 1 });
|
|
assert.match(written[0], /"method":"initialize"/);
|
|
const sent = JSON.parse(written[0]);
|
|
client.handleLine(JSON.stringify({ jsonrpc: "2.0", id: sent.id, result: { ok: true } }));
|
|
assert.deepEqual(await pending, { ok: true });
|
|
});
|
|
|
|
test("handleGrokAcpMessage routes session/update notifications", () => {
|
|
const emitter = makeEmitter();
|
|
const state = { acceptUpdates: true };
|
|
const pending = new Map();
|
|
handleGrokAcpMessage({
|
|
jsonrpc: "2.0",
|
|
method: "session/update",
|
|
params: {
|
|
sessionId: "s1",
|
|
update: {
|
|
sessionUpdate: "agent_message_chunk",
|
|
content: { text: "ok" },
|
|
},
|
|
},
|
|
}, { emitter, state, pending });
|
|
assert.equal(state.sessionId, "s1");
|
|
assert.deepEqual(emitter.calls, [
|
|
["text", "ok"],
|
|
["sessionId", "s1"],
|
|
]);
|
|
});
|
|
|
|
test("handleGrokAcpMessage suppresses session/load history until prompt accepts updates", () => {
|
|
const emitter = makeEmitter();
|
|
const state = { acceptUpdates: false };
|
|
const pending = new Map();
|
|
// Historical replay during session/load must not pollute the current turn.
|
|
handleGrokAcpMessage({
|
|
jsonrpc: "2.0",
|
|
method: "session/update",
|
|
params: {
|
|
sessionId: "s1",
|
|
update: {
|
|
sessionUpdate: "agent_message_chunk",
|
|
content: { text: "previous turn reply" },
|
|
},
|
|
},
|
|
}, { emitter, state, pending });
|
|
assert.equal(state.sessionId, "s1");
|
|
assert.deepEqual(emitter.calls, []);
|
|
|
|
state.acceptUpdates = true;
|
|
handleGrokAcpMessage({
|
|
jsonrpc: "2.0",
|
|
method: "session/update",
|
|
params: {
|
|
sessionId: "s1",
|
|
update: {
|
|
sessionUpdate: "agent_message_chunk",
|
|
content: { text: "only this turn" },
|
|
},
|
|
},
|
|
}, { emitter, state, pending });
|
|
assert.deepEqual(emitter.calls, [["text", "only this turn"]]);
|
|
});
|
|
|
|
test("runGrokAcpTurn drives initialize/authenticate/session/new/prompt via fixture RPC", async () => {
|
|
const emitter = makeEmitter();
|
|
const methods = [];
|
|
const result = await runGrokAcpTurn({
|
|
prompt: "hi",
|
|
binPath: "/usr/bin/grok",
|
|
cwd: "/repo",
|
|
permissionMode: "auto",
|
|
toolIntegrationMode: "mcp",
|
|
env: { XAI_API_KEY: "xai-test" },
|
|
injectedMcpServers: [{
|
|
name: "netcatty-remote-hosts",
|
|
command: "node",
|
|
args: ["mcp.cjs"],
|
|
env: [{ name: "NETCATTY_MCP_PORT", value: "7" }],
|
|
}],
|
|
emitter,
|
|
rpcClientFactory: ({ emitter: em, state }) => ({
|
|
async request(method, params) {
|
|
methods.push([method, params]);
|
|
if (method === "initialize") {
|
|
return {
|
|
protocolVersion: ACP_PROTOCOL_VERSION,
|
|
authMethods: [{ id: "xai.api_key" }, { id: "cached_token" }],
|
|
};
|
|
}
|
|
if (method === "authenticate") {
|
|
assert.equal(params.methodId, "xai.api_key");
|
|
assert.equal(params._meta.headless, true);
|
|
return {};
|
|
}
|
|
if (method === "session/new") {
|
|
assert.equal(params.cwd, resolveGrokAcpCwd("/repo"));
|
|
assert.equal(params.mcpServers[0].name, "netcatty-remote-hosts");
|
|
assert.equal(params.mcpServers[0].type, "stdio");
|
|
assert.ok(Array.isArray(params.mcpServers[0].env));
|
|
assert.deepEqual(
|
|
params.mcpServers[0].env,
|
|
[{ name: "NETCATTY_MCP_PORT", value: "7" }],
|
|
);
|
|
return { sessionId: "acp-sess-1" };
|
|
}
|
|
if (method === "session/prompt") {
|
|
// Simulate streamed ACP updates during the prompt
|
|
translateGrokAcpUpdate({
|
|
sessionUpdate: "agent_message_chunk",
|
|
content: { text: "hello-acp" },
|
|
}, em, state);
|
|
return { stopReason: "end_turn" };
|
|
}
|
|
throw new Error(`unexpected method ${method}`);
|
|
},
|
|
}),
|
|
});
|
|
|
|
assert.equal(result.runtime, "acp");
|
|
assert.equal(result.sessionId, "acp-sess-1");
|
|
assert.deepEqual(
|
|
methods.map((m) => m[0]),
|
|
["initialize", "authenticate", "session/new", "session/prompt"],
|
|
);
|
|
assert.ok(emitter.calls.some((c) => c[0] === "text" && c[1] === "hello-acp"));
|
|
assert.ok(emitter.calls.some((c) => c[0] === "sessionId" && c[1] === "acp-sess-1"));
|
|
assert.ok(emitter.calls.some((c) => c[0] === "done"));
|
|
});
|
|
|
|
test("runGrokAcpTurn seeds history when resume/load fail and session/new is used", async () => {
|
|
const emitter = makeEmitter();
|
|
const methods = [];
|
|
const historySeed = [
|
|
"[Conversation context replay: the agent SDK may be starting from a fresh local session, so use these prior turns as context and answer only the latest user request.]",
|
|
"USER: earlier question",
|
|
"ASSISTANT: earlier answer",
|
|
].join("\n");
|
|
await runGrokAcpTurn({
|
|
prompt: "latest only",
|
|
binPath: "/usr/bin/grok",
|
|
resumeSessionId: "stale-sess",
|
|
historySeed,
|
|
permissionMode: "auto",
|
|
emitter,
|
|
rpcClientFactory: () => ({
|
|
async request(method, params) {
|
|
methods.push([method, params]);
|
|
if (method === "initialize") {
|
|
return {
|
|
protocolVersion: ACP_PROTOCOL_VERSION,
|
|
agentCapabilities: {
|
|
loadSession: true,
|
|
sessionCapabilities: { resume: {} },
|
|
},
|
|
authMethods: [],
|
|
};
|
|
}
|
|
if (method === "session/resume") throw new Error("missing session");
|
|
if (method === "session/load") throw new Error("cannot load");
|
|
if (method === "session/new") return { sessionId: "fresh-sess" };
|
|
if (method === "session/prompt") {
|
|
assert.equal(params.sessionId, "fresh-sess");
|
|
const text = params.prompt?.[0]?.text || "";
|
|
assert.match(text, /Conversation context replay/);
|
|
assert.match(text, /earlier question/);
|
|
assert.match(text, /latest only$/);
|
|
return { stopReason: "end_turn" };
|
|
}
|
|
throw new Error(`unexpected method ${method}`);
|
|
},
|
|
}),
|
|
});
|
|
assert.deepEqual(methods.map((m) => m[0]), [
|
|
"initialize",
|
|
"session/resume",
|
|
"session/load",
|
|
"session/new",
|
|
"session/prompt",
|
|
]);
|
|
assert.ok(emitter.calls.some((c) => c[0] === "sessionId" && c[1] === "fresh-sess"));
|
|
});
|
|
|
|
test("runGrokAcpTurn does not seed history when session/resume succeeds", async () => {
|
|
const emitter = makeEmitter();
|
|
const historySeed = "USER: earlier\nASSISTANT: earlier answer";
|
|
await runGrokAcpTurn({
|
|
prompt: "latest only",
|
|
binPath: "/usr/bin/grok",
|
|
resumeSessionId: "live-sess",
|
|
historySeed,
|
|
permissionMode: "auto",
|
|
emitter,
|
|
rpcClientFactory: () => ({
|
|
async request(method, params) {
|
|
if (method === "initialize") {
|
|
return {
|
|
protocolVersion: ACP_PROTOCOL_VERSION,
|
|
agentCapabilities: {
|
|
loadSession: true,
|
|
sessionCapabilities: { resume: {} },
|
|
},
|
|
authMethods: [],
|
|
};
|
|
}
|
|
if (method === "session/resume") return {};
|
|
if (method === "session/prompt") {
|
|
const text = params.prompt?.[0]?.text || "";
|
|
assert.equal(text, "latest only");
|
|
assert.doesNotMatch(text, /earlier/);
|
|
return { stopReason: "end_turn" };
|
|
}
|
|
throw new Error(`unexpected method ${method}`);
|
|
},
|
|
}),
|
|
});
|
|
});
|
|
|
|
test("runGrokAcpTurn prefers session/resume and keeps load history off the emitter", async () => {
|
|
const emitter = makeEmitter();
|
|
const methods = [];
|
|
const result = await runGrokAcpTurn({
|
|
prompt: "follow up",
|
|
binPath: "/usr/bin/grok",
|
|
cwd: "/repo",
|
|
resumeSessionId: "prior-sess",
|
|
permissionMode: "auto",
|
|
toolIntegrationMode: "mcp",
|
|
emitter,
|
|
rpcClientFactory: ({ emitter: em, state }) => ({
|
|
async request(method, params) {
|
|
methods.push(method);
|
|
if (method === "initialize") {
|
|
return {
|
|
protocolVersion: ACP_PROTOCOL_VERSION,
|
|
agentCapabilities: {
|
|
loadSession: true,
|
|
sessionCapabilities: { resume: {} },
|
|
},
|
|
authMethods: [],
|
|
};
|
|
}
|
|
if (method === "session/resume") {
|
|
assert.equal(params.sessionId, "prior-sess");
|
|
// Resume must not replay; even if a rogue update arrives pre-prompt, suppress it.
|
|
handleGrokAcpMessage({
|
|
jsonrpc: "2.0",
|
|
method: "session/update",
|
|
params: {
|
|
sessionId: "prior-sess",
|
|
update: {
|
|
sessionUpdate: "agent_message_chunk",
|
|
content: { text: "SHOULD NOT APPEAR" },
|
|
},
|
|
},
|
|
}, { emitter: em, state, pending: new Map() });
|
|
return {};
|
|
}
|
|
if (method === "session/prompt") {
|
|
assert.equal(state.acceptUpdates, true);
|
|
translateGrokAcpUpdate({
|
|
sessionUpdate: "agent_message_chunk",
|
|
content: { text: "only this turn" },
|
|
}, em, state);
|
|
return { stopReason: "end_turn" };
|
|
}
|
|
throw new Error(`unexpected method ${method}`);
|
|
},
|
|
}),
|
|
});
|
|
|
|
assert.equal(result.sessionId, "prior-sess");
|
|
assert.deepEqual(methods, ["initialize", "session/resume", "session/prompt"]);
|
|
assert.deepEqual(
|
|
emitter.calls.filter((c) => c[0] === "text"),
|
|
[["text", "only this turn"]],
|
|
);
|
|
assert.ok(!emitter.calls.some((c) => c[0] === "text" && String(c[1]).includes("SHOULD NOT")));
|
|
});
|
|
|
|
test("runGrokAcpTurn falls back to session/load and suppresses history replay text", async () => {
|
|
const emitter = makeEmitter();
|
|
const methods = [];
|
|
const result = await runGrokAcpTurn({
|
|
prompt: "follow up",
|
|
binPath: "/usr/bin/grok",
|
|
resumeSessionId: "prior-load",
|
|
permissionMode: "auto",
|
|
emitter,
|
|
rpcClientFactory: ({ emitter: em, state }) => ({
|
|
async request(method) {
|
|
methods.push(method);
|
|
if (method === "initialize") {
|
|
return {
|
|
protocolVersion: ACP_PROTOCOL_VERSION,
|
|
agentCapabilities: {
|
|
loadSession: true,
|
|
sessionCapabilities: { resume: {} },
|
|
},
|
|
authMethods: [],
|
|
};
|
|
}
|
|
if (method === "session/resume") throw new Error("resume not available");
|
|
if (method === "session/load") {
|
|
assert.equal(state.acceptUpdates, false);
|
|
// Load-style history replay must not hit the current bubble.
|
|
handleGrokAcpMessage({
|
|
jsonrpc: "2.0",
|
|
method: "session/update",
|
|
params: {
|
|
sessionId: "prior-load",
|
|
update: {
|
|
sessionUpdate: "agent_message_chunk",
|
|
content: { text: "previous turn reply" },
|
|
},
|
|
},
|
|
}, { emitter: em, state, pending: new Map() });
|
|
return { sessionId: "prior-load" };
|
|
}
|
|
if (method === "session/prompt") {
|
|
translateGrokAcpUpdate({
|
|
sessionUpdate: "agent_message_chunk",
|
|
content: { text: "fresh reply" },
|
|
}, em, state);
|
|
return { stopReason: "end_turn" };
|
|
}
|
|
throw new Error(`unexpected method ${method}`);
|
|
},
|
|
}),
|
|
});
|
|
|
|
assert.equal(result.sessionId, "prior-load");
|
|
assert.deepEqual(methods, [
|
|
"initialize",
|
|
"session/resume",
|
|
"session/load",
|
|
"session/prompt",
|
|
]);
|
|
assert.deepEqual(
|
|
emitter.calls.filter((c) => c[0] === "text"),
|
|
[["text", "fresh reply"]],
|
|
);
|
|
});
|
|
|
|
test("runGrokAcpTurn maps missing auth methods to a clear user error", async () => {
|
|
const emitter = makeEmitter();
|
|
await runGrokAcpTurn({
|
|
prompt: "hi",
|
|
binPath: "/usr/bin/grok",
|
|
emitter,
|
|
rpcClientFactory: () => ({
|
|
async request(method) {
|
|
if (method === "initialize") {
|
|
return {
|
|
protocolVersion: ACP_PROTOCOL_VERSION,
|
|
authMethods: [{ id: "weird-method" }],
|
|
};
|
|
}
|
|
throw new Error(`unexpected method ${method}`);
|
|
},
|
|
}),
|
|
});
|
|
const err = emitter.calls.find((c) => c[0] === "error");
|
|
assert.ok(err);
|
|
assert.match(String(err[1]), /not logged in|grok login|XAI_API_KEY/i);
|
|
});
|
|
|
|
test("runGrokAcpTurn reports missing CLI clearly", async () => {
|
|
const emitter = makeEmitter();
|
|
const result = await runGrokAcpTurn({
|
|
prompt: "hi",
|
|
binPath: "",
|
|
emitter,
|
|
});
|
|
assert.equal(result.sessionId, null);
|
|
assert.equal(result.runtime, "acp");
|
|
assert.match(String(emitter.calls[0]?.[1] || ""), /not found/i);
|
|
});
|
|
|
|
test("runGrokAcpTurn ignores benign stdin EPIPE after turn completes", async () => {
|
|
// Matches processErrorGuards: EPIPE must not crash main or fail a completed turn.
|
|
const { EventEmitter } = require("node:events");
|
|
const emitter = makeEmitter();
|
|
let stdinRef = null;
|
|
|
|
function makeFakeChild() {
|
|
const child = new EventEmitter();
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
const stdin = new EventEmitter();
|
|
stdin.destroyed = false;
|
|
stdin.writable = true;
|
|
stdin.write = (line, cb) => {
|
|
let msg;
|
|
try { msg = JSON.parse(String(line).trim()); } catch {
|
|
if (typeof cb === "function") cb();
|
|
return true;
|
|
}
|
|
if (msg?.id == null) {
|
|
if (typeof cb === "function") cb();
|
|
return true;
|
|
}
|
|
queueMicrotask(() => {
|
|
if (msg.method === "initialize") {
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: msg.id,
|
|
result: { protocolVersion: ACP_PROTOCOL_VERSION, authMethods: [] },
|
|
})}\n`));
|
|
} else if (msg.method === "session/new") {
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: msg.id,
|
|
result: { sessionId: "s-epipe" },
|
|
})}\n`));
|
|
} else if (msg.method === "session/prompt") {
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
method: "session/update",
|
|
params: {
|
|
sessionId: "s-epipe",
|
|
update: { sessionUpdate: "agent_message_chunk", content: { text: "ok" } },
|
|
},
|
|
})}\n`));
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: msg.id,
|
|
result: { stopReason: "end_turn" },
|
|
})}\n`));
|
|
// Teardown race: peer closed stdin after prompt success.
|
|
queueMicrotask(() => {
|
|
const err = new Error("write EPIPE");
|
|
err.code = "EPIPE";
|
|
stdin.emit("error", err);
|
|
child.emit("close", 1);
|
|
});
|
|
} else {
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: msg.id,
|
|
result: {},
|
|
})}\n`));
|
|
}
|
|
if (typeof cb === "function") cb();
|
|
});
|
|
return true;
|
|
};
|
|
stdin.end = () => {};
|
|
child.stdin = stdin;
|
|
stdinRef = stdin;
|
|
child.pid = 3;
|
|
child.killed = false;
|
|
child.exitCode = null;
|
|
child.kill = () => {
|
|
child.killed = true;
|
|
queueMicrotask(() => child.emit("close", 1));
|
|
};
|
|
return child;
|
|
}
|
|
|
|
await runGrokAcpTurn({
|
|
prompt: "hi",
|
|
binPath: "C:\\fake\\grok.exe",
|
|
permissionMode: "auto",
|
|
emitter,
|
|
spawnImpl: () => makeFakeChild(),
|
|
forceKillImpl: (c) => { c.kill(); },
|
|
});
|
|
|
|
assert.ok(stdinRef, "stdin must have been wired");
|
|
assert.ok(emitter.calls.some((c) => c[0] === "done"));
|
|
assert.ok(!emitter.calls.some((c) => c[0] === "error"), "post-completion EPIPE is benign");
|
|
});
|
|
|
|
test("runGrokAcpTurn forwards usage from session/prompt result", async () => {
|
|
const emitter = makeEmitter();
|
|
await runGrokAcpTurn({
|
|
prompt: "hi",
|
|
binPath: "/usr/bin/grok",
|
|
permissionMode: "auto",
|
|
emitter,
|
|
rpcClientFactory: ({ emitter: em, state }) => ({
|
|
async request(method) {
|
|
if (method === "initialize") {
|
|
return { protocolVersion: ACP_PROTOCOL_VERSION, authMethods: [] };
|
|
}
|
|
if (method === "session/new") return { sessionId: "s-usage" };
|
|
if (method === "session/prompt") {
|
|
translateGrokAcpUpdate({
|
|
sessionUpdate: "agent_message_chunk",
|
|
content: { text: "ok" },
|
|
}, em, state);
|
|
// Live Grok shape under _meta.usage (camelCase + cachedReadTokens).
|
|
return {
|
|
stopReason: "end_turn",
|
|
_meta: {
|
|
inputTokens: 27144,
|
|
outputTokens: 29,
|
|
totalTokens: 27174,
|
|
cachedReadTokens: 2560,
|
|
reasoningTokens: 24,
|
|
usage: {
|
|
inputTokens: 27144,
|
|
outputTokens: 29,
|
|
totalTokens: 27173,
|
|
cachedReadTokens: 2560,
|
|
reasoningTokens: 24,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
throw new Error(`unexpected ${method}`);
|
|
},
|
|
}),
|
|
});
|
|
const usage = emitter.calls.find((c) => c[0] === "usage");
|
|
assert.ok(usage);
|
|
assert.deepEqual(usage[1], {
|
|
inputTokens: 27144,
|
|
cachedInputTokens: 2560,
|
|
outputTokens: 29,
|
|
reasoningTokens: 24,
|
|
totalTokens: 27173,
|
|
});
|
|
assert.ok(emitter.calls.some((c) => c[0] === "done"));
|
|
});
|
|
|
|
test("runGrokAcpTurn emits usage on prompt response even when process closes same tick", async () => {
|
|
// Repro of Token ~1: onPromptComplete resolves promptDone in the same turn as
|
|
// the prompt RPC result; Promise.race often returns "closed" and used to skip usage.
|
|
const { EventEmitter } = require("node:events");
|
|
const emitter = makeEmitter();
|
|
|
|
function makeFakeChild() {
|
|
const child = new EventEmitter();
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
child.stdin = {
|
|
destroyed: false,
|
|
write(line) {
|
|
let msg;
|
|
try { msg = JSON.parse(String(line).trim()); } catch { return; }
|
|
if (msg?.id == null) return;
|
|
queueMicrotask(() => {
|
|
if (msg.method === "initialize") {
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0", id: msg.id,
|
|
result: { protocolVersion: ACP_PROTOCOL_VERSION, authMethods: [] },
|
|
})}\n`));
|
|
} else if (msg.method === "session/new") {
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0", id: msg.id, result: { sessionId: "s-race" },
|
|
})}\n`));
|
|
} else if (msg.method === "session/prompt") {
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0", method: "session/update",
|
|
params: {
|
|
sessionId: "s-race",
|
|
update: { sessionUpdate: "agent_message_chunk", content: { text: "long reply…" } },
|
|
},
|
|
})}\n`));
|
|
// Prompt result with real Grok usage, then same-tick process close
|
|
// (simulates onPromptComplete → promptDone racing the await).
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: msg.id,
|
|
result: {
|
|
stopReason: "end_turn",
|
|
_meta: {
|
|
usage: {
|
|
inputTokens: 1200,
|
|
outputTokens: 800,
|
|
totalTokens: 2000,
|
|
cachedReadTokens: 100,
|
|
reasoningTokens: 50,
|
|
},
|
|
},
|
|
},
|
|
})}\n`));
|
|
queueMicrotask(() => child.emit("close", 1));
|
|
} else {
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0", id: msg.id, result: {},
|
|
})}\n`));
|
|
}
|
|
});
|
|
},
|
|
end() {},
|
|
};
|
|
child.pid = 7;
|
|
child.killed = false;
|
|
child.exitCode = null;
|
|
child.kill = () => {
|
|
child.killed = true;
|
|
queueMicrotask(() => child.emit("close", 1));
|
|
};
|
|
return child;
|
|
}
|
|
|
|
await runGrokAcpTurn({
|
|
prompt: "write a long essay",
|
|
binPath: "C:\\fake\\grok.exe",
|
|
permissionMode: "auto",
|
|
emitter,
|
|
spawnImpl: () => makeFakeChild(),
|
|
forceKillImpl: (c) => { c.kill(); },
|
|
});
|
|
|
|
const usage = emitter.calls.find((c) => c[0] === "usage");
|
|
assert.ok(usage, "usage must be emitted even if process closes in the same tick");
|
|
assert.deepEqual(usage[1], {
|
|
inputTokens: 1200,
|
|
cachedInputTokens: 100,
|
|
outputTokens: 800,
|
|
reasoningTokens: 50,
|
|
totalTokens: 2000,
|
|
});
|
|
assert.ok(emitter.calls.some((c) => c[0] === "done"));
|
|
assert.ok(!emitter.calls.some((c) => c[0] === "error"));
|
|
});
|
|
|
|
test("runGrokAcpTurn does not treat post-prompt exit code 1 as failure for tool-only turns", async () => {
|
|
// Repro: successful session/prompt (tools only, no assistant text) then
|
|
// process teardown with code 1 (Windows taskkill). Must emitDone, not error.
|
|
const { EventEmitter } = require("node:events");
|
|
const emitter = makeEmitter();
|
|
|
|
function makeFakeChild() {
|
|
const child = new EventEmitter();
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
child.stdin = {
|
|
destroyed: false,
|
|
write(line) {
|
|
let msg;
|
|
try { msg = JSON.parse(String(line).trim()); } catch { return; }
|
|
if (msg?.id == null) return;
|
|
queueMicrotask(() => {
|
|
if (msg.method === "initialize") {
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: msg.id,
|
|
result: { protocolVersion: ACP_PROTOCOL_VERSION, authMethods: [] },
|
|
})}\n`));
|
|
} else if (msg.method === "session/new") {
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: msg.id,
|
|
result: { sessionId: "s-tool" },
|
|
})}\n`));
|
|
} else if (msg.method === "session/prompt") {
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
method: "session/update",
|
|
params: {
|
|
sessionId: "s-tool",
|
|
update: {
|
|
sessionUpdate: "tool_call",
|
|
toolCallId: "t1",
|
|
toolName: "read_file",
|
|
rawInput: {},
|
|
},
|
|
},
|
|
})}\n`));
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
method: "session/update",
|
|
params: {
|
|
sessionId: "s-tool",
|
|
update: {
|
|
sessionUpdate: "tool_call_update",
|
|
toolCallId: "t1",
|
|
status: "completed",
|
|
rawOutput: "ok",
|
|
},
|
|
},
|
|
})}\n`));
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: msg.id,
|
|
result: { stopReason: "end_turn" },
|
|
})}\n`));
|
|
queueMicrotask(() => child.emit("close", 1));
|
|
} else {
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: msg.id,
|
|
result: {},
|
|
})}\n`));
|
|
}
|
|
});
|
|
},
|
|
end() {},
|
|
};
|
|
child.pid = 4242;
|
|
child.killed = false;
|
|
child.exitCode = null;
|
|
child.kill = () => {
|
|
child.killed = true;
|
|
queueMicrotask(() => child.emit("close", 1));
|
|
};
|
|
return child;
|
|
}
|
|
|
|
const result = await runGrokAcpTurn({
|
|
prompt: "tool only",
|
|
binPath: "C:\\fake\\grok.exe",
|
|
permissionMode: "auto",
|
|
emitter,
|
|
spawnImpl: () => makeFakeChild(),
|
|
forceKillImpl: (child) => { child.kill(); },
|
|
});
|
|
|
|
assert.equal(result.sessionId, "s-tool");
|
|
assert.ok(emitter.calls.some((c) => c[0] === "toolCall"));
|
|
assert.ok(emitter.calls.some((c) => c[0] === "done"));
|
|
assert.ok(!emitter.calls.some((c) => c[0] === "error"), "post-prompt exit 1 must not emitError");
|
|
});
|
|
|
|
test("runGrokAcpTurn still errors when process dies mid-turn with no completion", async () => {
|
|
const { EventEmitter } = require("node:events");
|
|
const emitter = makeEmitter();
|
|
|
|
function makeFakeChild() {
|
|
const child = new EventEmitter();
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
child.stdin = {
|
|
destroyed: false,
|
|
write(line) {
|
|
let msg;
|
|
try { msg = JSON.parse(String(line).trim()); } catch { return; }
|
|
if (msg?.id == null) return;
|
|
queueMicrotask(() => {
|
|
if (msg.method === "initialize") {
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: msg.id,
|
|
result: { protocolVersion: ACP_PROTOCOL_VERSION, authMethods: [] },
|
|
})}\n`));
|
|
} else if (msg.method === "session/new") {
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: msg.id,
|
|
result: { sessionId: "s-die" },
|
|
})}\n`));
|
|
} else if (msg.method === "session/prompt") {
|
|
// Die before answering prompt — turn never completed.
|
|
queueMicrotask(() => child.emit("close", 1));
|
|
} else {
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: msg.id,
|
|
result: {},
|
|
})}\n`));
|
|
}
|
|
});
|
|
},
|
|
end() {},
|
|
};
|
|
child.pid = 1;
|
|
child.killed = false;
|
|
child.exitCode = null;
|
|
child.kill = () => { child.killed = true; };
|
|
return child;
|
|
}
|
|
|
|
await runGrokAcpTurn({
|
|
prompt: "die mid turn",
|
|
binPath: "C:\\fake\\grok.exe",
|
|
permissionMode: "auto",
|
|
emitter,
|
|
spawnImpl: () => makeFakeChild(),
|
|
});
|
|
|
|
assert.ok(emitter.calls.some((c) => c[0] === "error"));
|
|
assert.ok(!emitter.calls.some((c) => c[0] === "done"));
|
|
});
|
|
|
|
test("runGrokAcpTurn errors when process dies after partial text without prompt result", async () => {
|
|
// Partial assistant text is not success — must not suppress exit failure.
|
|
const { EventEmitter } = require("node:events");
|
|
const emitter = makeEmitter();
|
|
|
|
function makeFakeChild() {
|
|
const child = new EventEmitter();
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
child.stdin = {
|
|
destroyed: false,
|
|
write(line) {
|
|
let msg;
|
|
try { msg = JSON.parse(String(line).trim()); } catch { return; }
|
|
if (msg?.id == null) return;
|
|
queueMicrotask(() => {
|
|
if (msg.method === "initialize") {
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: msg.id,
|
|
result: { protocolVersion: ACP_PROTOCOL_VERSION, authMethods: [] },
|
|
})}\n`));
|
|
} else if (msg.method === "session/new") {
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: msg.id,
|
|
result: { sessionId: "s-partial" },
|
|
})}\n`));
|
|
} else if (msg.method === "session/prompt") {
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
method: "session/update",
|
|
params: {
|
|
sessionId: "s-partial",
|
|
update: {
|
|
sessionUpdate: "agent_message_chunk",
|
|
content: { text: "half…" },
|
|
},
|
|
},
|
|
})}\n`));
|
|
// Crash before session/prompt result (turnCompleted stays false).
|
|
queueMicrotask(() => child.emit("close", 1));
|
|
} else {
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: msg.id,
|
|
result: {},
|
|
})}\n`));
|
|
}
|
|
});
|
|
},
|
|
end() {},
|
|
};
|
|
child.pid = 2;
|
|
child.killed = false;
|
|
child.exitCode = null;
|
|
child.kill = () => { child.killed = true; };
|
|
return child;
|
|
}
|
|
|
|
await runGrokAcpTurn({
|
|
prompt: "long answer",
|
|
binPath: "C:\\fake\\grok.exe",
|
|
permissionMode: "auto",
|
|
emitter,
|
|
spawnImpl: () => makeFakeChild(),
|
|
});
|
|
|
|
assert.ok(emitter.calls.some((c) => c[0] === "text" && c[1] === "half…"));
|
|
assert.ok(emitter.calls.some((c) => c[0] === "error"), "partial text + crash must error");
|
|
assert.ok(!emitter.calls.some((c) => c[0] === "done"));
|
|
});
|
|
|
|
test("registry grok backend defaults to ACP path and keeps streaming-json fallback", async () => {
|
|
assert.ok(listBackends().includes("grok"));
|
|
const driver = getDriver("grok");
|
|
assert.equal(typeof driver.runTurn, "function");
|
|
|
|
// Fallback runtime uses streaming-json builder path (no real binary).
|
|
const emitter = makeEmitter();
|
|
const fallback = await driver.runTurn({
|
|
prompt: "x",
|
|
binPath: "",
|
|
grokRuntime: "streaming-json",
|
|
emitter,
|
|
env: {},
|
|
});
|
|
assert.equal(fallback.sessionId, null);
|
|
assert.match(String(emitter.calls[0]?.[1] || ""), /not found/i);
|
|
|
|
// Default ACP path also surfaces missing CLI without hanging.
|
|
const emitter2 = makeEmitter();
|
|
const acp = await driver.runTurn({
|
|
prompt: "x",
|
|
binPath: "",
|
|
emitter: emitter2,
|
|
env: {},
|
|
});
|
|
assert.equal(acp.runtime, "acp");
|
|
assert.match(String(emitter2.calls[0]?.[1] || ""), /not found/i);
|
|
});
|
|
|
|
test("buildGrokAcpPermissionResponse selects offered optionId, never invents allow-once", () => {
|
|
// Live ACP uses optionId values like "allow-once" / "allow-always" / "reject".
|
|
const allowOnce = buildGrokAcpPermissionResponse({
|
|
options: [
|
|
{ optionId: "allow-once", kind: "allow_once", name: "Allow once" },
|
|
{ optionId: "allow-always", kind: "allow_always", name: "Allow always" },
|
|
{ optionId: "reject", kind: "reject_once", name: "Reject" },
|
|
],
|
|
}, true);
|
|
assert.deepEqual(allowOnce, {
|
|
outcome: { outcome: "selected", optionId: "allow-always" },
|
|
});
|
|
|
|
const allowOnlyOnce = buildGrokAcpPermissionResponse({
|
|
options: [
|
|
{ optionId: "allow-once", name: "Allow once" },
|
|
{ optionId: "reject", name: "Reject" },
|
|
],
|
|
}, true);
|
|
assert.deepEqual(allowOnlyOnce, {
|
|
outcome: { outcome: "selected", optionId: "allow-once" },
|
|
});
|
|
|
|
const deny = buildGrokAcpPermissionResponse({
|
|
options: [
|
|
{ optionId: "allow-once", kind: "allow_once" },
|
|
{ optionId: "reject", kind: "reject_once" },
|
|
],
|
|
}, false);
|
|
assert.deepEqual(deny, {
|
|
outcome: { outcome: "selected", optionId: "reject" },
|
|
});
|
|
|
|
// No options offered: deny cancels; allow falls back to underscore form only as last resort.
|
|
assert.deepEqual(
|
|
buildGrokAcpPermissionResponse({}, false),
|
|
{ outcome: { outcome: "cancelled" } },
|
|
);
|
|
assert.deepEqual(
|
|
buildGrokAcpPermissionResponse({ options: [] }, true),
|
|
{ outcome: { outcome: "selected", optionId: "allow_once" } },
|
|
);
|
|
});
|
|
|
|
test("handleGrokAcpMessage permission reply uses real optionId from params.options", () => {
|
|
const written = [];
|
|
const state = {
|
|
autoAllowPermissions: true,
|
|
writeResponse: (id, result) => written.push([id, result]),
|
|
};
|
|
handleGrokAcpMessage({
|
|
jsonrpc: "2.0",
|
|
id: 42,
|
|
method: "session/request_permission",
|
|
params: {
|
|
options: [
|
|
{ optionId: "allow-once", kind: "allow_once" },
|
|
{ optionId: "reject", kind: "reject_once" },
|
|
],
|
|
},
|
|
}, { emitter: makeEmitter(), state, pending: new Map() });
|
|
|
|
assert.equal(written.length, 1);
|
|
assert.equal(written[0][0], 42);
|
|
// Must echo the offered id (hyphen form), not invent underscore "allow_once".
|
|
assert.equal(written[0][1].outcome.optionId, "allow-once");
|
|
});
|
|
|
|
test("shouldReportGrokProcessExitFailure treats any incomplete close as failure", () => {
|
|
// Node close(null, "SIGTERM") when killed — must fail unfinished turns.
|
|
assert.equal(
|
|
shouldReportGrokProcessExitFailure({ turnCompleted: false }, null, null, "SIGTERM"),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
shouldReportGrokProcessExitFailure({ turnCompleted: false }, null, 1, null),
|
|
true,
|
|
);
|
|
// Exit 0 without session/prompt success is still a failure.
|
|
assert.equal(
|
|
shouldReportGrokProcessExitFailure({ turnCompleted: false }, null, 0, null),
|
|
true,
|
|
);
|
|
assert.equal(
|
|
shouldReportGrokProcessExitFailure({ turnCompleted: true }, null, 1, "SIGTERM"),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
shouldReportGrokProcessExitFailure({ turnCompleted: false }, { aborted: true }, null, "SIGTERM"),
|
|
false,
|
|
);
|
|
assert.equal(
|
|
shouldReportGrokProcessExitFailure({ turnCompleted: false, failed: true }, null, 1, null),
|
|
false,
|
|
);
|
|
});
|
|
|
|
test("runGrokAcpTurn fails on session/prompt JSON-RPC error (no silent done)", async () => {
|
|
// Regression: async request wrap + promptDone race used to swallow the reject
|
|
// and emitDone when the process later exited 0.
|
|
const emitter = makeEmitter();
|
|
const { EventEmitter } = require("node:events");
|
|
const unhandled = [];
|
|
const onUnhandled = (err) => unhandled.push(err);
|
|
process.on("unhandledRejection", onUnhandled);
|
|
|
|
function makeFakeChild() {
|
|
const child = new EventEmitter();
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
const stdin = new EventEmitter();
|
|
stdin.write = (chunk, cb) => {
|
|
const msg = JSON.parse(String(chunk).trim());
|
|
queueMicrotask(() => {
|
|
if (msg.method === "initialize") {
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: msg.id,
|
|
result: { protocolVersion: ACP_PROTOCOL_VERSION, authMethods: [] },
|
|
})}\n`));
|
|
} else if (msg.method === "session/new") {
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: msg.id,
|
|
result: { sessionId: "s-err" },
|
|
})}\n`));
|
|
} else if (msg.method === "session/prompt") {
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: msg.id,
|
|
error: { code: -32000, message: "model overloaded" },
|
|
})}\n`));
|
|
queueMicrotask(() => child.emit("close", 0));
|
|
} else {
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: msg.id,
|
|
result: {},
|
|
})}\n`));
|
|
}
|
|
if (typeof cb === "function") cb();
|
|
});
|
|
return true;
|
|
};
|
|
stdin.end = () => {};
|
|
child.stdin = stdin;
|
|
child.pid = 8;
|
|
child.killed = false;
|
|
child.exitCode = null;
|
|
child.kill = () => { child.killed = true; };
|
|
return child;
|
|
}
|
|
|
|
try {
|
|
await runGrokAcpTurn({
|
|
prompt: "hi",
|
|
binPath: "C:\\fake\\grok.exe",
|
|
permissionMode: "auto",
|
|
emitter,
|
|
spawnImpl: () => makeFakeChild(),
|
|
});
|
|
} finally {
|
|
process.off("unhandledRejection", onUnhandled);
|
|
}
|
|
|
|
const err = emitter.calls.find((c) => c[0] === "error");
|
|
assert.ok(err, "prompt JSON-RPC error must emitError");
|
|
assert.match(String(err[1]), /overloaded|error|fail/i);
|
|
assert.ok(!emitter.calls.some((c) => c[0] === "done"), "must not emitDone on prompt error");
|
|
assert.equal(unhandled.length, 0, "must not leave unhandledRejection from swallowed race");
|
|
});
|
|
|
|
test("runGrokAcpTurn fails when process exits 0 mid-prompt without result", async () => {
|
|
const emitter = makeEmitter();
|
|
const { EventEmitter } = require("node:events");
|
|
|
|
function makeFakeChild() {
|
|
const child = new EventEmitter();
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
const stdin = new EventEmitter();
|
|
stdin.write = (chunk, cb) => {
|
|
const msg = JSON.parse(String(chunk).trim());
|
|
queueMicrotask(() => {
|
|
if (msg.method === "initialize") {
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: msg.id,
|
|
result: { protocolVersion: ACP_PROTOCOL_VERSION, authMethods: [] },
|
|
})}\n`));
|
|
} else if (msg.method === "session/new") {
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: msg.id,
|
|
result: { sessionId: "s-zero" },
|
|
})}\n`));
|
|
} else if (msg.method === "session/prompt") {
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
method: "session/update",
|
|
params: {
|
|
sessionId: "s-zero",
|
|
update: { sessionUpdate: "agent_message_chunk", content: { text: "half…" } },
|
|
},
|
|
})}\n`));
|
|
queueMicrotask(() => child.emit("close", 0));
|
|
} else {
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: msg.id,
|
|
result: {},
|
|
})}\n`));
|
|
}
|
|
if (typeof cb === "function") cb();
|
|
});
|
|
return true;
|
|
};
|
|
stdin.end = () => {};
|
|
child.stdin = stdin;
|
|
child.pid = 9;
|
|
child.killed = false;
|
|
child.exitCode = null;
|
|
child.kill = () => { child.killed = true; };
|
|
return child;
|
|
}
|
|
|
|
await runGrokAcpTurn({
|
|
prompt: "long",
|
|
binPath: "C:\\fake\\grok.exe",
|
|
permissionMode: "auto",
|
|
emitter,
|
|
spawnImpl: () => makeFakeChild(),
|
|
});
|
|
|
|
assert.ok(emitter.calls.some((c) => c[0] === "text" && c[1] === "half…"));
|
|
assert.ok(emitter.calls.some((c) => c[0] === "error"), "exit 0 mid-prompt must emitError");
|
|
assert.ok(!emitter.calls.some((c) => c[0] === "done"));
|
|
});
|
|
|
|
test("runGrokAcpTurn fails when process is signal-killed mid-turn (code=null)", async () => {
|
|
// Node reports close(null, "SIGTERM") for signal kills — must not silently succeed.
|
|
const emitter = makeEmitter();
|
|
const { EventEmitter } = require("node:events");
|
|
|
|
function makeFakeChild() {
|
|
const child = new EventEmitter();
|
|
child.stdout = new EventEmitter();
|
|
child.stderr = new EventEmitter();
|
|
const stdin = new EventEmitter();
|
|
stdin.write = (chunk, cb) => {
|
|
const msg = JSON.parse(String(chunk).trim());
|
|
queueMicrotask(() => {
|
|
if (msg.method === "initialize") {
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: msg.id,
|
|
result: { protocolVersion: ACP_PROTOCOL_VERSION, authMethods: [] },
|
|
})}\n`));
|
|
} else if (msg.method === "session/new") {
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: msg.id,
|
|
result: { sessionId: "s-sig" },
|
|
})}\n`));
|
|
} else if (msg.method === "session/prompt") {
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
method: "session/update",
|
|
params: {
|
|
sessionId: "s-sig",
|
|
update: { sessionUpdate: "agent_message_chunk", content: { text: "half…" } },
|
|
},
|
|
})}\n`));
|
|
// Crash mid-prompt: signal kill, code=null (Node convention).
|
|
queueMicrotask(() => child.emit("close", null, "SIGTERM"));
|
|
} else {
|
|
child.stdout.emit("data", Buffer.from(`${JSON.stringify({
|
|
jsonrpc: "2.0",
|
|
id: msg.id,
|
|
result: {},
|
|
})}\n`));
|
|
}
|
|
if (typeof cb === "function") cb();
|
|
});
|
|
return true;
|
|
};
|
|
stdin.end = () => {};
|
|
child.stdin = stdin;
|
|
child.pid = 7;
|
|
child.killed = false;
|
|
child.exitCode = null;
|
|
child.kill = () => { child.killed = true; };
|
|
return child;
|
|
}
|
|
|
|
await runGrokAcpTurn({
|
|
prompt: "long",
|
|
binPath: "C:\\fake\\grok.exe",
|
|
permissionMode: "auto",
|
|
emitter,
|
|
spawnImpl: () => makeFakeChild(),
|
|
});
|
|
|
|
assert.ok(emitter.calls.some((c) => c[0] === "text" && c[1] === "half…"));
|
|
const err = emitter.calls.find((c) => c[0] === "error");
|
|
assert.ok(err, "signal kill mid-turn must emitError");
|
|
assert.match(String(err[1]), /SIGTERM|signal/i);
|
|
assert.ok(!emitter.calls.some((c) => c[0] === "done"));
|
|
});
|