feat: reset idle assistant sessions after one hour

When a conversation's assistant session has been idle longer than
runtime.acp.assistantSessionResetIdleMs (default 1h, 0 disables) and
its owner has no unfinished proposals, the next inbound message starts
a fresh session instead of resuming. Binding lastActiveAt is persisted
per turn so the decision survives restarts.
This commit is contained in:
zenord
2026-08-19 16:17:54 +08:00
parent 9c3139380d
commit bd96595ba8
7 changed files with 128 additions and 8 deletions
+91 -4
View File
@@ -26,7 +26,7 @@ interface Harness {
logFile: string;
}
async function createHarness(options: { initialize?: boolean; agentEnv?: Record<string, string> } = {}): Promise<Harness> {
async function createHarness(options: { initialize?: boolean; agentEnv?: Record<string, string>; acp?: Record<string, unknown> } = {}): Promise<Harness> {
const home = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-assistant-home-"));
const workspace = await fs.promises.mkdtemp(path.join(os.tmpdir(), "gori-assistant-ws-"));
const logFile = path.join(home, "fake-acp.log");
@@ -34,7 +34,7 @@ async function createHarness(options: { initialize?: boolean; agentEnv?: Record<
configVersion: 3,
bot: { id: "test-bot", workspace, persona: "", agent: { id: "fake", command: process.execPath, args: [fixture], env: { FAKE_ACP_LOG: logFile, ...options.agentEnv } }, permissions: { mode: "deny" } },
gateway: { platform: { type: "webhook", secret: "secret" } },
runtime: { acp: { promptTimeoutMs: 10_000, cancelGraceMs: 200 } }
runtime: { acp: { promptTimeoutMs: 10_000, cancelGraceMs: 200, ...options.acp } }
});
const identity = { botId: "test-bot", platform: "webhook" };
const store = new DurableSessionStore(path.join(home, "state", "acp-sessions.json"), identity);
@@ -51,7 +51,7 @@ async function createHarness(options: { initialize?: boolean; agentEnv?: Record<
return { home, workspace, store, proposals, manager, events, logFile };
}
async function reopenManager(harness: Harness): Promise<void> {
async function reopenManager(harness: Harness, options: { acp?: Record<string, unknown> } = {}): Promise<void> {
await harness.manager.shutdown().catch(() => undefined);
await harness.proposals.close().catch(() => undefined);
await harness.store.close().catch(() => undefined);
@@ -64,7 +64,7 @@ async function reopenManager(harness: Harness): Promise<void> {
configVersion: 3,
bot: { id: "test-bot", workspace: harness.workspace, persona: "", agent: { id: "fake", command: process.execPath, args: [fixture], env: { FAKE_ACP_LOG: harness.logFile } }, permissions: { mode: "deny" } },
gateway: { platform: { type: "webhook", secret: "secret" } },
runtime: { acp: { promptTimeoutMs: 10_000, cancelGraceMs: 200 } }
runtime: { acp: { promptTimeoutMs: 10_000, cancelGraceMs: 200, ...options.acp } }
});
harness.manager = new AssistantManager(config.runtime.acp, new BotProfileResolver(config).bot, harness.store, harness.proposals, {
assistantWorkspaceHome: harness.home,
@@ -722,6 +722,93 @@ test("image attachments degrade to a text note when the agent has no image capab
}
});
test("an assistant session idle beyond the reset threshold is dropped when the owner has no unfinished proposal", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("hello"));
const first = harness.store.getBinding(CONVERSATION_KEY)!;
assert.ok(first.lastActiveAt);
await harness.store.setBinding({ ...first, lastActiveAt: Date.now() - 3_700_000 });
await reopenManager(harness);
const logs: string[] = [];
const originalLog = console.log;
console.log = (...args: unknown[]) => { logs.push(args.map(String).join(" ")); };
try {
await harness.manager.prompt(request("hello again"));
} finally {
console.log = originalLog;
}
const reset = harness.store.getBinding(CONVERSATION_KEY)!;
assert.notEqual(reset.nativeSessionId, first.nativeSessionId);
assert.ok(logs.some((line) => /Assistant session reset for conversation [0-9a-f]{8} after \d+s idle/.test(line)), logs.join("\n"));
const restores = readLog(harness.logFile).filter((entry) => (entry.method === "session/resume" || entry.method === "session/load")
&& entry.sessionId === first.nativeSessionId);
assert.equal(restores.length, 0);
} finally {
await closeHarness(harness);
}
});
test("an idle assistant session is resumed while the owner has an unfinished proposal", async () => {
const harness = await createHarness();
try {
// Proposed (never confirmed) is enough to keep the session.
await harness.manager.prompt(request("create proposal: succeed"));
const first = harness.store.getBinding(CONVERSATION_KEY)!;
await harness.store.setBinding({ ...first, lastActiveAt: Date.now() - 3_700_000 });
await reopenManager(harness);
await harness.manager.prompt(request("hello"));
assert.equal(harness.store.getBinding(CONVERSATION_KEY)!.nativeSessionId, first.nativeSessionId);
// Pending keeps it too.
const proposal = harness.proposals.list()[0]!;
await harness.manager.prompt(request("confirm"));
await waitFor(() => harness.proposals.get(proposal.id)!.status === "pending");
const second = harness.store.getBinding(CONVERSATION_KEY)!;
await harness.store.setBinding({ ...second, lastActiveAt: Date.now() - 3_700_000 });
await reopenManager(harness);
await harness.manager.prompt(request("hello again"));
assert.equal(harness.store.getBinding(CONVERSATION_KEY)!.nativeSessionId, first.nativeSessionId);
const restores = readLog(harness.logFile).filter((entry) => (entry.method === "session/resume" || entry.method === "session/load")
&& entry.sessionId === first.nativeSessionId);
assert.ok(restores.length > 0);
} finally {
await closeHarness(harness);
}
});
test("a recently active assistant session resumes and lastActiveAt persists across restarts", async () => {
const harness = await createHarness();
try {
await harness.manager.prompt(request("hello"));
const first = harness.store.getBinding(CONVERSATION_KEY)!;
await reopenManager(harness);
await harness.manager.prompt(request("hello again"));
const touched = harness.store.getBinding(CONVERSATION_KEY)!;
assert.equal(touched.nativeSessionId, first.nativeSessionId);
assert.ok(touched.lastActiveAt! >= first.lastActiveAt!);
await reopenManager(harness);
assert.equal(harness.store.getBinding(CONVERSATION_KEY)!.lastActiveAt, touched.lastActiveAt);
} finally {
await closeHarness(harness);
}
});
test("assistantSessionResetIdleMs 0 disables the idle reset", async () => {
const harness = await createHarness({ acp: { assistantSessionResetIdleMs: 0 } });
try {
await harness.manager.prompt(request("hello"));
const first = harness.store.getBinding(CONVERSATION_KEY)!;
await harness.store.setBinding({ ...first, lastActiveAt: Date.now() - 86_400_000 });
await reopenManager(harness, { acp: { assistantSessionResetIdleMs: 0 } });
await harness.manager.prompt(request("hello again"));
assert.equal(harness.store.getBinding(CONVERSATION_KEY)!.nativeSessionId, first.nativeSessionId);
} finally {
await closeHarness(harness);
}
});
test("a worker envelope truncated at the closing tag is salvaged without a repair round", async () => {
const harness = await createHarness();
try {