fix(session): cleared session-scoped tool state
Cleared queued tool-choice directives and ACP always decisions after successful logical session transitions. Added new-session and cross-session regression coverage for staged resolves and both ACP always decisions. Fixes #4093
This commit is contained in:
@@ -32,6 +32,12 @@ import { type } from "arktype";
|
||||
let tempDir: TempDir;
|
||||
let session: AgentSession | undefined;
|
||||
|
||||
const boundaryCases: Array<[decision: "allow_always" | "reject_always", transition: "new" | "switch"]> = [
|
||||
["allow_always", "new"],
|
||||
["allow_always", "switch"],
|
||||
["reject_always", "new"],
|
||||
["reject_always", "switch"],
|
||||
];
|
||||
/** Fake tool that records execute calls. */
|
||||
function makeFakeTool(name: string): AgentTool & { executeCalls: number } {
|
||||
const tool = {
|
||||
@@ -77,13 +83,20 @@ async function createSession(
|
||||
tools: AgentTool[],
|
||||
bridge?: ClientBridge,
|
||||
settingsOverrides: Partial<Record<SettingPath, unknown>> = {},
|
||||
options?: { xdevRegistry?: XdevRegistry; builtInToolNames?: string[]; initialMountedXdevToolNames?: string[] },
|
||||
options?: {
|
||||
xdevRegistry?: XdevRegistry;
|
||||
builtInToolNames?: string[];
|
||||
initialMountedXdevToolNames?: string[];
|
||||
persist?: boolean;
|
||||
},
|
||||
): Promise<AgentSession> {
|
||||
const model = getBundledModel("anthropic", "claude-sonnet-4-5");
|
||||
if (!model) throw new Error("Expected claude-sonnet-4-5 model to exist");
|
||||
|
||||
const settings = Settings.isolated({ "compaction.enabled": false, ...settingsOverrides });
|
||||
const sessionManager = SessionManager.inMemory(tempDir.path());
|
||||
const sessionManager = options?.persist
|
||||
? SessionManager.create(tempDir.path(), `${tempDir.path()}/sessions`)
|
||||
: SessionManager.inMemory(tempDir.path());
|
||||
|
||||
const agent = new Agent({
|
||||
getApiKey: () => "test-key",
|
||||
@@ -838,6 +851,58 @@ 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 });
|
||||
|
||||
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);
|
||||
} 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 {
|
||||
await execution;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -22,6 +22,7 @@ describe("AgentSession resolve reminder", () => {
|
||||
let mock: MockModel;
|
||||
let authStorage: AuthStorage | undefined;
|
||||
|
||||
const transitions: Array<"new" | "switch"> = ["new", "switch"];
|
||||
beforeEach(async () => {
|
||||
tempDir = path.join(os.tmpdir(), `pi-resolve-reminder-test-${Snowflake.next()}`);
|
||||
fs.mkdirSync(tempDir, { recursive: true });
|
||||
@@ -54,7 +55,7 @@ describe("AgentSession resolve reminder", () => {
|
||||
|
||||
session = new AgentSession({
|
||||
agent,
|
||||
sessionManager: SessionManager.inMemory(),
|
||||
sessionManager: SessionManager.create(tempDir, path.join(tempDir, "sessions")),
|
||||
settings: Settings.isolated(),
|
||||
modelRegistry,
|
||||
});
|
||||
@@ -69,6 +70,26 @@ describe("AgentSession resolve reminder", () => {
|
||||
}
|
||||
});
|
||||
|
||||
async function changeLogicalSession(transition: "new" | "switch"): Promise<void> {
|
||||
if (transition === "new") {
|
||||
expect(await session.newSession()).toBe(true);
|
||||
return;
|
||||
}
|
||||
const targetId = `target-${Snowflake.next()}`;
|
||||
const targetPath = path.join(tempDir, `${targetId}.jsonl`);
|
||||
await Bun.write(
|
||||
targetPath,
|
||||
`${JSON.stringify({
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: targetId,
|
||||
timestamp: new Date().toISOString(),
|
||||
cwd: tempDir,
|
||||
})}\n`,
|
||||
);
|
||||
expect(await session.switchSession(targetPath)).toBe(true);
|
||||
}
|
||||
|
||||
it("delivers the resolve reminder via a non-forcing soft requirement, not a steer or a forced tool_choice", () => {
|
||||
queueResolveHandler(toolSession, {
|
||||
label: "AST Edit: 1 replacement in 1 file",
|
||||
@@ -93,6 +114,21 @@ describe("AgentSession resolve reminder", () => {
|
||||
expect(session.agent.peekSteeringQueue()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it.each(transitions)("clears a staged preview after a successful %s session boundary", async transition => {
|
||||
queueResolveHandler(toolSession, {
|
||||
label: "AST Edit: 1 replacement in 1 file",
|
||||
sourceToolName: "ast_edit",
|
||||
apply: async () => ({ content: [{ type: "text", text: "Applied" }] }),
|
||||
});
|
||||
expect(session.peekPendingInvoker()).toBeDefined();
|
||||
expect(isSoftToolRequirement(session.nextToolChoiceDirective())).toBe(true);
|
||||
|
||||
await changeLogicalSession(transition);
|
||||
|
||||
expect(session.peekPendingInvoker()).toBeUndefined();
|
||||
expect(session.nextToolChoiceDirective()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("dispatches a staged preview through the production toolSession wiring and drains the gate", async () => {
|
||||
let applyRuns = 0;
|
||||
queueResolveHandler(toolSession, {
|
||||
|
||||
Reference in New Issue
Block a user