Agent API with `stream: false` always returns `messages: []` and zero tokens — ResponseCollector only matches the legacy string `claude-response` shape

Author: sebzz07Created Sep 15, 2026Updated Sep 15, 2026

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:

json
{"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

bash
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:

typescript
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:

typescript
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:

typescript
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.