fix(extension): release the pending agent-tab slot when tab creation fails

tool.tab_create increments pendingAgentTabCount *before* chrome.tabs.create
so the onCreated listener can tell an agent tab from a user-opened tab. When
the create failed, no onCreated event arrived and the counter leaked, so the
next tab the user opened was misclassified as agent-created — the exact
confusion the counter exists to prevent, a variant of issue #57.

handleTabCreate now releases the slot on failure via
SessionManager.releaseAgentTabPending. An abort (cancelled) is excluded: the
tab did open (and was cleaned up), so its onCreated still consumes the slot.

Added a regression test asserting that after a failed tab_create, a
user-opened tab is still classified as 'user' (not 'agent').
This commit is contained in:
iAstro
2026-09-01 15:36:44 +08:00
parent a4fed136e3
commit 91621b2545
3 changed files with 40 additions and 1 deletions
@@ -263,6 +263,18 @@ export class SessionManager {
ctx.pendingAgentTabCount += 1;
}
/**
* Release a pending `tool.tab_create` slot when the create itself failed
* and no `chrome.tabs.onCreated` event will arrive to consume it. Without
* this the counter leaks and the *next* tab the user opens is misclassified
* as agent-created — the exact confusion this counter exists to prevent.
*/
releaseAgentTabPending(windowId: number): void {
const ctx = this.findByWindowId(windowId);
if (!ctx || ctx.pendingAgentTabCount === 0) return;
ctx.pendingAgentTabCount -= 1;
}
/**
* Called by the `onCreated` listener for each new tab in an Agent Window.
* Returns `"agent"` when the tab corresponds to a pending `tool.tab_create`
@@ -256,6 +256,23 @@ describe("handleTabCreate", () => {
const res = await handleTabCreate(sm, { session_id: "aa11", index: -1 }, { tabs: api });
expect(res).toMatchObject({ code: "invalid_params" });
});
it("releases the pending agent-tab slot when chrome.tabs.create fails", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const state: FakeTabState = { tabs: new Map(), nextTabId: 10, windowsClosed: new Set() };
const { api, spies } = makeTabMutationApi(state);
spies.create.mockRejectedValueOnce(new Error("chrome.tabs.create failed"));
const res = await handleTabCreate(sm, { session_id: "aa11" }, { tabs: api });
expect(res).toMatchObject({ code: "protocol_error" });
// No tab was opened, so no `onCreated` event will consume the pending
// slot. Without the release the counter leaks and the next tab the user
// opens is misclassified as agent-created — the exact confusion the
// counter exists to prevent.
expect(sm.classifyNewTab(42, 100)).toBe("user");
});
});
describe("handleTabClose", () => {
+11 -1
View File
@@ -431,7 +431,17 @@ export async function handleTabCreate(
manager.markAgentTabPending(ctx.agentWindowId);
const tab = await createTabAndCleanup(deps, buildCreateProps(ctx, params));
if (isRpcError(tab)) return tab;
if (isRpcError(tab)) {
// Release the pending slot when no tab was actually opened: with no
// `onCreated` event to consume it, the counter would leak and the next
// tab the user opens would be misclassified as agent-created. An abort
// (`cancelled`) is the exception — the tab did open (and was cleaned up),
// so its `onCreated` still consumes the slot.
if (tab.code !== "cancelled") {
manager.releaseAgentTabPending(ctx.agentWindowId);
}
return tab;
}
// Track agent-created tabs so session_stop can clean them up (design §3.1).
// The onCreated listener also adds this id; this is an idempotent fallback