From 4091378e154628fedec9d06e3baa865e9f8957cb Mon Sep 17 00:00:00 2001 From: JD Date: Tue, 13 Oct 2026 20:37:20 -0400 Subject: [PATCH 1/2] fix(telemetry): store the prompt cache only for CLIs whose statusline reports it The status-telemetry route gated the model report on capabilities.statusLineTelemetry but stored the prompt-cache report for any session mode, even though both ride the same statusline payload the exporter POSTs. Gate the prompt-cache store on the same capability so a CLI with no statusline exporter no longer carries prompt-cache state it never actually reported. --- src/web/routes/status-telemetry-routes.ts | 15 ++++++++++----- test/routes/status-telemetry-routes.test.ts | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/web/routes/status-telemetry-routes.ts b/src/web/routes/status-telemetry-routes.ts index a568ac74..a93cb4e1 100644 --- a/src/web/routes/status-telemetry-routes.ts +++ b/src/web/routes/status-telemetry-routes.ts @@ -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); } @@ -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. diff --git a/test/routes/status-telemetry-routes.test.ts b/test/routes/status-telemetry-routes.test.ts index 0741b17f..4fa3f049 100644 --- a/test/routes/status-telemetry-routes.test.ts +++ b/test/routes/status-telemetry-routes.test.ts @@ -212,6 +212,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; From 8bf581cc55343b21038fdc42f64388b76e314a83 Mon Sep 17 00:00:00 2001 From: JD Date: Tue, 13 Oct 2026 20:37:32 -0400 Subject: [PATCH 2/2] fix(telemetry): keep the first 20 miss causes instead of refusing the post last_miss_cause.causes was z.array(z.unknown()).max(20), so a statusline payload carrying more than 20 causes failed validation and lost the whole post: plan usage, model, cache and the footer with it. Truncate to the first 20 with a preprocess that slices before the array validates, so an over-long array costs one bounded slice rather than per-element work across every entry. --- src/web/schemas.ts | 11 ++++++++++- test/routes/status-telemetry-routes.test.ts | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/web/schemas.ts b/src/web/schemas.ts index f40a2a16..6a23a304 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -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(), }) diff --git a/test/routes/status-telemetry-routes.test.ts b/test/routes/status-telemetry-routes.test.ts index 4fa3f049..faa96ebd 100644 --- a/test/routes/status-telemetry-routes.test.ts +++ b/test/routes/status-telemetry-routes.test.ts @@ -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);