Skip to content

Commit 5ff0a9e

Browse files
committed
fix(cursor): unify conversation_id session-key precedence across all hook events
Surfaced by Cursor agent spec-check on PR #129 (2026-05-10). Previous behaviour was: pre/postToolUse mapped by conversation_id, sessionEnd preferred session_id with conversation_id fallback. Cursor's sessionEnd payload contains BOTH (conversation_id from common-base fields + session_id from event-specific fields), and the two IDs identify different scopes (conversation_id = chat thread; session_id = broader SDK session that may span multiple conversations). The mismatch would orphan AXME sessions: pre/postToolUse creates .axme-code/active-sessions/<conversation_id>.txt, then sessionEnd looks up by session_id and finds nothing — clearClaudeSessionMapping no-ops, the original mapping leaks, and the audit worker doesn't fire on the right session. Fix: always prefer conversation_id (present in every Cursor hook payload via common-base fields), fall back to session_id only if conversation_id is somehow absent. Apply to all three hook kinds uniformly. Tests updated: existing "uses session_id for sessionEnd" assertion inverted to "uses conversation_id for sessionEnd"; new test for session_id fallback when conversation_id absent. Full suite: 573 / 573 (was 572 before fix; +1 new fallback test). tsc clean. build clean. Also supersedes memory cursor-sdk-system-prompt-via-inline-agent-... (deleted) with cursor-sdk-system-prompt-prepend-to-first-send-... after the same spec-check found that Agent.create() has no top-level systemPrompt option and the agents+agentId pattern injects subagents, not the outer agent's system prompt. PR-2 implementation will use agent.send(`<system>\n\${SYSTEM}\n</system>\n\n\${user}`) instead. This fix touches PR-1 code only; the PR-2 SDK adapter design is already updated in the plan file. #!axme pr=129 repo=AxmeAI/axme-code
1 parent 6ee387f commit 5ff0a9e

2 files changed

Lines changed: 28 additions & 15 deletions

File tree

src/hooks/adapters/cursor.ts

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,18 +34,17 @@ export const cursorInputAdapter: HookInputAdapter = {
3434
const toolName = asString(obj.tool_name);
3535
const toolInput = asObject(obj.tool_input);
3636

37-
// Pre/postToolUse: Cursor identifies the running session via
38-
// conversation_id (per-conversation) + generation_id (per-turn).
39-
// Use conversation_id as the stable session id so consecutive tool calls
40-
// in the same conversation route to the same AXME session.
41-
// sessionEnd: Cursor sends a top-level session_id that may differ from
42-
// conversation_id (it identifies the SDK session, not the chat).
43-
let sessionId: string | undefined;
44-
if (kind === "sessionEnd") {
45-
sessionId = asString(obj.session_id) ?? asString(obj.conversation_id);
46-
} else {
47-
sessionId = asString(obj.conversation_id) ?? asString(obj.session_id);
48-
}
37+
// Use conversation_id as the stable AXME session key across ALL three
38+
// hook events. conversation_id is part of Cursor's common-base fields
39+
// (present in preToolUse, postToolUse, AND sessionEnd payloads), and
40+
// it represents one chat thread — exactly the granularity AXME wants
41+
// for one filesChanged trail / one audit. session_id is only event-
42+
// specific to sessionStart/sessionEnd and may identify a coarser SDK
43+
// session that spans multiple conversations; using it would break the
44+
// mapping created by pre/postToolUse (which only sees conversation_id),
45+
// leaving the work as an orphan at audit time. Fall back to session_id
46+
// only if conversation_id is somehow missing.
47+
const sessionId = asString(obj.conversation_id) ?? asString(obj.session_id);
4948

5049
// transcript_path may legitimately be null on Cursor (e.g. very first
5150
// turn). Preserve null so callers can distinguish "no transcript yet"

test/cursor-hook-adapter.test.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,13 @@ describe("cursorInputAdapter.parse — preToolUse", () => {
4343
});
4444

4545
describe("cursorInputAdapter.parse — sessionEnd", () => {
46-
it("uses session_id (not conversation_id) for sessionEnd", () => {
46+
it("uses conversation_id (not session_id) for sessionEnd — consistency with pre/postToolUse", () => {
47+
// Cursor's sessionEnd payload includes BOTH conversation_id (common
48+
// base) and session_id (event-specific). We deliberately prefer
49+
// conversation_id so all three hook events route to the same AXME
50+
// session via the same key. Otherwise pre/postToolUse would map by
51+
// conversation_id and sessionEnd would look up by session_id, leaving
52+
// the work as an orphan.
4753
const ev = cursorInputAdapter.parse(
4854
{
4955
cursor_version: "1.7",
@@ -58,11 +64,19 @@ describe("cursorInputAdapter.parse — sessionEnd", () => {
5864
);
5965
assert.equal(ev.kind, "sessionEnd");
6066
assert.equal(ev.ide, "cursor");
61-
assert.equal(ev.sessionId, "sdk-session-b");
67+
assert.equal(ev.sessionId, "conv-a");
6268
assert.equal(ev.reason, "user_close");
6369
});
6470

65-
it("falls back to conversation_id when session_id absent", () => {
71+
it("falls back to session_id when conversation_id absent", () => {
72+
const ev = cursorInputAdapter.parse(
73+
{ cursor_version: "1.7", session_id: "sdk-session-only", reason: "completed" },
74+
"sessionEnd",
75+
);
76+
assert.equal(ev.sessionId, "sdk-session-only");
77+
});
78+
79+
it("falls back to conversation_id when session_id absent (older Cursor versions)", () => {
6680
const ev = cursorInputAdapter.parse(
6781
{ cursor_version: "1.7", conversation_id: "conv-a", reason: "completed" },
6882
"sessionEnd",

0 commit comments

Comments
 (0)