mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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>
169 lines
6.7 KiB
TypeScript
169 lines
6.7 KiB
TypeScript
/**
|
||
* @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');
|
||
});
|
||
});
|