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:
roboomp
2026-07-23 19:57:57 +00:00
committed by can1357
parent 3d64910bb0
commit e6cdfd6187
5 changed files with 121 additions and 3 deletions
@@ -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, {