chore: reformat
This commit is contained in:
@@ -4222,60 +4222,60 @@ describe("advisor", () => {
|
||||
expect(promptInputs[1]).toContain("keep me");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"success",
|
||||
"error",
|
||||
] as const)("releases blocked %s hooks so reset can run replacement work", async hookKind => {
|
||||
const hookStarted = Promise.withResolvers<void>();
|
||||
const releaseHook = Promise.withResolvers<void>();
|
||||
const replacementPromptStarted = Promise.withResolvers<void>();
|
||||
let promptCalls = 0;
|
||||
let hookCalls = 0;
|
||||
const blockHook = async () => {
|
||||
if (++hookCalls !== 1) return;
|
||||
hookStarted.resolve();
|
||||
await releaseHook.promise;
|
||||
};
|
||||
const agent: AdvisorAgent = {
|
||||
prompt: async () => {
|
||||
promptCalls++;
|
||||
if (promptCalls === 1 && hookKind === "error") throw new Error("provider failure");
|
||||
if (promptCalls === 2) replacementPromptStarted.resolve();
|
||||
},
|
||||
abort: () => {},
|
||||
reset: () => {},
|
||||
state: { messages: [] },
|
||||
};
|
||||
const runtime = new AdvisorRuntime(agent, {
|
||||
snapshotMessages: () => [],
|
||||
enqueueAdvice: () => {},
|
||||
...(hookKind === "success"
|
||||
? { onTurnSuccess: blockHook }
|
||||
: {
|
||||
onTurnError: async () => {
|
||||
await blockHook();
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
});
|
||||
it.each(["success", "error"] as const)(
|
||||
"releases blocked %s hooks so reset can run replacement work",
|
||||
async hookKind => {
|
||||
const hookStarted = Promise.withResolvers<void>();
|
||||
const releaseHook = Promise.withResolvers<void>();
|
||||
const replacementPromptStarted = Promise.withResolvers<void>();
|
||||
let promptCalls = 0;
|
||||
let hookCalls = 0;
|
||||
const blockHook = async () => {
|
||||
if (++hookCalls !== 1) return;
|
||||
hookStarted.resolve();
|
||||
await releaseHook.promise;
|
||||
};
|
||||
const agent: AdvisorAgent = {
|
||||
prompt: async () => {
|
||||
promptCalls++;
|
||||
if (promptCalls === 1 && hookKind === "error") throw new Error("provider failure");
|
||||
if (promptCalls === 2) replacementPromptStarted.resolve();
|
||||
},
|
||||
abort: () => {},
|
||||
reset: () => {},
|
||||
state: { messages: [] },
|
||||
};
|
||||
const runtime = new AdvisorRuntime(agent, {
|
||||
snapshotMessages: () => [],
|
||||
enqueueAdvice: () => {},
|
||||
...(hookKind === "success"
|
||||
? { onTurnSuccess: blockHook }
|
||||
: {
|
||||
onTurnError: async () => {
|
||||
await blockHook();
|
||||
return false;
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
runtime.onTurnEnd([{ role: "user", content: "old session", timestamp: 1 } as AgentMessage]);
|
||||
await hookStarted.promise;
|
||||
const pause = runtime.pauseForSessionTransition();
|
||||
const pausedQuickly = await Promise.race([pause.then(() => true), Bun.sleep(50).then(() => false)]);
|
||||
runtime.reset();
|
||||
runtime.onTurnEnd([{ role: "user", content: "replacement session", timestamp: 2 } as AgentMessage]);
|
||||
const replacementRan = await Promise.race([
|
||||
replacementPromptStarted.promise.then(() => true),
|
||||
Bun.sleep(50).then(() => false),
|
||||
]);
|
||||
releaseHook.resolve();
|
||||
await pause;
|
||||
runtime.dispose();
|
||||
runtime.onTurnEnd([{ role: "user", content: "old session", timestamp: 1 } as AgentMessage]);
|
||||
await hookStarted.promise;
|
||||
const pause = runtime.pauseForSessionTransition();
|
||||
const pausedQuickly = await Promise.race([pause.then(() => true), Bun.sleep(50).then(() => false)]);
|
||||
runtime.reset();
|
||||
runtime.onTurnEnd([{ role: "user", content: "replacement session", timestamp: 2 } as AgentMessage]);
|
||||
const replacementRan = await Promise.race([
|
||||
replacementPromptStarted.promise.then(() => true),
|
||||
Bun.sleep(50).then(() => false),
|
||||
]);
|
||||
releaseHook.resolve();
|
||||
await pause;
|
||||
runtime.dispose();
|
||||
|
||||
expect(pausedQuickly).toBe(true);
|
||||
expect(replacementRan).toBe(true);
|
||||
});
|
||||
expect(pausedQuickly).toBe(true);
|
||||
expect(replacementRan).toBe(true);
|
||||
},
|
||||
);
|
||||
it("aborts retry backoff before pausing for a session transition", async () => {
|
||||
const recoveryStarted = Promise.withResolvers<void>();
|
||||
const agent: AdvisorAgent = {
|
||||
|
||||
@@ -849,56 +849,57 @@ it("allow_always: caches decision and calls bridge only once for subsequent exec
|
||||
expect(bashTool.executeCalls).toBe(2);
|
||||
});
|
||||
|
||||
it.each(
|
||||
boundaryCases,
|
||||
)("%s permission decisions prompt again after a successful %s session boundary", async (decision, transition) => {
|
||||
const bashTool = makeFakeTool("bash");
|
||||
const bridge = makeBridge({ outcome: "selected", optionId: decision, kind: decision });
|
||||
const permissionSpy = spyOn(bridge, "requestPermission");
|
||||
session = await createSession([bashTool], bridge, {}, { persist: true });
|
||||
it.each(boundaryCases)(
|
||||
"%s permission decisions prompt again after a successful %s session boundary",
|
||||
async (decision, transition) => {
|
||||
const bashTool = makeFakeTool("bash");
|
||||
const bridge = makeBridge({ outcome: "selected", optionId: decision, kind: decision });
|
||||
const permissionSpy = spyOn(bridge, "requestPermission");
|
||||
session = await createSession([bashTool], bridge, {}, { persist: true });
|
||||
|
||||
await session.setActiveToolsByName(["bash"]);
|
||||
const wrappedBash = session.agent.state.tools.find(tool => tool.name === "bash");
|
||||
if (!wrappedBash) throw new Error("Expected wrapped bash tool");
|
||||
await session.setActiveToolsByName(["bash"]);
|
||||
const wrappedBash = session.agent.state.tools.find(tool => tool.name === "bash");
|
||||
if (!wrappedBash) throw new Error("Expected wrapped bash tool");
|
||||
|
||||
for (let callIndex = 0; callIndex < 2; callIndex++) {
|
||||
if (callIndex === 1) {
|
||||
if (transition === "new") {
|
||||
expect(await session.newSession()).toBe(true);
|
||||
for (let callIndex = 0; callIndex < 2; callIndex++) {
|
||||
if (callIndex === 1) {
|
||||
if (transition === "new") {
|
||||
expect(await session.newSession()).toBe(true);
|
||||
} else {
|
||||
const targetId = `permission-target-${Bun.nanoseconds()}`;
|
||||
const targetPath = `${tempDir.path()}/${targetId}.jsonl`;
|
||||
await Bun.write(
|
||||
targetPath,
|
||||
`${JSON.stringify({
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: targetId,
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: tempDir.path(),
|
||||
})}\n`,
|
||||
);
|
||||
expect(await session.switchSession(targetPath)).toBe(true);
|
||||
}
|
||||
}
|
||||
|
||||
const execution = wrappedBash.execute(
|
||||
`call-${callIndex}`,
|
||||
{ command: "echo boundary" },
|
||||
undefined,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
);
|
||||
if (decision === "reject_always") {
|
||||
await expect(execution).rejects.toThrow(/rejected by user/);
|
||||
} else {
|
||||
const targetId = `permission-target-${Bun.nanoseconds()}`;
|
||||
const targetPath = `${tempDir.path()}/${targetId}.jsonl`;
|
||||
await Bun.write(
|
||||
targetPath,
|
||||
`${JSON.stringify({
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: targetId,
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: tempDir.path(),
|
||||
})}\n`,
|
||||
);
|
||||
expect(await session.switchSession(targetPath)).toBe(true);
|
||||
await execution;
|
||||
}
|
||||
}
|
||||
|
||||
const execution = wrappedBash.execute(
|
||||
`call-${callIndex}`,
|
||||
{ command: "echo boundary" },
|
||||
undefined,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
);
|
||||
if (decision === "reject_always") {
|
||||
await expect(execution).rejects.toThrow(/rejected by user/);
|
||||
} else {
|
||||
await execution;
|
||||
}
|
||||
}
|
||||
|
||||
expect(permissionSpy).toHaveBeenCalledTimes(2);
|
||||
expect(bashTool.executeCalls).toBe(decision === "allow_always" ? 2 : 0);
|
||||
});
|
||||
expect(permissionSpy).toHaveBeenCalledTimes(2);
|
||||
expect(bashTool.executeCalls).toBe(decision === "allow_always" ? 2 : 0);
|
||||
},
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Read tool not gated: bridge never called even when bridge is set
|
||||
|
||||
@@ -203,68 +203,67 @@ describe("AgentSession bash session ownership", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"new",
|
||||
"switch",
|
||||
"branch",
|
||||
] as const)("records a late bash result in its original session after %s", async transition => {
|
||||
const sessionDir = path.join(tempDir.path(), "sessions");
|
||||
const { completion, emitUserBash, extensionRunner } = createGatedBashRunner();
|
||||
createSession(SessionManager.create(tempDir.path(), sessionDir), extensionRunner);
|
||||
const oldSessionFile = await seedPersistedSession();
|
||||
const oldSessionId = session.sessionId;
|
||||
it.each(["new", "switch", "branch"] as const)(
|
||||
"records a late bash result in its original session after %s",
|
||||
async transition => {
|
||||
const sessionDir = path.join(tempDir.path(), "sessions");
|
||||
const { completion, emitUserBash, extensionRunner } = createGatedBashRunner();
|
||||
createSession(SessionManager.create(tempDir.path(), sessionDir), extensionRunner);
|
||||
const oldSessionFile = await seedPersistedSession();
|
||||
const oldSessionId = session.sessionId;
|
||||
|
||||
const bashPromise = session.executeBash("old-session-command");
|
||||
expect(emitUserBash).toHaveBeenCalledTimes(1);
|
||||
const bashPromise = session.executeBash("old-session-command");
|
||||
expect(emitUserBash).toHaveBeenCalledTimes(1);
|
||||
|
||||
switch (transition) {
|
||||
case "new":
|
||||
await session.newSession();
|
||||
break;
|
||||
case "switch": {
|
||||
const targetManager = SessionManager.create(tempDir.path(), sessionDir);
|
||||
targetManager.appendMessage({ role: "user", content: "target", timestamp: Date.now() });
|
||||
targetManager.appendMessage(createAssistantMessage("target reply"));
|
||||
await targetManager.ensureOnDisk();
|
||||
const targetFile = targetManager.getSessionFile();
|
||||
if (!targetFile) throw new Error("Expected target session file");
|
||||
await targetManager.close();
|
||||
await session.switchSession(targetFile);
|
||||
break;
|
||||
switch (transition) {
|
||||
case "new":
|
||||
await session.newSession();
|
||||
break;
|
||||
case "switch": {
|
||||
const targetManager = SessionManager.create(tempDir.path(), sessionDir);
|
||||
targetManager.appendMessage({ role: "user", content: "target", timestamp: Date.now() });
|
||||
targetManager.appendMessage(createAssistantMessage("target reply"));
|
||||
await targetManager.ensureOnDisk();
|
||||
const targetFile = targetManager.getSessionFile();
|
||||
if (!targetFile) throw new Error("Expected target session file");
|
||||
await targetManager.close();
|
||||
await session.switchSession(targetFile);
|
||||
break;
|
||||
}
|
||||
case "branch": {
|
||||
const userEntry = session.sessionManager
|
||||
.getEntries()
|
||||
.find(entry => entry.type === "message" && entry.message.role === "user");
|
||||
if (!userEntry) throw new Error("Expected user entry for branch");
|
||||
await session.branch(userEntry.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
case "branch": {
|
||||
const userEntry = session.sessionManager
|
||||
.getEntries()
|
||||
.find(entry => entry.type === "message" && entry.message.role === "user");
|
||||
if (!userEntry) throw new Error("Expected user entry for branch");
|
||||
await session.branch(userEntry.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(session.sessionId).not.toBe(oldSessionId);
|
||||
completion.resolve({ result: bashResult });
|
||||
await bashPromise;
|
||||
expect(session.sessionId).not.toBe(oldSessionId);
|
||||
completion.resolve({ result: bashResult });
|
||||
await bashPromise;
|
||||
|
||||
expect(
|
||||
session.messages.some(
|
||||
message => message.role === "bashExecution" && message.command === "old-session-command",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
session.messages.some(
|
||||
message => message.role === "bashExecution" && message.command === "old-session-command",
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
const oldSession = await SessionManager.open(oldSessionFile, sessionDir, undefined, {
|
||||
initialCwd: tempDir.path(),
|
||||
suppressBreadcrumb: true,
|
||||
});
|
||||
additionalManagers.push(oldSession);
|
||||
const oldMessages = oldSession.getBranch().flatMap(entry => (entry.type === "message" ? [entry.message] : []));
|
||||
expect(oldMessages.slice(-3).map(message => message.role)).toEqual(["user", "assistant", "bashExecution"]);
|
||||
expect(oldMessages.at(-1)).toMatchObject({
|
||||
role: "bashExecution",
|
||||
command: "old-session-command",
|
||||
output: "old-output",
|
||||
});
|
||||
});
|
||||
const oldSession = await SessionManager.open(oldSessionFile, sessionDir, undefined, {
|
||||
initialCwd: tempDir.path(),
|
||||
suppressBreadcrumb: true,
|
||||
});
|
||||
additionalManagers.push(oldSession);
|
||||
const oldMessages = oldSession.getBranch().flatMap(entry => (entry.type === "message" ? [entry.message] : []));
|
||||
expect(oldMessages.slice(-3).map(message => message.role)).toEqual(["user", "assistant", "bashExecution"]);
|
||||
expect(oldMessages.at(-1)).toMatchObject({
|
||||
role: "bashExecution",
|
||||
command: "old-session-command",
|
||||
output: "old-output",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("stores minimized bash output with the originating session", async () => {
|
||||
const sessionDir = path.join(tempDir.path(), "sessions");
|
||||
|
||||
@@ -1878,14 +1878,13 @@ describe("InteractiveMode plan review rendering", () => {
|
||||
// `#approvePlan`'s `finally`. No aborted message_end is required to consume it,
|
||||
// so a stranded flag could otherwise silence the next unrelated abort. One
|
||||
// parametrized case per outcome keeps ok/cancelled/failed each covered.
|
||||
it.each([
|
||||
"ok",
|
||||
"cancelled",
|
||||
"failed",
|
||||
] as const)("B1-B3: Approve and compact context + %s outcome → flag cleared by finally", async outcome => {
|
||||
await approveWithCompact(outcome);
|
||||
expect(session.isPlanInternalAbortPending).toBe(false);
|
||||
});
|
||||
it.each(["ok", "cancelled", "failed"] as const)(
|
||||
"B1-B3: Approve and compact context + %s outcome → flag cleared by finally",
|
||||
async outcome => {
|
||||
await approveWithCompact(outcome);
|
||||
expect(session.isPlanInternalAbortPending).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it("B4: Approve and compact context + handleCompactCommand throws → showError surfaces the failure AND flag cleared by finally before the outer catch", async () => {
|
||||
// `handlePlanApproval` wraps `#approvePlan` in a try/catch
|
||||
|
||||
@@ -315,27 +315,27 @@ describe("MemoryProtocolHandler", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"memory://root/skills/**/../*.md",
|
||||
"memory://root/skills/**/%2e%2e/*.md",
|
||||
])("rejects traversal in a memory glob suffix: %s", async pattern => {
|
||||
await withMemoryFixture(async ({ cwd }) => {
|
||||
await expect(createGlobTool(cwd).execute("memory-glob-traversal", { path: pattern })).rejects.toThrow(
|
||||
/traversal/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
it.each(["memory://root/skills/**/../*.md", "memory://root/skills/**/%2e%2e/*.md"])(
|
||||
"rejects traversal in a memory glob suffix: %s",
|
||||
async pattern => {
|
||||
await withMemoryFixture(async ({ cwd }) => {
|
||||
await expect(createGlobTool(cwd).execute("memory-glob-traversal", { path: pattern })).rejects.toThrow(
|
||||
/traversal/i,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"memory://root/skills/**/demo%2fnested/*.md",
|
||||
"memory://root/skills/**/demo%5cnested/*.md",
|
||||
])("rejects encoded separators in a memory glob suffix: %s", async pattern => {
|
||||
await withMemoryFixture(async ({ cwd }) => {
|
||||
await expect(createGlobTool(cwd).execute("memory-glob-separator", { path: pattern })).rejects.toThrow(
|
||||
/encoded path separator/i,
|
||||
);
|
||||
});
|
||||
});
|
||||
it.each(["memory://root/skills/**/demo%2fnested/*.md", "memory://root/skills/**/demo%5cnested/*.md"])(
|
||||
"rejects encoded separators in a memory glob suffix: %s",
|
||||
async pattern => {
|
||||
await withMemoryFixture(async ({ cwd }) => {
|
||||
await expect(createGlobTool(cwd).execute("memory-glob-separator", { path: pattern })).rejects.toThrow(
|
||||
/encoded path separator/i,
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("throws clear error for missing files", async () => {
|
||||
await withMemoryFixture(async () => {
|
||||
|
||||
@@ -97,22 +97,19 @@ describe("task async preflight", () => {
|
||||
spawns: "scout",
|
||||
expectation: "Cannot spawn 'task'",
|
||||
},
|
||||
])("returns $name policy errors before registering an async job", async ({
|
||||
name,
|
||||
params,
|
||||
settings,
|
||||
spawns,
|
||||
expectation,
|
||||
}) => {
|
||||
mockDiscovery();
|
||||
const jobs = manager();
|
||||
const tool = await TaskTool.create(createSession({ manager: jobs, settings, spawns }));
|
||||
])(
|
||||
"returns $name policy errors before registering an async job",
|
||||
async ({ name, params, settings, spawns, expectation }) => {
|
||||
mockDiscovery();
|
||||
const jobs = manager();
|
||||
const tool = await TaskTool.create(createSession({ manager: jobs, settings, spawns }));
|
||||
|
||||
const result = await tool.execute("preflight", params as TaskParams);
|
||||
const result = await tool.execute("preflight", params as TaskParams);
|
||||
|
||||
expect(textOf(result)).toContain(expectation);
|
||||
expect(jobs.getJob(name)).toBeUndefined();
|
||||
});
|
||||
expect(textOf(result)).toContain(expectation);
|
||||
expect(jobs.getJob(name)).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects an invalid async batch atomically before dispatching any item", async () => {
|
||||
mockDiscovery();
|
||||
|
||||
@@ -427,25 +427,24 @@ describe("title generator", () => {
|
||||
expect(title).toBe("Fix login button on mobile");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"Here's a thinking process:",
|
||||
"Thinking process:",
|
||||
"Reasoning process:",
|
||||
])("rejects a markerless prose thinking preamble: %s", async responseText => {
|
||||
const model = getModelFor("deepseek", "deepseek-v4-pro");
|
||||
vi.spyOn(ai, "completeSimple").mockResolvedValue({
|
||||
stopReason: "stop",
|
||||
content: [{ type: "text", text: responseText }],
|
||||
} as never);
|
||||
it.each(["Here's a thinking process:", "Thinking process:", "Reasoning process:"])(
|
||||
"rejects a markerless prose thinking preamble: %s",
|
||||
async responseText => {
|
||||
const model = getModelFor("deepseek", "deepseek-v4-pro");
|
||||
vi.spyOn(ai, "completeSimple").mockResolvedValue({
|
||||
stopReason: "stop",
|
||||
content: [{ type: "text", text: responseText }],
|
||||
} as never);
|
||||
|
||||
const title = await generateSessionTitle(
|
||||
"the login button is broken on mobile",
|
||||
createRegistry(model),
|
||||
createSettings(model),
|
||||
);
|
||||
const title = await generateSessionTitle(
|
||||
"the login button is broken on mobile",
|
||||
createRegistry(model),
|
||||
createSettings(model),
|
||||
);
|
||||
|
||||
expect(title).toBeNull();
|
||||
});
|
||||
expect(title).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves a markerless title that mentions a <think> tag", async () => {
|
||||
const model = getModelFor("deepseek", "deepseek-v4-pro");
|
||||
|
||||
@@ -114,27 +114,21 @@ describe("default echo/printf redirect rule", () => {
|
||||
describe("default hub start rules", () => {
|
||||
const tools = ["hub"];
|
||||
|
||||
it.each([
|
||||
"bun run dev",
|
||||
"vite --host 0.0.0.0",
|
||||
"lldb ./app",
|
||||
"bun test --watch",
|
||||
"nohup server",
|
||||
"server &",
|
||||
])("routes %s to hub start", command => {
|
||||
const result = checkBashInterception(command, tools, DEFAULT_BASH_INTERCEPTOR_RULES);
|
||||
expect(result.block).toBe(true);
|
||||
expect(result.suggestedTool).toBe("hub");
|
||||
});
|
||||
it.each(["bun run dev", "vite --host 0.0.0.0", "lldb ./app", "bun test --watch", "nohup server", "server &"])(
|
||||
"routes %s to hub start",
|
||||
command => {
|
||||
const result = checkBashInterception(command, tools, DEFAULT_BASH_INTERCEPTOR_RULES);
|
||||
expect(result.block).toBe(true);
|
||||
expect(result.suggestedTool).toBe("hub");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"git diff -w",
|
||||
"docker compose up -d",
|
||||
"bun test",
|
||||
"printf 'server &'",
|
||||
])("does not misclassify finite command %s", command => {
|
||||
expect(checkBashInterception(command, tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(false);
|
||||
});
|
||||
it.each(["git diff -w", "docker compose up -d", "bun test", "printf 'server &'"])(
|
||||
"does not misclassify finite command %s",
|
||||
command => {
|
||||
expect(checkBashInterception(command, tools, DEFAULT_BASH_INTERCEPTOR_RULES).block).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("BashTool argument validation", () => {
|
||||
|
||||
Reference in New Issue
Block a user