Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions src/web/routes/status-telemetry-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,17 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session

const payload = data as RawStatuslinePayload | undefined;

// Only a CLI that has a statusline exporter at all speaks for its own model and
// prompt-cache state: both ride the SAME statusline payload the exporter POSTs, so
// both are gated on capabilities.statusLineTelemetry (the gate that also decides
// whether the exporter is injected at all, in tmux-manager._resolveStatusLine).
const session = ctx.sessions.get(sessionId);
const reportsStatusLine = !!session && !!getCli(session.mode)?.capabilities.statusLineTelemetry;

// The model the CLI says it is running, for the session headers (displayModel). It
// rides every render, so it follows an in-session `/model`; the session dedupes and
// broadcasts only a change. Only a CLI that has a statusline exporter at all may
// report one here.
const session = ctx.sessions.get(sessionId);
if (session && getCli(session.mode)?.capabilities.statusLineTelemetry) {
// broadcasts only a change.
if (session && reportsStatusLine) {
session.noteReportedModel('statusline', payload?.model?.display_name);
}

Expand All @@ -68,7 +73,7 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
// it backgrounds this POST (hooks-config.ts), so two sub-second loopback reports can
// arrive out of order; a stale one then briefly overwrites newer state until the next
// render corrects it. Accepted for a display-only readout rather than ordered here.
const promptCache = parsePromptCache(payload);
const promptCache = reportsStatusLine ? parsePromptCache(payload) : null;
if (promptCache && session?.setPromptCache(promptCache)) {
ctx.broadcast(SessionPromptCache, { sessionId, ...session.promptCache });
// Persisted on change, like a model change, so a crash restores the latest report.
Expand Down
11 changes: 10 additions & 1 deletion src/web/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -707,7 +707,16 @@ export const StatusTelemetrySchema = z.object({
miss_recache_tokens: z.number().nullish(),
last_miss_at: z.number().nullish(),
recache_tokens_if_cold: z.number().nullish(),
last_miss_cause: z.object({ causes: z.array(z.unknown()).max(20).nullish() }).nullish(),
// Keep the first 20 causes instead of 400-ing the whole post (which also drops the
// footer, the plan chip and the model report it carries) when Claude Code reports more. The
// preprocess slices BEFORE the array validates, so an over-long array costs one bounded
// slice, never per-element validation across every entry (parsePromptCache then keeps
// the string ones among those 20).
last_miss_cause: z
.object({
causes: z.preprocess((v) => (Array.isArray(v) ? v.slice(0, 20) : v), z.array(z.unknown()).nullish()),
})
.nullish(),
})
.nullish(),
})
Expand Down
28 changes: 28 additions & 0 deletions test/routes/status-telemetry-routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,20 @@ describe('POST /api/status-telemetry', () => {
expect(events.filter((e) => e === SessionPromptCache)).toHaveLength(1);
});

it('keeps the first 20 miss causes instead of refusing a post that carries more', async () => {
const causes = Array.from({ length: 25 }, (_, i) => `cause_${i}`);
const res = await post({
sessionId: SID,
data: { ...REAL, prompt_cache: { warm: false, ttl: '1h', last_miss_cause: { causes } } },
});
// Accepted, not 400: an over-long causes array must not blank the whole statusline.
expect(res.statusCode).toBe(200);
const session = h.ctx.sessions.get(SID) as unknown as { promptCache: { lastMissCauses?: string[] } | null };
expect(session.promptCache?.lastMissCauses).toHaveLength(20);
expect(session.promptCache?.lastMissCauses?.[0]).toBe('cause_0');
expect(session.promptCache?.lastMissCauses?.[19]).toBe('cause_19');
});

it('rejects a malformed body (missing sessionId) with 400', async () => {
const res = await post({ data: REAL });
expect(res.statusCode).toBe(400);
Expand Down Expand Up @@ -212,6 +226,20 @@ describe('POST /api/status-telemetry: the session model (displayModel)', () => {
expect(session.toState().displayModel).toBeUndefined();
});

it('stores no prompt-cache from a CLI with no statusline exporter, but still broadcasts plan usage', async () => {
const session = realSession('codex', 'codex-cache');
const res = await post({
sessionId: 'codex-cache',
data: { ...REAL, prompt_cache: { warm: true, ttl: '1h', expires_at: Math.floor(Date.now() / 1000) + 3600 } },
});
expect(res.statusCode).toBe(200);
// Prompt cache is gated like the model report; the rest of the post still lands.
expect(session.toState().promptCache).toBeUndefined();
const events = h.ctx.broadcast.mock.calls.map(([name]) => name);
expect(events).not.toContain(SessionPromptCache);
expect(events).toContain(SessionStatusTelemetry);
});

it('broadcasts the STORED prompt-cache object, so a compaction stamp reaches the live readout', async () => {
const session = realSession('claude', 'claude-compact');
const expiresAtS = Math.floor(Date.now() / 1000) + 3600;
Expand Down
Loading