Summary
The standard ACP surface carries no token-consumption field, so ACP threads produce no thread/tokenUsage/updated events. Nothing expresses that, so downstream an unmeasured turn looks the same as a turn that used nothing.
To be clear about what this is not: the bridge is not dropping anything. On main (42d2c1b), ACP's only usage message is
// packages/provider-bridge-acp/src/wire.ts:246-253
acpUsageUpdateSchema = { sessionUpdate: "usage_update", used, size } // .passthrough()
— context-window occupancy — and delta-translation.ts:970-983 maps it to { kind: "contextWindow", used, size, estimated: false }. That mapping looks correct. There is simply no token data on the standard surface to forward.
Observation
From bb.db on one host:
SELECT t.provider_id,
count(DISTINCT t.id) AS threads,
sum(CASE WHEN e.type LIKE '%turn/completed' THEN 1 ELSE 0 END) AS completed_turns,
sum(CASE WHEN e.type = 'thread/tokenUsage/updated' THEN 1 ELSE 0 END) AS usage_events
FROM events e JOIN threads t ON t.id = e.thread_id
GROUP BY t.provider_id ORDER BY completed_turns DESC;
| provider |
threads |
completed turns |
tokenUsage/updated |
| claude-code |
62 |
1932 |
96 |
| codex |
153 |
570 |
368 |
| acp-opencode |
124 |
307 |
0 |
| acp-hermes-agent |
38 |
305 |
0 |
Scope of that data, so it isn't read as more than it is: both ACP providers are locally-registered agents rather than anything bb ships, and both run through the same bridge and translation path — so the zeros follow from the schema above rather than from two independent agents. The non-ACP rows are not 1:1 either (claude-code shows 1932 completions against 96 usage events), so no per-turn denominator should be read from this table. The distinct threads reporting a context window are 106 (acp-opencode) and 38 (acp-hermes-agent), so these threads are observed — only their cost is absent.
Why it's awkward downstream
A consumer can detect the absence by joining on events, as the query above does. The cost falls on consumers that instead collapse a missing report to a numeric zero, which is easy to write when the providers you integrate against first all report. That happened in a plugin I maintain: its usage columns are NOT NULL DEFAULT 0, so every ACP row reads as a measured zero and share-style metrics return a confident 0% for ACP threads.
That coercion is mine, not bb's. The general point is only that nothing in the event stream states "this turn produced no report", so correct handling depends on a consumer knowing to look for an absence.
Possible improvement
Optional capability metadata on the provider descriptor:
reportsTokenUsage?: boolean; // absent = unknown; false = known not to
Absence should mean unknown rather than true — existing and third-party descriptors omit the field, and reading those as capable would assert something unestablished.
A capability rather than a per-turn marker because dialect.ts already lets a vendor stamp _meta side channels and acpUsageUpdateSchema is .passthrough(), so an agent could carry token data without a protocol change. A dialect that does surface tokens sets true; the generic ACP dialect sets false. That way the signal improves as vendors improve instead of hard-coding a limitation, and a consumer decides once per provider.
Its limit, stated plainly: a capability describes what a provider can do, not whether a given turn was measured. If those diverge within a provider it can't express that — I have no evidence they do.
Tests it would need: descriptor parse/serialize round-trip with the field present and absent, and a legacy descriptor with no field staying unknown rather than coerced.
Summary
The standard ACP surface carries no token-consumption field, so ACP threads produce no
thread/tokenUsage/updatedevents. Nothing expresses that, so downstream an unmeasured turn looks the same as a turn that used nothing.To be clear about what this is not: the bridge is not dropping anything. On
main(42d2c1b), ACP's only usage message is— context-window occupancy — and
delta-translation.ts:970-983maps it to{ kind: "contextWindow", used, size, estimated: false }. That mapping looks correct. There is simply no token data on the standard surface to forward.Observation
From
bb.dbon one host:tokenUsage/updatedScope of that data, so it isn't read as more than it is: both ACP providers are locally-registered agents rather than anything bb ships, and both run through the same bridge and translation path — so the zeros follow from the schema above rather than from two independent agents. The non-ACP rows are not 1:1 either (claude-code shows 1932 completions against 96 usage events), so no per-turn denominator should be read from this table. The distinct threads reporting a context window are 106 (
acp-opencode) and 38 (acp-hermes-agent), so these threads are observed — only their cost is absent.Why it's awkward downstream
A consumer can detect the absence by joining on events, as the query above does. The cost falls on consumers that instead collapse a missing report to a numeric zero, which is easy to write when the providers you integrate against first all report. That happened in a plugin I maintain: its usage columns are
NOT NULL DEFAULT 0, so every ACP row reads as a measured zero and share-style metrics return a confident 0% for ACP threads.That coercion is mine, not bb's. The general point is only that nothing in the event stream states "this turn produced no report", so correct handling depends on a consumer knowing to look for an absence.
Possible improvement
Optional capability metadata on the provider descriptor:
Absence should mean unknown rather than
true— existing and third-party descriptors omit the field, and reading those as capable would assert something unestablished.A capability rather than a per-turn marker because
dialect.tsalready lets a vendor stamp_metaside channels andacpUsageUpdateSchemais.passthrough(), so an agent could carry token data without a protocol change. A dialect that does surface tokens setstrue; the generic ACP dialect setsfalse. That way the signal improves as vendors improve instead of hard-coding a limitation, and a consumer decides once per provider.Its limit, stated plainly: a capability describes what a provider can do, not whether a given turn was measured. If those diverge within a provider it can't express that — I have no evidence they do.
Tests it would need: descriptor parse/serialize round-trip with the field present and absent, and a legacy descriptor with no field staying unknown rather than coerced.