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`.
This commit is contained in:
Danny Kopping
2026-04-22 19:02:31 +02:00
committed by GitHub
parent e421c2f747
commit 8aa3294f06
4 changed files with 14 additions and 2 deletions
+1 -1
View File
@@ -51,7 +51,7 @@ func GuessClient(r *http.Request) Client {
return ClientRoo return ClientRoo
case strings.HasPrefix(userAgent, "coder-agents/"): case strings.HasPrefix(userAgent, "coder-agents/"):
return ClientCoderAgents return ClientCoderAgents
case strings.HasPrefix(userAgent, "charm crush/"): case strings.HasPrefix(userAgent, "charm crush/") || strings.HasPrefix(userAgent, "charm-crush/"):
return ClientCrush return ClientCrush
case r.Header.Get("x-cursor-client-version") != "": case r.Header.Get("x-cursor-client-version") != "":
return ClientCursor return ClientCursor
+6 -1
View File
@@ -79,10 +79,15 @@ func TestGuessClient(t *testing.T) {
wantClient: aibridge.ClientCoderAgents, wantClient: aibridge.ClientCoderAgents,
}, },
{ {
name: "charm_crush", name: "charm_crush_space",
userAgent: "Charm Crush/0.1.11", userAgent: "Charm Crush/0.1.11",
wantClient: aibridge.ClientCrush, wantClient: aibridge.ClientCrush,
}, },
{
name: "charm_crush_hyphen",
userAgent: "Charm-Crush/0.2.0 (https://charm.land/crush)",
wantClient: aibridge.ClientCrush,
},
{ {
name: "cursor_x_cursor_client_version", name: "cursor_x_cursor_client_version",
userAgent: "connect-es/1.6.1", userAgent: "connect-es/1.6.1",
+2
View File
@@ -75,6 +75,8 @@ func GuessSessionID(client Client, r *http.Request) *string {
return cleanRef(r.Header.Get("X-KILOCODE-TASKID")) return cleanRef(r.Header.Get("X-KILOCODE-TASKID"))
case ClientCoderAgents: case ClientCoderAgents:
return cleanRef(r.Header.Get("X-Coder-Chat-Id")) return cleanRef(r.Header.Get("X-Coder-Chat-Id"))
case ClientCrush:
return nil // Crush does not send a session ID header.
case ClientRoo: case ClientRoo:
return nil // RooCode doesn't send a session ID. return nil // RooCode doesn't send a session ID.
case ClientCursor: case ClientCursor:
+5
View File
@@ -182,6 +182,11 @@ func TestGuessSessionID(t *testing.T) {
name: "coder_agents_without_chat_id", name: "coder_agents_without_chat_id",
client: aibridge.ClientCoderAgents, client: aibridge.ClientCoderAgents,
}, },
// Crush.
{
name: "crush_returns_empty",
client: aibridge.ClientCrush,
},
// Roo. // Roo.
{ {
name: "roo_returns_empty", name: "roo_returns_empty",