fix: date a working row by its turn, not by the session age

`TuiSessionRow` declared `lastSubmitAt`/`inputTokens`/`outputTokens`,
`stateSince()` ordered the WORKING group by the first of them and
`renderRowLines()` painted the other two, but nothing ever filled any of
them in: the unified list carries none, and the `session:updated` payload
that does was discarded (an event only schedules a refetch).

So a running turn was dated by its SESSION's creation instead. Measured
against the live server before the fix: w65 (created 21h ago, turn started
one minute earlier) outranked w67 (created 15 minutes ago, turn started
five minutes earlier), the reverse of the rule docs/tui.md states, and the
elapsed column read `21h` for a turn a minute old. The token column was
unreachable code for the same reason.

`fetchLiveSessionMetrics()` reads the three fields from `GET /api/sessions`
and `applyLiveMetrics()` folds them onto the rows. That route answers from
the server's cached LIGHT state (no terminal buffers): 10-20ms measured,
against the ~550ms the unified list in the same `Promise.all` already
costs, so it is cheap enough to ride every refresh. It is best-effort like
the approvals and tmux reads beside it, because losing the anchor is
better than losing the list.

A ZERO is treated as unknown rather than merged: `stateSince()` reads
`lastSubmitAt ?? createdAt` and 0 is not nullish, so a merged 0 would date
every never-submitted session to the epoch.

The snapshot path gets the same merge, or `codeman tui --list` would number
the WORKING group differently from the dashboard that `codeman tui <n>`
indexes into.

Verified live: working rows now show 28m/8m (turn age, tokens 280.5k/65.2k)
where they showed 21h/34m and no tokens. The e2e assertion fails on master's
wiring with `[*] 10m` against a session that pressed Enter one minute ago.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-17 15:59:17 +02:00
parent b555d2b7b3
commit 715b484fc6
5 changed files with 205 additions and 9 deletions
+63 -2
View File
@@ -11,6 +11,7 @@
*/
import { describe, it, expect } from 'vitest';
import {
applyLiveMetrics,
applyMuxNames,
buildListLines,
confirmAccepts,
@@ -27,9 +28,9 @@ import {
tmuxRowsToSessions,
tmuxSocketFromEnv,
} from '../../src/tui/tui-app.js';
import { createTuiModel } from '../../src/tui/tui-model.js';
import { createTuiModel, stateSince } from '../../src/tui/tui-model.js';
import { glyphsFor } from '../../src/tui/tui-render.js';
import type { TuiTmuxSession } from '../../src/tui/tui-client.js';
import type { TuiLiveSessionMetrics, TuiTmuxSession } from '../../src/tui/tui-client.js';
import type { TuiConfirmState, TuiRow, TuiSessionRow } from '../../src/tui/tui-types.js';
const GLYPHS = glyphsFor('unicode');
@@ -339,6 +340,66 @@ describe('applyMuxNames', () => {
});
});
describe('applyLiveMetrics', () => {
const metrics: TuiLiveSessionMetrics[] = [
{ sessionId: 'aaaa1111', lastSubmitAt: 5_000, inputTokens: 900, outputTokens: 100 },
{ sessionId: 'bbbb2222', lastSubmitAt: 0, inputTokens: 0, outputTokens: 0 },
];
it('folds the turn stamp and the token totals onto the matching row', () => {
const rows = applyLiveMetrics(
[
{ sessionId: 'aaaa1111', sources: ['live'] },
{ sessionId: 'cccc3333', sources: ['live'] },
],
metrics
);
expect(rows[0]).toMatchObject({ lastSubmitAt: 5_000, inputTokens: 900, outputTokens: 100 });
// No live counterpart (a history row): nothing to fold, nothing invented.
expect(rows[1].lastSubmitAt).toBeUndefined();
expect(rows[1].inputTokens).toBeUndefined();
});
it('treats a zero as unknown, so a never-submitted session is not dated to the epoch', () => {
const [merged] = applyLiveMetrics([{ sessionId: 'bbbb2222', sources: ['live'], createdAt: 1_000 }], metrics);
expect(merged.lastSubmitAt).toBeUndefined();
expect(merged.inputTokens).toBeUndefined();
expect(stateSince('working', merged)).toBe(1_000);
});
it('copies rather than mutating its input, and survives an empty metrics list', () => {
const input: TuiSessionRow[] = [{ sessionId: 'aaaa1111', sources: ['live'] }];
const rows = applyLiveMetrics(input, []);
expect(rows[0]).not.toBe(input[0]);
expect(rows[0].lastSubmitAt).toBeUndefined();
expect(input[0].lastSubmitAt).toBeUndefined();
});
it('orders the WORKING group by when the turn started, not by session age', () => {
// The regression this merge exists for: `old` was created a day before
// `fresh` but started its turn a minute AFTER it, so `fresh` has been
// working longer and has to lead. Without the merge both fall back to
// createdAt and `old` wins.
const unified: TuiSessionRow[] = [
{ sessionId: 'old00000', name: 'old', sources: ['live'], isWorking: true, createdAt: 1_000 },
{ sessionId: 'fresh000', name: 'fresh', sources: ['live'], isWorking: true, createdAt: 500_000 },
];
const turns: TuiLiveSessionMetrics[] = [
{ sessionId: 'old00000', lastSubmitAt: 900_000 },
{ sessionId: 'fresh000', lastSubmitAt: 600_000 },
];
const before = createTuiModel();
before.replaceSessions(unified);
expect(before.rows().map((r) => r.session.name)).toEqual(['old', 'fresh']);
const after = createTuiModel();
after.replaceSessions(applyLiveMetrics(unified, turns));
expect(after.rows().map((r) => r.session.name)).toEqual(['fresh', 'old']);
expect(after.rows()[0].since).toBe(600_000);
});
});
describe('buildListLines', () => {
it('numbers rows in the dashboard order, so `tui <n>` and `tui --list` agree', () => {
const model = createTuiModel();
+28
View File
@@ -63,6 +63,17 @@ const defaultResponder: Responder = (req, res) => {
data: { sessions: [{ sessionId: 'abc', name: 'w1-codeman', sources: ['live'] }], total: 1 },
});
}
if (url === '/api/sessions' || url.startsWith('/api/sessions?')) {
// The light state, plus the two rows the narrowing has to discard.
return sendJson(res, 200, {
success: true,
data: [
{ id: 'abc', lastSubmitAt: 4000, inputTokens: 900, outputTokens: 100, status: 'busy' },
{ id: '', lastSubmitAt: 7000 },
{ id: 'zzz', lastSubmitAt: '5', inputTokens: null },
],
});
}
if (url.startsWith('/api/approvals')) {
return sendJson(res, 200, {
success: true,
@@ -318,6 +329,23 @@ describe('TuiClient remaining API surface', () => {
expect(recorded[0].url).toBe('/api/sessions/abc/terminal?tail=4096');
});
it('reads the turn stamp and token counters off the light session state', async () => {
const metrics = await client().fetchLiveSessionMetrics();
expect(recorded[0].url).toBe('/api/sessions');
// Narrowed to the three fields, keyed by id: a row with no usable id is
// dropped rather than folded in under an empty key, and a field of the
// wrong type is left absent rather than merged as a string.
expect(metrics).toEqual([
{ sessionId: 'abc', lastSubmitAt: 4000, inputTokens: 900, outputTokens: 100 },
{ sessionId: 'zzz' },
]);
});
it('reports an unreadable session list as empty rather than throwing', async () => {
responder = (_req, res) => sendJson(res, 200, { success: true, data: { sessions: 'not an array' } });
await expect(client().fetchLiveSessionMetrics()).resolves.toEqual([]);
});
it('starts sessions through quick-start', async () => {
const result = await client().quickStart({ caseName: 'x', mode: 'claude', parentSessionId: 'abc' });
expect(result.sessionId).toBe('new-1');
+25
View File
@@ -53,6 +53,13 @@ let sessions: UnifiedSessionItem[] = [];
let approvals: ApprovalItem[] = [];
/** Terminal buffers the preview pane polls, by session id. */
const terminals = new Map<string, string>();
/**
* The LIGHT session state (`GET /api/sessions`), which is where the turn stamp
* and the token counters come from: the unified list carries neither, so w2-beta
* is deliberately a session created 10 minutes ago whose turn started one minute
* ago. A row dated by the wrong one of those reads `10m` instead of `1m`.
*/
let liveState: Array<Record<string, unknown>> = [];
/** Everything the TUI posted, so a test can assert on the exact body. */
const answered: Array<{ id: string; body: Record<string, unknown> }> = [];
const inputs: Array<{ sessionId: string; body: Record<string, unknown> }> = [];
@@ -184,6 +191,10 @@ function resetSessions(): void {
lastActivityAt: NOW - 3_600_000,
},
];
liveState = [
{ id: BETA, status: 'busy', lastSubmitAt: NOW - 60_000, inputTokens: 42_000, outputTokens: 3_200 },
{ id: ALPHA, status: 'idle', lastSubmitAt: NOW - 60_000 },
];
}
let server: http.Server;
@@ -264,6 +275,9 @@ beforeAll(async () => {
return sendJson(res, { success: true, data: { version: '9.9.9', planUsage: PLAN_USAGE } });
}
if (url.startsWith('/api/sessions/unified')) return sendJson(res, { success: true, data: { sessions } });
if (url === '/api/sessions' || url.startsWith('/api/sessions?')) {
return sendJson(res, { success: true, data: liveState });
}
const previewFor = sessionRoute(url, 'terminal');
if (previewFor) {
@@ -424,6 +438,17 @@ describe('codeman tui (under a pty)', () => {
expect(index('RECENT')).toBeLessThan(index('w3-gamma'));
});
it('dates a working row by its turn, not by the session age', () => {
// w2-beta was created 10 minutes ago and pressed Enter one minute ago, and
// only `GET /api/sessions` knows the second number. `1m` proves the merge
// ran end to end; `10m` would mean the row fell back to createdAt.
const beta = rowFor(output, 'w2-beta');
expect(beta).toMatch(/\b1m\b/);
expect(beta).not.toMatch(/\b10m\b/);
// The token column rides the same read.
expect(beta).toContain('45.2k');
});
it('shows the header facts and only the keys that work', () => {
const lines = frameLines(output);
expect(lines[0]).toContain('codeman');