mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
Merge pull request #623 from JDProfresh/fix/prompt-cache-report-bounds
fix(telemetry): gate the prompt cache like the model report, and keep the first 20 miss causes Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
48637e9920
3 files changed
+48
-6
No files matched your search
@@ -51,12 +51,17 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session
|
|||||||
|
|
||||||
const payload = data as RawStatuslinePayload | undefined;
|
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
|
// 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
|
// 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
|
// broadcasts only a change.
|
||||||
// report one here.
|
if (session && reportsStatusLine) {
|
||||||
const session = ctx.sessions.get(sessionId);
|
|
||||||
if (session && getCli(session.mode)?.capabilities.statusLineTelemetry) {
|
|
||||||
session.noteReportedModel('statusline', payload?.model?.display_name);
|
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
|
// 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
|
// 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.
|
// 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)) {
|
if (promptCache && session?.setPromptCache(promptCache)) {
|
||||||
ctx.broadcast(SessionPromptCache, { sessionId, ...session.promptCache });
|
ctx.broadcast(SessionPromptCache, { sessionId, ...session.promptCache });
|
||||||
// Persisted on change, like a model change, so a crash restores the latest report.
|
// Persisted on change, like a model change, so a crash restores the latest report.
|
||||||
|
|||||||
+10
-1
@@ -707,7 +707,16 @@ export const StatusTelemetrySchema = z.object({
|
|||||||
miss_recache_tokens: z.number().nullish(),
|
miss_recache_tokens: z.number().nullish(),
|
||||||
last_miss_at: z.number().nullish(),
|
last_miss_at: z.number().nullish(),
|
||||||
recache_tokens_if_cold: 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(),
|
.nullish(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -154,6 +154,20 @@ describe('POST /api/status-telemetry', () => {
|
|||||||
expect(events.filter((e) => e === SessionPromptCache)).toHaveLength(1);
|
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 () => {
|
it('rejects a malformed body (missing sessionId) with 400', async () => {
|
||||||
const res = await post({ data: REAL });
|
const res = await post({ data: REAL });
|
||||||
expect(res.statusCode).toBe(400);
|
expect(res.statusCode).toBe(400);
|
||||||
@@ -212,6 +226,20 @@ describe('POST /api/status-telemetry: the session model (displayModel)', () => {
|
|||||||
expect(session.toState().displayModel).toBeUndefined();
|
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 () => {
|
it('broadcasts the STORED prompt-cache object, so a compaction stamp reaches the live readout', async () => {
|
||||||
const session = realSession('claude', 'claude-compact');
|
const session = realSession('claude', 'claude-compact');
|
||||||
const expiresAtS = Math.floor(Date.now() / 1000) + 3600;
|
const expiresAtS = Math.floor(Date.now() / 1000) + 3600;
|
||||||
|
|||||||
Reference in new issue
Block a user