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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user