From ebf151e057350fa545e952c7f73405a018ad481d Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Fri, 7 Aug 2026 11:23:27 -0300 Subject: [PATCH] fix(cursor): preserve tool context across multi-turn conversations when client lacks conversation_id (#9029) Closes #9029 --- changelog.d/fixes/9029-cursor-narration.md | 1 + config/quality/file-size-baseline.json | 2 +- open-sse/executors/cursor.ts | 3 + open-sse/services/cursorSessionManager.ts | 23 +++++ tests/unit/cursor-agent-session.test.ts | 105 +++++++++++++++++++++ 5 files changed, 133 insertions(+), 1 deletion(-) create mode 100644 changelog.d/fixes/9029-cursor-narration.md diff --git a/changelog.d/fixes/9029-cursor-narration.md b/changelog.d/fixes/9029-cursor-narration.md new file mode 100644 index 0000000000..852f75f192 --- /dev/null +++ b/changelog.d/fixes/9029-cursor-narration.md @@ -0,0 +1 @@ +- fix(cursor): preserve tool context across multi-turn conversations when client lacks conversation_id (#9029) diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 8b2d5e4761..5a5c4208e1 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -528,7 +528,7 @@ "open-sse/executors/base.ts": 1640, "open-sse/executors/chatgpt-web.ts": 3241, "open-sse/executors/codex.ts": 1562, - "open-sse/executors/cursor.ts": 1560, + "open-sse/executors/cursor.ts": 1563, "open-sse/executors/deepseek-web.ts": 1148, "open-sse/executors/grok-web.ts": 1044, "open-sse/executors/muse-spark-web.ts": 1405, diff --git a/open-sse/executors/cursor.ts b/open-sse/executors/cursor.ts index 3941cdb3e7..61250dfe4c 100644 --- a/open-sse/executors/cursor.ts +++ b/open-sse/executors/cursor.ts @@ -1219,6 +1219,9 @@ export class CursorExecutor extends BaseExecutor { if (isToolFollowUp) { session = cursorSessionManager.acquire(conversationId); + // #9029: content-based session match when client lacks conversation_id. + if (!session && !body.conversation_id) session = cursorSessionManager.findByToolCallIds( + messages.filter(m => m.role === "tool" && m.tool_call_id).map(m => m.tool_call_id!)); } if (session) { diff --git a/open-sse/services/cursorSessionManager.ts b/open-sse/services/cursorSessionManager.ts index 95af2b491b..74358c03ad 100644 --- a/open-sse/services/cursorSessionManager.ts +++ b/open-sse/services/cursorSessionManager.ts @@ -192,6 +192,29 @@ export class CursorSessionManager { if (oldest) this.close(oldest); } + /** + * Find a session that has one of the specified tool call IDs pending. + * Only matches sessions in "awaiting_tool_result" state. + * Transitions the found session to "running" (same as acquire). + * This is used when the client doesn't provide conversation_id + * (OpenAI-compatible clients), so we match by content instead of key. + * Returns undefined if no session has any of the given IDs pending. + */ + findByToolCallIds(toolCallIds: string[]): CursorSession | undefined { + this.evictExpired(); + for (const id of toolCallIds) { + for (const session of this.sessions.values()) { + if (session.state === "awaiting_tool_result" && session.pendingToolCalls.has(id)) { + this.clearIdleTimer(session); + session.state = "running"; + session.lastActivityTs = Date.now(); + return session; + } + } + } + return undefined; + } + // ─── Test / introspection helpers ──────────────────────────────────────── size(): number { diff --git a/tests/unit/cursor-agent-session.test.ts b/tests/unit/cursor-agent-session.test.ts index f63be412c4..3519e8a97f 100644 --- a/tests/unit/cursor-agent-session.test.ts +++ b/tests/unit/cursor-agent-session.test.ts @@ -253,6 +253,111 @@ test("CursorSessionManager.close clears unanswered pendingToolCalls", () => { assert.equal(m.size(), 0); }); +// ─── findByToolCallIds — content-based session matching ──────────────────── +// +// These tests validate the fix for #9029: when the client does not provide +// conversation_id, every turn gets a random UUID and acquire() fails. The +// fallback findByToolCallIds matches by tool_call_id content instead. + +test("CursorSessionManager.findByToolCallIds finds an awaiting session by tool call ID", () => { + const m = new CursorSessionManager(); + const { req } = mockReq(); + const { client } = mockClient(); + const session = m.open("conv-find", client, req, new Map()); + session.pendingToolCalls.set("call_abc", { + execMsgId: 1, + execId: "exec-1", + toolName: "get_weather", + }); + m.release(session, "awaiting_tool_result"); + + const found = m.findByToolCallIds(["call_abc", "call_other"]); + assert.equal(found, session); + // Must transition to "running" (same as acquire() does) + assert.equal(found?.state, "running"); +}); + +test("CursorSessionManager.findByToolCallIds returns undefined when no IDs match", () => { + const m = new CursorSessionManager(); + const { req } = mockReq(); + const { client } = mockClient(); + const session = m.open("conv-nomatch", client, req, new Map()); + session.pendingToolCalls.set("call_xyz", { + execMsgId: 1, + execId: "exec-1", + toolName: "tool", + }); + m.release(session, "awaiting_tool_result"); + + const found = m.findByToolCallIds(["call_nonexistent"]); + assert.equal(found, undefined); +}); + +test("CursorSessionManager.findByToolCallIds returns undefined for running session", () => { + const m = new CursorSessionManager(); + const { req } = mockReq(); + const { client } = mockClient(); + const session = m.open("conv-running", client, req, new Map()); + session.pendingToolCalls.set("call_abc", { + execMsgId: 1, + execId: "exec-1", + toolName: "tool", + }); + // NOT released, so state is still "running" — not eligible + + const found = m.findByToolCallIds(["call_abc"]); + assert.equal(found, undefined); +}); + +test("findByToolCallIds matches session even when acquire fails due to different conversation_id (#9029)", () => { + const m = new CursorSessionManager(); + + // Turn 1: session opens with conv-a and releases awaiting tool result + const r1 = mockReq(); + const c1 = mockClient(); + const s1 = m.open("conv-a", c1.client, r1.req, new Map()); + s1.pendingToolCalls.set("call_p1", { + execMsgId: 1, + execId: "exec-1", + toolName: "tool_a", + }); + m.release(s1, "awaiting_tool_result"); + + // Turn 2: client sends tool result with a DIFFERENT conversation_id + // (random UUID because OpenAI client doesn't provide conversation_id). + // acquire() fails — this is the bug. + const acquired = m.acquire("random-uuid-xyz"); + assert.equal(acquired, undefined, "acquire with different ID must return undefined (the bug)"); + + // findByToolCallIds finds the session by tool_call_id content matching + const found = m.findByToolCallIds(["call_p1"]); + assert.equal(found, s1, "findByToolCallIds must find session by tool call ID"); + assert.equal(found.state, "running", "found session must transition to running"); +}); + +test("findByToolCallIds matches first matching session across multiple sessions", () => { + const m = new CursorSessionManager(); + + const r1 = mockReq(); + const c1 = mockClient(); + const s1 = m.open("conv-1", c1.client, r1.req, new Map()); + s1.pendingToolCalls.set("call_1", { execMsgId: 1, execId: "e1", toolName: "t1" }); + m.release(s1, "awaiting_tool_result"); + + const r2 = mockReq(); + const c2 = mockClient(); + const s2 = m.open("conv-2", c2.client, r2.req, new Map()); + s2.pendingToolCalls.set("call_2", { execMsgId: 2, execId: "e2", toolName: "t2" }); + m.release(s2, "awaiting_tool_result"); + + // Should find conv-1 first because it has "call_1" + const found = m.findByToolCallIds(["call_1", "call_2"]); + assert.equal(found, s1); + // conv-2 session should still be available + const found2 = m.findByToolCallIds(["call_2"]); + assert.equal(found2, s2); +}); + test("CursorSessionManager.open replaces an existing session for the same conversation", () => { const m = new CursorSessionManager(); const r1 = mockReq();