diff --git a/apps/extension/src/session-manager/manager.ts b/apps/extension/src/session-manager/manager.ts index f8557dd..75d23d8 100644 --- a/apps/extension/src/session-manager/manager.ts +++ b/apps/extension/src/session-manager/manager.ts @@ -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` diff --git a/apps/extension/src/tools/__tests__/tabs.test.ts b/apps/extension/src/tools/__tests__/tabs.test.ts index aa47fad..a44fe47 100644 --- a/apps/extension/src/tools/__tests__/tabs.test.ts +++ b/apps/extension/src/tools/__tests__/tabs.test.ts @@ -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", () => { diff --git a/apps/extension/src/tools/tabs.ts b/apps/extension/src/tools/tabs.ts index 102a0ef..c84c098 100644 --- a/apps/extension/src/tools/tabs.ts +++ b/apps/extension/src/tools/tabs.ts @@ -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