Agent API with `stream: false` always returns `messages: []` and zero tokens — ResponseCollector only matches the legacy string `claude-response` shape
Version: 1.37.2, and 1.37.3 (the code is byte-identical in both). Self-hosted npm install, Node 22.23.1, Linux, claude provider on a Pro subscription. Reached over plain HTTP on 127.0.0.1:3001, no proxy.
Describe the bug
POST /api/agent with stream: false always answers 200 OK with an empty result, no matter what the agent actually did:
{"success":true,"sessionId":"0fd41a3a-…","messages":[],
"tokens":{"inputTokens":0,"outputTokens":0,"cacheReadTokens":0,"cacheCreationTokens":0,"totalTokens":0},
"projectPath":"/home/user"}The run itself is healthy — only the response is empty. This makes the non-streaming half of the external API unusable: a caller has no way to read the agent's answer, and the token accounting is always zero.
To Reproduce
curl -s -X POST http://127.0.0.1:3001/api/agent \
-H "x-api-key: $KEY" -H 'Content-Type: application/json' \
-d '{"projectPath":"/home/user","message":"Reply with the single word PONG, use no tools.","stream":false}'Returns the payload above: messages: [], all token counters 0, success: true.
The agent really did run and really did answer. The session transcript CloudCLI just created holds the reply and the usage:
assistant | PONG | {"input_tokens":2,"cache_creation_input_tokens":10157,
"cache_read_input_tokens":18069,"output_tokens":5, …}Same prompt, same project, stream: true — works, the text is right there:
data: {"type":"status","message":"Session started","projectPath":"/home/user"}
data: {"type":"session-id","sessionId":"2296142c-…"}
data: {"kind":"session_created","newSessionId":"2296142c-…","provider":"claude", …}
data: {"id":"abcd9495-…_0","sessionId":"2296142c-…","provider":"claude","kind":"text","role":"assistant","content":"BONJOUR"}
data: {"kind":"status","text":"token_budget","tokenBudget":{"used":28237,"total":160000,"inputTokens":28236,"outputTokens":1, …}}
data: {"kind":"complete","provider":"claude","sessionId":"2296142c-…","exitCode":0,"success":true,"aborted":false}
data: {"type":"done"}Expected behavior
stream: false should return the assistant's messages and the real token totals — what the endpoint's own doc block promises:
* {
* success: true,
* sessionId: "session-123",
* messages: [...], // Assistant messages only (filtered)
* tokens: { inputTokens: 150, outputTokens: 50, … }
* }Root cause
Two independent reasons, in server/modules/agent/agent.routes.ts, both in ResponseCollector.
1. The filter only looks at strings; providers send objects. getAssistantMessages() (~L561) puts its whole body behind a branch that can never be taken:
for (const msg of this.messages) {
if (msg && msg.type === 'status') continue;
if (typeof msg === 'string') { // <-- never true
const parsed = JSON.parse(msg);
if (parsed.type === 'claude-response' && parsed.data && parsed.data.type === 'assistant') {
assistantMessages.push(parsed.data);
}
}
}SSEStreamWriter, thirty lines above in the same file, states the contract plainly:
send(data) {
// Format as SSE - providers send raw objects, we stringify
this.res.write(`data: ${JSON.stringify(data)}\n\n`);
}ResponseCollector is the same writer interface handed to the same providers, so it receives those same raw objects. typeof msg === 'string' is dead by construction, and getAssistantMessages() returns [] for every run. getTotalTokens() (~L590) is built the same way — it does handle objects, but only matches data.type === 'claude-response', so it also never adds anything.
2. Even reachable, the shape is stale. {type: 'claude-response', data: {type: 'assistant'}} is not what providers emit any more. As the SSE capture above shows, assistant text now arrives as {kind: 'text', role: 'assistant', content: '…'}, and usage arrives as {kind: 'status', text: 'token_budget', tokenBudget: {…}}. Nothing in a current run matches the old predicate.
The doc block for the endpoint still documents the old streaming events too (- { type: "claude-response", data: {...} }), so it is worth refreshing alongside.
Why CI doesn't catch it
Every case in server/modules/agent/tests/agent.routes.test.js stubs all four providers with unexpectedProviderCall, which throws. The tests cover validation, auth and clone-argument hygiene — all the paths that return before a provider runs. No test ever drives a successful run through ResponseCollector, so the shape mismatch is invisible.
Suggested fix
Normalize once, accept both shapes, and cover it with a test that feeds the collector real provider events:
const asEvent = (msg: unknown) => {
if (typeof msg !== 'string') return msg as any;
try { return JSON.parse(msg); } catch { return null; }
};
getAssistantMessages() {
const out = [];
for (const msg of this.messages) {
const e = asEvent(msg);
if (!e || e.type === 'status') continue;
// current provider shape
if (e.kind === 'text' && e.role === 'assistant') {
out.push({ type: 'assistant', content: e.content, sessionId: e.sessionId, timestamp: e.timestamp });
continue;
}
// legacy shape, kept for providers that still emit it
if (e.type === 'claude-response' && e.data?.type === 'assistant') out.push(e.data);
}
return out;
}For the totals, the simplest source is the last {kind: 'status', text: 'token_budget'} event — it already carries inputTokens / outputTokens / cacheReadTokens / cacheCreationTokens — with the legacy message.usage summation kept as a fallback.
Worth checking the other three providers (cursor, codex, opencode) against the same predicate while you're in there; I only exercised claude.
Possibly the same pattern elsewhere — not verified
server/modules/git/git.routes.ts builds an inline collector for AI commit-message generation that matches {type:'claude-response'}, {type:'cursor-output'} and {type:'text'}, with no kind branch. If the provider events reaching it are the ones above, responseText stays empty and every generated message silently degrades to the chore: update N files fallback. I haven't tested that endpoint, so treat this as a lead rather than a report.
Source: siteboon/claudecodeui