mirror of
https://github.com/coder/coder.git
synced 2026-09-21 12:44:32 +08:00
Codex prompts were showing up as one session per request in the AI Sessions list instead of being grouped into a conversation. ## Root Cause AI Gateway extracts the Codex session key from the `session_id` request header: https://github.com/coder/coder/blob/main/aibridge/session.go#L57-L58 Newer Codex releases renamed the header to `session-id` (hyphen) in [`codex-rs/codex-api/src/requests/headers.rs`](https://github.com/openai/codex/blob/main/codex-rs/codex-api/src/requests/headers.rs): ```rust insert_header(&mut headers, "session-id", &id); ``` `Header.Get` is case-insensitive but not underscore/hyphen-insensitive, so no session key is extracted and every request falls back to its own session. Reproduced with Codex CLI 0.139.0. ## Changes - Check `session-id` first, fall back to the legacy `session_id` for older Codex versions - Added test cases for the hyphenated header and precedence ## Before/After The same three-prompt Codex conversation ("Write a haiku about Pittsburgh" → "Now make it about Coder" → "Translate it to Spanish", via `codex exec` + `codex exec resume --last`) against a local build. **Before**: each prompt of the conversation lands as its own session, Threads: 1  **After**: the conversation is a single session with Threads: 3  Clicking into the session shows all three threads on the session timeline:  Linear: [AIGOV-437](https://linear.app/codercom/issue/AIGOV-437) 🤖 Generated with Coder Agents on behalf of @bpmct
110 lines
3.4 KiB
Go
110 lines
3.4 KiB
Go
package aibridge
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/tidwall/gjson"
|
|
|
|
"github.com/coder/coder/v2/aibridge/utils"
|
|
)
|
|
|
|
var claudeCodePattern = regexp.MustCompile(`_session_(.+)$`) // Legacy format: save compilation on each call.
|
|
|
|
// GuessSessionID attempts to retrieve a session ID which may have been sent by
|
|
// the client. We only attempt to retrieve sessions using methods recognized for
|
|
// the given client.
|
|
func GuessSessionID(client Client, r *http.Request) *string {
|
|
switch client {
|
|
case ClientClaudeCode:
|
|
// Prefer the dedicated header (added in Claude Code v2.1.86+).
|
|
if sid := cleanRef(r.Header.Get("X-Claude-Code-Session-Id")); sid != nil {
|
|
return sid
|
|
}
|
|
|
|
// Fall back to extracting from the metadata.user_id field in the JSON body.
|
|
// Newer format: JSON-encoded object with a "session_id" field.
|
|
// Legacy format: "user_{sha256}_account_{id}_session_{uuid}"
|
|
payload, err := io.ReadAll(r.Body)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
_ = r.Body.Close()
|
|
|
|
// Restore the request body.
|
|
r.Body = io.NopCloser(bytes.NewReader(payload))
|
|
userID := gjson.GetBytes(payload, "metadata.user_id")
|
|
if userID.Type != gjson.String {
|
|
return nil
|
|
}
|
|
|
|
raw := userID.String()
|
|
|
|
// Newer body format: user_id is a JSON-encoded object with a session_id field.
|
|
if sessionID := gjson.Get(raw, "session_id"); sessionID.Exists() {
|
|
return cleanRef(sessionID.String())
|
|
}
|
|
|
|
// Legacy body format: "user_{sha256}_account_{id}_session_{uuid}"
|
|
matches := claudeCodePattern.FindStringSubmatch(raw)
|
|
if len(matches) < 2 {
|
|
return nil
|
|
}
|
|
return cleanRef(matches[1])
|
|
case ClientCodex:
|
|
// Codex renamed the header from "session_id" to "session-id" in
|
|
// newer releases. Check the current name first, then fall back to
|
|
// the legacy name for older Codex versions.
|
|
if sid := cleanRef(r.Header.Get("session-id")); sid != nil {
|
|
return sid
|
|
}
|
|
return cleanRef(r.Header.Get("session_id"))
|
|
case ClientMux:
|
|
return cleanRef(r.Header.Get("X-Mux-Workspace-Id"))
|
|
case ClientZed:
|
|
return nil // Zed does not send a session ID from Zed Agent or Text Thread.
|
|
case ClientCopilotVSC:
|
|
// This does not map precisely to what we consider a session, but it's close enough.
|
|
// Most other providers' equivalent of this would persist for the duration of a
|
|
// conversation; it does seem to persist across an agentic loop though, which is
|
|
// all we really need.
|
|
//
|
|
// There's also `vscode-sessionid` but that's persistent for the duration of the
|
|
// VS Code window.
|
|
return cleanRef(r.Header.Get("x-interaction-id"))
|
|
case ClientCopilotCLI:
|
|
return cleanRef(r.Header.Get("X-Client-Session-Id"))
|
|
case ClientKilo:
|
|
return cleanRef(r.Header.Get("X-KILOCODE-TASKID"))
|
|
case ClientCoderAgents:
|
|
return cleanRef(r.Header.Get("X-Coder-Chat-Id"))
|
|
case ClientOpenCode:
|
|
// Prefer X-OpenCode-Session (set by the OpenCode "Zen" provider).
|
|
if sid := cleanRef(r.Header.Get("X-OpenCode-Session")); sid != nil {
|
|
return sid
|
|
}
|
|
// Fall back to x-session-affinity (set by other providers).
|
|
return cleanRef(r.Header.Get("x-session-affinity"))
|
|
case ClientCrush:
|
|
return nil // Crush does not send a session ID header.
|
|
case ClientRoo:
|
|
return nil // RooCode doesn't send a session ID.
|
|
case ClientCursor:
|
|
return nil // Cursor is not currently supported.
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func cleanRef(str string) *string {
|
|
str = strings.TrimSpace(str)
|
|
if str == "" {
|
|
return nil
|
|
}
|
|
|
|
return utils.PtrTo(str)
|
|
}
|