Files
Danny Kopping 8aa3294f06 fix(aibridge): track Charm Crush client and session ID (#24630)
*Disclaimer: implemented by a Coder Agent using Claude Opus 4.6*

Porting https://github.com/coder/aibridge/pull/277 to coder/coder after
the [aibridge code move](https://github.com/coder/coder/pull/24190).

## Summary

Fixes client detection and session ID tracking for the [Charm
Crush](https://github.com/charmbracelet/crush) AI coding client.

## Changes

### Bug fix: User-Agent matching

The actual Crush user-agent is `Charm-Crush/{version}
(https://charm.land/crush)` (hyphenated), but `GuessClient` only checked
for `charm crush/` (space-separated). After lowercasing,
`Charm-Crush/0.2.0` becomes `charm-crush/0.2.0`, which did not match the
`charm crush/` prefix.

Now matches both formats for backwards compatibility.

### Session ID tracking

Adds an explicit `ClientCrush` case to `GuessSessionID`. Crush does not
currently send a session ID header to upstream AI providers, so this
returns `nil` (consistent with how `ClientZed`, `ClientRoo`, and
`ClientCursor` are handled).

### Tests

- Added `charm_crush_hyphen` test case for `GuessClient` using the real
user-agent format.
- Added `crush_returns_empty` test case for `GuessSessionID`.
2026-04-22 19:02:31 +02:00

248 lines
7.2 KiB
Go

package aibridge_test
import (
"io"
"net/http"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/aibridge"
"github.com/coder/coder/v2/aibridge/utils"
)
func TestGuessSessionID(t *testing.T) {
t.Parallel()
cases := []struct {
name string
client aibridge.Client
body string
headers map[string]string
sessionID *string
}{
// Claude Code.
{
name: "claude_code_header_takes_precedence",
client: aibridge.ClientClaudeCode,
headers: map[string]string{"X-Claude-Code-Session-Id": "header-session-id"},
body: `{"metadata":{"user_id":"user_abc123_account_456_session_body-session-id"}}`,
sessionID: utils.PtrTo("header-session-id"),
},
{
name: "claude_code_header_only",
client: aibridge.ClientClaudeCode,
headers: map[string]string{"X-Claude-Code-Session-Id": "aabb-ccdd"},
body: `{"model":"claude-3"}`,
sessionID: utils.PtrTo("aabb-ccdd"),
},
{
name: "claude_code_empty_header_falls_back_to_body",
client: aibridge.ClientClaudeCode,
headers: map[string]string{"X-Claude-Code-Session-Id": ""},
body: `{"metadata":{"user_id":"user_abc123_account_456_session_f47ac10b-58cc-4372-a567-0e02b2c3d479"}}`,
sessionID: utils.PtrTo("f47ac10b-58cc-4372-a567-0e02b2c3d479"),
},
{
name: "claude_code_whitespace_header_falls_back_to_body",
client: aibridge.ClientClaudeCode,
headers: map[string]string{"X-Claude-Code-Session-Id": " "},
body: `{"metadata":{"user_id":"user_abc123_account_456_session_f47ac10b-58cc-4372-a567-0e02b2c3d479"}}`,
sessionID: utils.PtrTo("f47ac10b-58cc-4372-a567-0e02b2c3d479"),
},
{
name: "claude_code_with_valid_session",
client: aibridge.ClientClaudeCode,
body: `{"metadata":{"user_id":"user_abc123_account_456_session_f47ac10b-58cc-4372-a567-0e02b2c3d479"}}`,
sessionID: utils.PtrTo("f47ac10b-58cc-4372-a567-0e02b2c3d479"),
},
{
name: "claude_code_with_valid_session_new_format",
client: aibridge.ClientClaudeCode,
body: `{"metadata":{"user_id":"{\"device_id\":\"45aa15c8c244ea2582f8144dde91a50ec3815851f6f648abef4ee15b173cc927\",\"account_uuid\":\"\",\"session_id\":\"54c1eb09-bc4c-4d2f-98eb-6d2ab2d5e2fe\"}"}}`,
sessionID: utils.PtrTo("54c1eb09-bc4c-4d2f-98eb-6d2ab2d5e2fe"),
},
{
name: "claude_code_new_format_empty_session_id",
client: aibridge.ClientClaudeCode,
body: `{"metadata":{"user_id":"{\"device_id\":\"abc\",\"account_uuid\":\"\",\"session_id\":\"\"}"}}`,
},
{
name: "claude_code_new_format_no_session_id_field",
client: aibridge.ClientClaudeCode,
body: `{"metadata":{"user_id":"{\"device_id\":\"abc\",\"account_uuid\":\"\"}"}}`,
},
{
name: "claude_code_missing_metadata",
client: aibridge.ClientClaudeCode,
body: `{"model":"claude-3"}`,
},
{
name: "claude_code_missing_user_id",
client: aibridge.ClientClaudeCode,
body: `{"metadata":{}}`,
},
{
name: "claude_code_user_id_without_session",
client: aibridge.ClientClaudeCode,
body: `{"metadata":{"user_id":"user_abc123_account_456"}}`,
},
{
name: "claude_code_empty_body",
client: aibridge.ClientClaudeCode,
body: ``,
},
{
name: "claude_code_invalid_json",
client: aibridge.ClientClaudeCode,
body: `not json at all`,
},
// Codex.
{
name: "codex_with_session_header",
client: aibridge.ClientCodex,
headers: map[string]string{"session_id": "codex-session-123"},
sessionID: utils.PtrTo("codex-session-123"),
},
{
name: "codex_with_whitespace_in_header",
client: aibridge.ClientCodex,
headers: map[string]string{"session_id": " codex-session-123 "},
sessionID: utils.PtrTo("codex-session-123"),
},
{
name: "codex_without_session_header",
client: aibridge.ClientCodex,
},
// Other clients shouldn't use others' logic.
{
name: "unknown_client_returns_empty",
client: aibridge.ClientUnknown,
body: `{"metadata":{"user_id":"user_abc_account_456_session_some-id"}}`,
},
{
name: "zed_returns_empty",
client: aibridge.ClientZed,
headers: map[string]string{"session_id": "zed-session"},
body: `{"metadata":{"user_id":"user_abc_account_456_session_some-id"}}`,
},
// Mux.
{
name: "mux_with_workspace_header",
client: aibridge.ClientMux,
headers: map[string]string{"X-Mux-Workspace-Id": "ws-abc-123"},
sessionID: utils.PtrTo("ws-abc-123"),
},
{
name: "mux_without_workspace_header",
client: aibridge.ClientMux,
},
// Copilot VS Code.
{
name: "copilot_vsc_with_interaction_id",
client: aibridge.ClientCopilotVSC,
headers: map[string]string{"x-interaction-id": "interaction-xyz"},
sessionID: utils.PtrTo("interaction-xyz"),
},
{
name: "copilot_vsc_without_interaction_id",
client: aibridge.ClientCopilotVSC,
},
// Copilot CLI.
{
name: "copilot_cli_with_session_header",
client: aibridge.ClientCopilotCLI,
headers: map[string]string{"X-Client-Session-Id": "cli-sess-456"},
sessionID: utils.PtrTo("cli-sess-456"),
},
{
name: "copilot_cli_without_session_header",
client: aibridge.ClientCopilotCLI,
},
// Kilo.
{
name: "kilo_with_task_id",
client: aibridge.ClientKilo,
headers: map[string]string{"X-KILOCODE-TASKID": "task-789"},
sessionID: utils.PtrTo("task-789"),
},
{
name: "kilo_without_task_id",
client: aibridge.ClientKilo,
},
// Coder Agents.
{
name: "coder_agents_with_chat_id",
client: aibridge.ClientCoderAgents,
headers: map[string]string{"X-Coder-Chat-Id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"},
sessionID: utils.PtrTo("a1b2c3d4-e5f6-7890-abcd-ef1234567890"),
},
{
name: "coder_agents_without_chat_id",
client: aibridge.ClientCoderAgents,
},
// Crush.
{
name: "crush_returns_empty",
client: aibridge.ClientCrush,
},
// Roo.
{
name: "roo_returns_empty",
client: aibridge.ClientRoo,
},
// Cursor.
{
name: "cursor_returns_empty",
client: aibridge.ClientCursor,
},
// Other cases.
{
name: "empty session ID value",
client: aibridge.ClientKilo,
headers: map[string]string{"X-KILOCODE-TASKID": " "},
sessionID: nil,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
body := tc.body
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "http://localhost", strings.NewReader(body))
require.NoError(t, err)
for key, value := range tc.headers {
req.Header.Set(key, value)
}
got := aibridge.GuessSessionID(tc.client, req)
require.Equal(t, tc.sessionID, got)
// Verify the body was restored and can be read again.
restored, err := io.ReadAll(req.Body)
require.NoError(t, err)
require.Equal(t, body, string(restored))
})
}
}
func TestUnreadableBody(t *testing.T) {
t.Parallel()
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "http://localhost", &errReader{})
require.NoError(t, err)
got := aibridge.GuessSessionID(aibridge.ClientClaudeCode, req)
require.Nil(t, got)
}
// errReader is an io.Reader that always returns an error.
type errReader struct{}
func (*errReader) Read([]byte) (int, error) {
return 0, io.ErrUnexpectedEOF
}