feat: per-thread session isolation for Claude and Codex agents#152
feat: per-thread session isolation for Claude and Codex agents#152
Conversation
Use Claude's --session-id / -r flags to maintain independent sessions per (agent_id, thread_id) pair, so messages from different users or channels no longer bleed context into each other. Closes #144 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use `codex exec resume <session_id>` for existing threads and capture session ID from JSONL output on first run. Falls back to `resume --last` when no threadId is provided. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
On reset, just start a new session and let saveThreadSession overwrite the mapping. The old session still exists in Claude/Codex session storage so users can revisit previous conversations. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
@greptileai review this PR |
Greptile SummaryThis PR introduces per-thread session isolation for Claude and Codex agents by adding a Key changes:
Confidence Score: 2/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant U as User (Telegram/Discord)
participant Ch as Channel Client
participant Q as Queue (SQLite)
participant QP as Queue Processor
participant DB as thread_sessions DB
participant AI as Claude / Codex CLI
U->>Ch: Send message
Ch->>Q: enqueueMessage(threadId: "telegram:<id>")
QP->>Q: claimNextMessage()
QP->>DB: getThreadSession(agentId, threadId)
alt Session exists (not reset)
DB-->>QP: { session_id, provider }
QP->>AI: claude -r <session_id> -p <msg>
note over AI: Resumes existing session
else No session or reset
DB-->>QP: null
QP->>AI: claude --session-id <new_uuid> -p <msg>
QP->>DB: saveThreadSession(agentId, threadId, new_uuid)
note over AI: Starts new session
end
AI-->>QP: response
QP->>Q: enqueueResponse(...)
Q-->>Ch: Response delivered to user
|
| if (threadId && detectedSessionId && !getThreadSession(agentId, threadId)) { | ||
| saveThreadSession(agentId, threadId, detectedSessionId, 'openai'); | ||
| log('INFO', `Saved Codex session ${detectedSessionId} for thread ${threadId}`); | ||
| } |
There was a problem hiding this comment.
Codex reset doesn't save the new session ID
After a reset (shouldReset = true), existing is set to null (line 98) so Codex starts a fresh session. But the old DB record for this (agentId, threadId) pair is still present. When the new detectedSessionId is captured from JSONL output, the guard !getThreadSession(agentId, threadId) calls the DB again and finds the old record — so it returns false and the new session is never saved.
On the very next message from this thread, getThreadSession returns the old (pre-reset) session_id, and Codex wrongly resumes the conversation that was supposed to be discarded.
The Claude path correctly avoids this by using the already-computed existing variable as its guard (else { saveThreadSession(...) }). Codex should do the same:
| if (threadId && detectedSessionId && !getThreadSession(agentId, threadId)) { | |
| saveThreadSession(agentId, threadId, detectedSessionId, 'openai'); | |
| log('INFO', `Saved Codex session ${detectedSessionId} for thread ${threadId}`); | |
| } | |
| if (threadId && detectedSessionId && !existing) { | |
| saveThreadSession(agentId, threadId, detectedSessionId, 'openai'); | |
| log('INFO', `Saved Codex session ${detectedSessionId} for thread ${threadId}`); | |
| } |
| export function deleteThreadSession(agentId: string, threadId: string): void { | ||
| getDb().prepare(` | ||
| DELETE FROM thread_sessions WHERE agent_id = ? AND thread_id = ? | ||
| `).run(agentId, threadId); | ||
| } |
There was a problem hiding this comment.
deleteThreadSession is unused dead code
This function is exported but is never called anywhere in the codebase. Per the PR description, resets work by overwriting the mapping via INSERT OR REPLACE (in saveThreadSession), so deletion is not actually needed. Consider removing this export to avoid confusion about whether it's part of the reset flow.
| export function saveThreadSession(agentId: string, threadId: string, sessionId: string, provider: string): void { | ||
| const now = Date.now(); | ||
| getDb().prepare(` | ||
| INSERT OR REPLACE INTO thread_sessions (agent_id, thread_id, session_id, provider, created_at, updated_at) | ||
| VALUES (?, ?, ?, ?, ?, ?) | ||
| `).run(agentId, threadId, sessionId, provider, now, now); | ||
| } |
There was a problem hiding this comment.
created_at is overwritten on every session update
INSERT OR REPLACE deletes the old row and inserts a fresh one, so created_at is reset to the current time whenever a session is replaced (e.g., after a reset). If you ever want to know when a thread's original session was established, that information is lost.
A common pattern is to preserve created_at during an upsert:
INSERT INTO thread_sessions (agent_id, thread_id, session_id, provider, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(agent_id, thread_id) DO UPDATE SET
session_id = excluded.session_id,
provider = excluded.provider,
updated_at = excluded.updated_atThis keeps the original created_at while still updating all other fields.
Summary
thread_sessionstable to track(agent_id, thread_id) → session_idmappings, enabling independent sessions per user per agent--session-id <uuid>for new sessions and-r <uuid>to resume, replacing the shared-c(continue last) flagcodex exec resume <session_id>for existing threads, captures session ID from JSONL output on first runthreadId: "telegram:<chat_id>", Discord sendsthreadId: "discord:<user_id>"-c/resume --lastbehavior for API calls without thread contextCloses #144
Files changed
src/lib/db.ts— newthread_sessionstable,getThreadSession/saveThreadSession/deleteThreadSessionhelpers,thread_idcolumn on messagessrc/lib/invoke.ts— session-id logic for Claude and Codex providerssrc/queue-processor.ts— passthreadIdtoinvokeAgentsrc/server/routes/messages.ts— acceptthreadIdin POST bodysrc/channels/telegram-client.ts— setthreadIdon messagessrc/channels/discord-client.ts— setthreadIdon messagesTest plan
npm run buildpasses--session-id <uuid>-r <uuid>(same session)/reset @agent→ next message gets a fresh--session-id <new-uuid>threadIdstill use-c/resume --lastbehavior🤖 Generated with Claude Code