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/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 0741b17f..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); @@ -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;