mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 08:59:40 +02:00
A session header can only name the model a session runs if the server knows
it, so SessionState gains `displayModel: { model, source }`, resolved in a pure
module (src/session-display-model.ts), strongest first:
- custom-endpoint: a Custom Model Endpoint Profile's modelId answers the
session, whatever alias the CLI prints;
- statusline / screen: the newest report from the running CLI itself.
Claude's statusLine exporter already posts model.display_name on every
render; the status-telemetry route now records it (only for a CLI with
capabilities.statusLineTelemetry). A CLI whose registry entry declares the
new capabilities.modelDetect has its footer read off the pane capture the
idle/working probe already takes (no extra tmux call), so an in-session
/model switch is followed at the next transition;
- launch: the model the session was launched with (claude's --model or the
app-wide default, another CLI's <cli>Config.model), read where the registry
says the model param lives;
- nothing known: no field, never a placeholder.
modelDetect is registry data, measured on live panes: dsh-TUI's status line
on the row under its composer (qwen3.8-27b on the owner's route) and codex's
`<model> <effort> ·` footer on its last row. Both anchor on chrome only that
CLI draws, over the last rows of the screen only; a transcript line shaped like
the footer is never taken (fixture tests). The pattern goes through
compileVersionRegex() with exactly one capture group, checked at load time.
An unreadable or covered footer keeps the last model (unlike the watching
label: a model does not stop running when something covers its row). Model
text is untrusted: escape sequences and control characters are stripped and it
is capped at 64 characters. A change emits displayModelChanged, broadcast
(session:updated) and persisted; a restart restores a CLI-reported model until
the next report.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
158 lines
7.0 KiB
TypeScript
158 lines
7.0 KiB
TypeScript
/**
|
|
* Route tests for POST /api/status-telemetry — the Claude statusline exporter
|
|
* endpoint that feeds the header "Plan Usage Limits" chip.
|
|
*
|
|
* Covers: broadcast on real telemetry + footer print-through, unknown-session
|
|
* skip, per-session change-detection (dedup), rebroadcast on a displayed change,
|
|
* NO rebroadcast on context-only drift, null-tolerance of Claude's undocumented
|
|
* fields (the .nullish() schema — the project's recurring .optional()/null trap),
|
|
* and 400 on a malformed body. Also the session's model (`displayModel`) the route
|
|
* records off the same payload.
|
|
*/
|
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
|
import { registerStatusTelemetryRoutes } from '../../src/web/routes/status-telemetry-routes.js';
|
|
import { SessionStatusTelemetry } from '../../src/web/sse-events.js';
|
|
import { Session } from '../../src/session.js';
|
|
|
|
const SID = 'test-session-1'; // default id created by createMockRouteContext
|
|
|
|
const REAL = {
|
|
rate_limits: {
|
|
five_hour: { used_percentage: 15, resets_at: 1781409000 },
|
|
seven_day: { used_percentage: 34, resets_at: 1781827200 },
|
|
},
|
|
context_window: { used_percentage: 56, total_input_tokens: 562411, total_output_tokens: 1188 },
|
|
cost: { total_cost_usd: 0.0415 },
|
|
model: { display_name: 'Opus 4.8 (1M context)' },
|
|
};
|
|
|
|
describe('POST /api/status-telemetry', () => {
|
|
let h: RouteTestHarness;
|
|
|
|
beforeEach(async () => {
|
|
h = await createRouteTestHarness(registerStatusTelemetryRoutes, { sessionId: SID });
|
|
});
|
|
|
|
const post = (body: unknown) => h.app.inject({ method: 'POST', url: '/api/status-telemetry', payload: body });
|
|
|
|
it('broadcasts plan-usage telemetry and returns the session-status footer', async () => {
|
|
const res = await post({ sessionId: SID, data: REAL });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.headers['content-type']).toContain('text/plain');
|
|
expect(res.body).toBe('Opus 4.8 (1M context) in:562,411 out:1,188 ctx:56%');
|
|
expect(h.ctx.broadcast).toHaveBeenCalledTimes(1);
|
|
const [event, payload] = h.ctx.broadcast.mock.calls[0];
|
|
expect(event).toBe(SessionStatusTelemetry);
|
|
expect(payload).toMatchObject({
|
|
sessionId: SID,
|
|
fiveHour: { usedPercentage: 15 },
|
|
sevenDay: { usedPercentage: 34 },
|
|
});
|
|
});
|
|
|
|
it('does not broadcast for an unknown session; returns an EMPTY footer, never a brand word', async () => {
|
|
const res = await post({ sessionId: 'does-not-exist', data: REAL });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.body).toBe('');
|
|
expect(h.ctx.broadcast).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('dedups identical telemetry — rebroadcasts only once', async () => {
|
|
await post({ sessionId: SID, data: REAL });
|
|
await post({ sessionId: SID, data: REAL });
|
|
expect(h.ctx.broadcast).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('rebroadcasts when a displayed window percentage changes', async () => {
|
|
await post({ sessionId: SID, data: REAL });
|
|
const moved = {
|
|
...REAL,
|
|
rate_limits: { ...REAL.rate_limits, five_hour: { used_percentage: 16, resets_at: 1781409000 } },
|
|
};
|
|
await post({ sessionId: SID, data: moved });
|
|
expect(h.ctx.broadcast).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('does NOT rebroadcast on context-only drift (the chip never shows context %)', async () => {
|
|
await post({ sessionId: SID, data: REAL });
|
|
await post({ sessionId: SID, data: { ...REAL, context_window: { ...REAL.context_window, used_percentage: 91 } } });
|
|
expect(h.ctx.broadcast).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("tolerates null in Claude's undocumented fields (no 400) and ignores them", async () => {
|
|
const res = await post({
|
|
sessionId: SID,
|
|
data: {
|
|
rate_limits: { five_hour: { used_percentage: 20, resets_at: 1781409000 }, seven_day: null },
|
|
cost: { total_cost_usd: null },
|
|
model: { display_name: null },
|
|
context_window: { used_percentage: null, total_input_tokens: null, total_output_tokens: null },
|
|
},
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(h.ctx.broadcast).toHaveBeenCalledTimes(1);
|
|
const [, payload] = h.ctx.broadcast.mock.calls[0];
|
|
expect(payload).toMatchObject({ fiveHour: { usedPercentage: 20 } });
|
|
expect(payload.sevenDay).toBeUndefined();
|
|
});
|
|
|
|
it('rejects a malformed body (missing sessionId) with 400', async () => {
|
|
const res = await post({ data: REAL });
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
});
|
|
|
|
describe('POST /api/status-telemetry: the session model (displayModel)', () => {
|
|
let h: RouteTestHarness;
|
|
|
|
beforeEach(async () => {
|
|
h = await createRouteTestHarness(registerStatusTelemetryRoutes, { sessionId: SID });
|
|
});
|
|
|
|
const post = (body: unknown) => h.app.inject({ method: 'POST', url: '/api/status-telemetry', payload: body });
|
|
|
|
/** A real claude session in the route's map, so displayModel is the shipped resolver's. */
|
|
function realSession(mode: 'claude' | 'codex', id: string): Session {
|
|
const session = new Session({ id, workingDir: '/tmp', mode } as ConstructorParameters<typeof Session>[0]);
|
|
(h.ctx.sessions as unknown as Map<string, unknown>).set(id, session);
|
|
return session;
|
|
}
|
|
|
|
it('records the model the statusline names, from the first render (no rate limits yet)', async () => {
|
|
const session = realSession('claude', 'claude-1');
|
|
const changed = vi.fn();
|
|
session.on('displayModelChanged', changed);
|
|
const res = await post({ sessionId: 'claude-1', data: { model: { display_name: 'Haiku 4.5' } } });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(session.toState().displayModel).toEqual({ model: 'Haiku 4.5', source: 'statusline' });
|
|
expect(changed).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('follows a model switch, and says nothing for an unchanged one', async () => {
|
|
const session = realSession('claude', 'claude-2');
|
|
const changed = vi.fn();
|
|
session.on('displayModelChanged', changed);
|
|
await post({ sessionId: 'claude-2', data: REAL });
|
|
await post({ sessionId: 'claude-2', data: REAL });
|
|
expect(changed).toHaveBeenCalledTimes(1);
|
|
await post({ sessionId: 'claude-2', data: { ...REAL, model: { display_name: 'Sonnet 4.6' } } });
|
|
expect(session.toState().displayModel).toEqual({ model: 'Sonnet 4.6', source: 'statusline' });
|
|
expect(changed).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('keeps a model name with markup as text, and a null name changes nothing', async () => {
|
|
const session = realSession('claude', 'claude-3');
|
|
await post({ sessionId: 'claude-3', data: { model: { display_name: '<b>Opus</b>' } } });
|
|
expect(session.toState().displayModel?.model).toBe('<b>Opus</b>');
|
|
await post({ sessionId: 'claude-3', data: { model: { display_name: null } } });
|
|
expect(session.toState().displayModel?.model).toBe('<b>Opus</b>');
|
|
});
|
|
|
|
it('takes no model from a CLI that has no statusline exporter', async () => {
|
|
const session = realSession('codex', 'codex-1');
|
|
await post({ sessionId: 'codex-1', data: { model: { display_name: 'Opus 4.8' } } });
|
|
expect(session.toState().displayModel).toBeUndefined();
|
|
});
|
|
});
|