Files
Codeman/test/routes/session-routes-external-cli-last-response.test.ts
T
Codeman maintainer d7ad73bc9b fix(response-viewer): role on full-context blocks, divider ReDoS, pi mode (#326 follow-up)
Three post-merge fixes for the external-CLI response viewer:

- ?context=full blocks now carry role ('user' for prompts, 'assistant'
  for response/status/tool). The frontend's loadFullContext() renders
  via msg.role, so the roleless blocks lost the "You" badge and every
  turn rendered as the agent. kind/label/text are unchanged and the
  frontend needs no change.

- normalizeDividerStatusLine() dropped its backtracking regex
  (/^[─-]+\s*(.+?)\s*[─-]{3,}$/): the lazy middle went catastrophic on
  a long dash run without a 3-dash tail (measured 15.5s at 4,000 chars,
  minutes at 10,000), and pane text is agent-controlled with buffers up
  to 32MB. Replaced by a linear counter walk with the identical accept
  set and captured content, pinned char-for-char against the old regex
  by a brute-force corpus test plus a hostile-input regression test
  that fails by timeout with the RegExp version (same approach as the
  glob-matcher hardening in 68ae9a8).

- 'pi' joins EXTERNAL_CLI_MODES: pi sessions had the identical
  empty-viewer symptom the transcript branch exists to fix. The list
  stays a local duplicate of isExternalCliMode() (importing session.ts
  would drag node-pty into the pure module); a new exhaustive parity
  test asserts the two mode sets can no longer drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 02:25:43 +02:00

169 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @fileoverview Tests for the external-CLI branch of GET /api/sessions/:id/last-response.
*
* Uses app.inject() — no real HTTP ports needed.
* Port: N/A (app.inject doesn't open ports)
*
* OpenCode / Gemini / Antigravity / Pi render their own TUIs and never write a
* Claude transcript under ~/.claude/projects, so before this branch existed the
* handler fell through to the Claude scan, found nothing, and the response viewer
* was permanently empty for those modes. These tests pin:
* - the pane buffer is segmented and the LAST response is returned
* - ?context=full carries the parsed blocks, and the short form omits them
* - ?context=full blocks carry role — the frontend renders via msg.role, so a
* block without it lost the "You" badge on prompts
* - a pane that has produced no output reports hasContext: false rather than 404ing
* - Claude mode still takes the Claude path (regression guard)
*/
import { describe, it, expect, beforeEach } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, createMockSession, type MockRouteContext } from '../mocks/index.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
interface LocalHarness {
app: FastifyInstance;
ctx: MockRouteContext;
}
/** Mirror of the production uniform-envelope hook (server.ts), as in the sibling suites. */
async function createEnvelopeHarness(
registerFn: (app: FastifyInstance, ctx: MockRouteContext) => void
): Promise<LocalHarness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext();
registerFn(app, ctx);
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
if (!req.url.startsWith('/api')) return done(null, payload);
if (payload === null || typeof payload !== 'object') return done(null, payload);
if (Buffer.isBuffer(payload) || typeof (payload as { pipe?: unknown }).pipe === 'function') {
return done(null, payload);
}
const p = payload as { success?: unknown; errorCode?: unknown };
if (p.success === false) {
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
}
return done(null, payload);
}
if (p.success === true) return done(null, payload);
return done(null, { success: true, data: payload });
});
await app.ready();
return { app, ctx };
}
// A pane as one of these CLIs actually leaves it: banner, a `›` prompt line, a
// status divider, a tool-activity marker, then the assistant's prose.
const PANE = `
╭──────────────────────────────────────────────────────╮
│ >_ OpenCode │
│ directory: /workspace/project │
╰──────────────────────────────────────────────────────╯
› summarise the retry logic
model · project · main · Ready · Context 100% left
• Explored
└ Read src/retry.ts
The retry helper backs off exponentially and gives up after five attempts.
› now document it
model · project · main · Ready · Context 92% left
• Called write_file
Documented the helper in docs/retry.md, including the five-attempt ceiling.
`.trim();
describe('GET /api/sessions/:id/last-response — external CLI panes', () => {
let harness: LocalHarness;
let session: ReturnType<typeof createMockSession>;
beforeEach(async () => {
harness = await createEnvelopeHarness(registerSessionRoutes);
session = harness.ctx._session;
});
async function lastResponse(full = false) {
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${session.id}/last-response${full ? '?context=full' : ''}`,
});
return { res, body: JSON.parse(res.body) };
}
for (const mode of ['opencode', 'gemini', 'antigravity', 'pi'] as const) {
it(`returns the last assistant response from the ${mode} pane buffer`, async () => {
session.mode = mode;
session.terminalBuffer = PANE;
const { res, body } = await lastResponse();
expect(res.statusCode).toBe(200);
// The LAST response, not the first — the viewer shows the current turn.
expect(body.data.text).toContain('Documented the helper in docs/retry.md');
expect(body.data.text).not.toContain('backs off exponentially');
expect(body.data.hasContext).toBe(true);
// Short form stays short: blocks only travel under ?context=full.
expect(body.data.messages).toBeUndefined();
});
}
it('carries the parsed blocks under ?context=full', async () => {
session.mode = 'opencode';
session.terminalBuffer = PANE;
const { body } = await lastResponse(true);
const kinds = body.data.messages.map((block: { kind: string }) => block.kind);
expect(kinds).toContain('prompt');
expect(kinds).toContain('response');
expect(kinds).toContain('tool');
// Both user turns survive segmentation, so the viewer can show the exchange.
const prompts = body.data.messages.filter((b: { kind: string }) => b.kind === 'prompt');
expect(prompts).toHaveLength(2);
expect(prompts[1].text).toContain('now document it');
// loadFullContext() renders via msg.role — without it every block got the
// agent badge and the user's own prompts lost their "You" attribution.
for (const block of body.data.messages as Array<{ kind: string; role: string }>) {
expect(block.role).toBe(block.kind === 'prompt' ? 'user' : 'assistant');
}
});
it('reports hasContext false for a pane that has produced no output', async () => {
session.mode = 'gemini';
// Session created but nothing rendered yet. Any non-prompt line counts as
// prose to the parser, so the empty pane is the honest no-context case.
session.terminalBuffer = '';
const { res, body } = await lastResponse();
expect(res.statusCode).toBe(200);
expect(body.data.text).toBe('');
expect(body.data.hasContext).toBe(false);
});
it('leaves Claude mode on the Claude transcript path', async () => {
// Regression guard: a claude pane must NOT be segmented off its terminal
// buffer, or a real transcript would be shadowed by scraped pane text.
session.mode = 'claude';
session.terminalBuffer = PANE;
const { res, body } = await lastResponse();
expect(res.statusCode).toBe(200);
expect(body.data.text).not.toContain('Documented the helper');
});
});