mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
fix(response-viewer): populate the viewer for OpenCode/Gemini/Antigravity panes
`GET /api/sessions/:id/last-response` branches to a Codex-specific reader, then falls through to scanning `~/.claude/projects` for a transcript. OpenCode, Gemini and Antigravity render their own TUIs and never write one, so that scan finds nothing and the response viewer is permanently empty for all three modes. For these CLIs the pane IS the transcript, so segment it. `response-viewer-transcript.ts` is a pure, dependency-free parser that splits a terminal buffer into prompt / response / status / tool blocks, keying off the `›` prompt marker, status dividers and `• Calling|Called` tool-activity lines. The route uses it to answer with the LAST response, and to carry the parsed blocks under `?context=full`. Codex keeps its existing branch: it has real rollout files, which are a better source than scraped pane text. The response shape is unchanged for every other mode, and Claude panes are explicitly pinned to the Claude transcript path so a real transcript can never be shadowed by scraped text. Tests: 14 parser cases plus a route suite covering all three modes, the `?context=full` payload, an empty pane, and the Claude regression guard.
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
export type ResponseViewerTranscriptKind = 'prompt' | 'response' | 'status' | 'tool';
|
||||
|
||||
export interface ResponseViewerTranscriptBlock {
|
||||
kind: ResponseViewerTranscriptKind;
|
||||
label: 'Prompt' | 'Response' | 'Status' | 'Tool';
|
||||
text: string;
|
||||
}
|
||||
|
||||
const EXTERNAL_CLI_MODES = new Set(['codex', 'gemini', 'opencode', 'antigravity']);
|
||||
|
||||
function isPromptLine(line: string): boolean {
|
||||
return /^\s*›\s*/.test(line);
|
||||
}
|
||||
|
||||
function normalizeDividerStatusLine(line: string): string | null {
|
||||
const trimmed = line.trim();
|
||||
const matched = trimmed.match(/^[─-]+\s*(.+?)\s*[─-]{3,}$/);
|
||||
if (!matched) return null;
|
||||
return matched[1]?.trim() || null;
|
||||
}
|
||||
|
||||
function isDividerOnlyLine(line: string): boolean {
|
||||
return /^[\s─-]{8,}$/.test(line.trim());
|
||||
}
|
||||
|
||||
// COD-227: unambiguous Codex tool-call markers. These only ever appear as internal
|
||||
// activity, never as ordinary assistant prose, so they are always a Tool block.
|
||||
function isToolActivityMarker(line: string): boolean {
|
||||
return /^\s*[•*-]\s+(Calling|Called)\b/.test(line.trim());
|
||||
}
|
||||
|
||||
// COD-227: action verbs that ALSO occur in ordinary assistant prose (e.g.
|
||||
// "• Created COD-226: …"). These are a Tool header only when corroborated by a
|
||||
// box-drawing result tree on the next non-blank line (see the caller); the verb
|
||||
// alone is not sufficient.
|
||||
function isToolVerbBullet(line: string): boolean {
|
||||
return /^\s*[•*-]\s+(Explored|Viewed|Read|Edited|Updated|Created|Deleted|Ran|Searched|Opened|Listed|Found|Applied|Patched|Used|Wrote|Executed|Modified|Analyzed|Compared|Fetched|Installed)\b/.test(
|
||||
line.trim()
|
||||
);
|
||||
}
|
||||
|
||||
// A genuine Codex tool block renders its result as a box-drawing tree (└ │ ├).
|
||||
function isBoxDrawingLine(line: string): boolean {
|
||||
return /^[│├└]/.test(line.trim());
|
||||
}
|
||||
|
||||
// Look past blank lines from `fromIndex + 1` for the next non-blank line and report
|
||||
// whether it is a box-drawing tool-result line — the signal that a verb bullet is a
|
||||
// real tool block rather than assistant prose that happens to start with a verb.
|
||||
function nextNonBlankIsBoxDrawing(lines: string[], fromIndex: number): boolean {
|
||||
for (let j = fromIndex + 1; j < lines.length; j += 1) {
|
||||
const trimmed = (lines[j] || '').trim();
|
||||
if (!trimmed) continue;
|
||||
return isBoxDrawingLine(trimmed);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isToolContinuationLine(line: string, currentKind: ResponseViewerTranscriptKind | null): boolean {
|
||||
if (currentKind !== 'tool') return false;
|
||||
const trimmed = line.trimEnd();
|
||||
if (!trimmed) return true;
|
||||
return /^\s*[│├└]/.test(trimmed) || /^\s{2,}\S/.test(line);
|
||||
}
|
||||
|
||||
function isStatusLine(line: string, mode: string): boolean {
|
||||
if (!EXTERNAL_CLI_MODES.has(mode)) return false;
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return false;
|
||||
if (normalizeDividerStatusLine(trimmed)) return true;
|
||||
if (/^(model|directory):\s+/i.test(trimmed)) return true;
|
||||
if (/\bContext\b.*\bleft\b/i.test(trimmed)) return true;
|
||||
if (/\b\/model to change\b/i.test(trimmed)) return true;
|
||||
if (/\bReady\b/i.test(trimmed) && /·/.test(trimmed)) return true;
|
||||
if (/^(gpt|o\d|claude|gemini)\b/i.test(trimmed) && /·/.test(trimmed)) return true;
|
||||
if (/^Tip:/i.test(trimmed)) return true;
|
||||
if (/^\s*[•*-]\s+(Working|Thinking|Loading|Starting\b|Waiting\b)/i.test(trimmed)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function isStandaloneMarkdownLine(line: string): boolean {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return false;
|
||||
if (/^#{1,6}\s/.test(trimmed)) return true;
|
||||
if (/^>\s/.test(trimmed)) return true;
|
||||
if (/^(```|~~~)/.test(trimmed)) return true;
|
||||
if (/^[-*+]\s/.test(trimmed)) return true;
|
||||
if (/^\d+[.)]\s/.test(trimmed)) return true;
|
||||
if (/^\|/.test(trimmed)) return true;
|
||||
if (/^\s{4,}\S/.test(line)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldJoinWrappedLine(previous: string, next: string): boolean {
|
||||
const prev = previous.trimEnd();
|
||||
const curr = next.trim();
|
||||
if (!prev || !curr) return false;
|
||||
if (/[.!?]$/.test(prev)) return false;
|
||||
if (/[:;]$/.test(prev)) return false;
|
||||
if (isStandaloneMarkdownLine(curr)) return false;
|
||||
if (/^[a-z(]/.test(curr)) return true;
|
||||
if (prev.length >= 72 && /^[A-Za-z0-9"'(]/.test(curr)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeWrappedText(lines: string[]): string {
|
||||
const out: string[] = [];
|
||||
let paragraph = '';
|
||||
|
||||
const flushParagraph = () => {
|
||||
if (!paragraph) return;
|
||||
out.push(paragraph);
|
||||
paragraph = '';
|
||||
};
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trimEnd();
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
flushParagraph();
|
||||
if (out[out.length - 1] !== '') out.push('');
|
||||
continue;
|
||||
}
|
||||
if (isStandaloneMarkdownLine(line)) {
|
||||
flushParagraph();
|
||||
out.push(trimmed);
|
||||
continue;
|
||||
}
|
||||
if (!paragraph) {
|
||||
paragraph = trimmed;
|
||||
continue;
|
||||
}
|
||||
if (shouldJoinWrappedLine(paragraph, trimmed)) {
|
||||
paragraph += ` ${trimmed}`;
|
||||
continue;
|
||||
}
|
||||
flushParagraph();
|
||||
paragraph = trimmed;
|
||||
}
|
||||
|
||||
flushParagraph();
|
||||
return out
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function cleanTerminalTranscript(buffer: string): string {
|
||||
// Stripping ANSI/OSC/DCS escape sequences and stray C0/C1 control bytes
|
||||
// legitimately requires control characters in these patterns.
|
||||
/* eslint-disable no-control-regex */
|
||||
return String(buffer || '')
|
||||
.replace(/\x1b\[[\x30-\x3F]*[\x20-\x2F]*[\x40-\x7E]/g, '')
|
||||
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
|
||||
.replace(/\x1b[PX^_][^\x1b]*\x1b\\/g, '')
|
||||
.replace(/\x1b[NO()][A-Z0-9]?/g, '')
|
||||
.replace(/\x1b[>=<78cDEHM]/g, '')
|
||||
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n')
|
||||
.replace(/[ \t]+$/gm, '')
|
||||
.trim();
|
||||
/* eslint-enable no-control-regex */
|
||||
}
|
||||
|
||||
function trimLeadingStartup(lines: string[]): string[] {
|
||||
let index = 0;
|
||||
while (index < lines.length) {
|
||||
const line = lines[index] || '';
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (/^[╭╰│─].*[╮╯│]?$/.test(trimmed)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (/^>_\s*OpenAI Codex/i.test(trimmed)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (/^(model|directory):\s+/i.test(trimmed)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return lines.slice(index);
|
||||
}
|
||||
|
||||
function pushBlock(
|
||||
blocks: ResponseViewerTranscriptBlock[],
|
||||
kind: ResponseViewerTranscriptKind | null,
|
||||
lines: string[]
|
||||
): void {
|
||||
if (!kind || lines.length === 0) return;
|
||||
const normalizedLines =
|
||||
kind === 'status'
|
||||
? lines.map((line) => normalizeDividerStatusLine(line) || line.trim()).filter((line) => line.length > 0)
|
||||
: lines;
|
||||
const text =
|
||||
kind === 'tool' || kind === 'status'
|
||||
? normalizedLines
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
: normalizeWrappedText(normalizedLines);
|
||||
if (!text) return;
|
||||
const label = (kind.charAt(0).toUpperCase() + kind.slice(1)) as ResponseViewerTranscriptBlock['label'];
|
||||
blocks.push({ kind, label, text });
|
||||
}
|
||||
|
||||
export function isExternalCliTranscriptMode(mode: string | null | undefined): boolean {
|
||||
return EXTERNAL_CLI_MODES.has(String(mode || ''));
|
||||
}
|
||||
|
||||
export function parseExternalCliTranscript(
|
||||
buffer: string,
|
||||
mode: string | null | undefined
|
||||
): ResponseViewerTranscriptBlock[] {
|
||||
const resolvedMode = String(mode || '');
|
||||
if (!isExternalCliTranscriptMode(resolvedMode)) return [];
|
||||
|
||||
const cleaned = cleanTerminalTranscript(buffer);
|
||||
if (!cleaned) return [];
|
||||
|
||||
const lines = trimLeadingStartup(cleaned.split('\n'));
|
||||
const blocks: ResponseViewerTranscriptBlock[] = [];
|
||||
let currentKind: ResponseViewerTranscriptKind | null = null;
|
||||
let currentLines: string[] = [];
|
||||
|
||||
const flush = () => {
|
||||
pushBlock(blocks, currentKind, currentLines);
|
||||
currentKind = null;
|
||||
currentLines = [];
|
||||
};
|
||||
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i] ?? '';
|
||||
// COD-226: within a prompt, the 2-space Codex gutter is authoritative. Blank
|
||||
// lines and gutter-indented (2+ leading spaces) continuation lines stay in the
|
||||
// Prompt block ahead of every structural detector below, so multiline prompts —
|
||||
// including bullets, indented dividers, and literal › lines — are not
|
||||
// misclassified as responses. Only a non-blank, non-gutter line ends the prompt
|
||||
// and falls through (a column-0 › then opens a NEW prompt).
|
||||
if (currentKind === 'prompt') {
|
||||
if (!line.trim()) {
|
||||
currentLines.push('');
|
||||
continue;
|
||||
}
|
||||
if (/^ {2,}\S/.test(line)) {
|
||||
currentLines.push(line);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (isDividerOnlyLine(line)) {
|
||||
flush();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isPromptLine(line)) {
|
||||
flush();
|
||||
currentKind = 'prompt';
|
||||
currentLines = [line.replace(/^\s*›\s*/, '').trim()];
|
||||
continue;
|
||||
}
|
||||
|
||||
// COD-227: • Calling / • Called are always tool markers; the other action verbs
|
||||
// are a tool header only when a box-drawing result tree follows on the next
|
||||
// non-blank line — otherwise the verb bullet is ordinary assistant prose.
|
||||
if (isToolActivityMarker(line) || (isToolVerbBullet(line) && nextNonBlankIsBoxDrawing(lines, i))) {
|
||||
if (currentKind !== 'tool') flush();
|
||||
currentKind = 'tool';
|
||||
currentLines.push(line.trimEnd());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isToolContinuationLine(line, currentKind)) {
|
||||
currentLines.push(line.trimEnd());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isStatusLine(line, resolvedMode)) {
|
||||
if (currentKind !== 'status') flush();
|
||||
currentKind = 'status';
|
||||
currentLines.push(line.trim());
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.trim()) {
|
||||
currentLines.push('');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentKind !== 'response') flush();
|
||||
currentKind = 'response';
|
||||
currentLines.push(line);
|
||||
}
|
||||
|
||||
flush();
|
||||
return blocks.filter((block) => block.text.trim().length > 0);
|
||||
}
|
||||
|
||||
export function getLastTranscriptResponse(blocks: ResponseViewerTranscriptBlock[]): string {
|
||||
for (let i = blocks.length - 1; i >= 0; i -= 1) {
|
||||
if (blocks[i]?.kind === 'response') return blocks[i].text;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
@@ -131,6 +131,11 @@ import {
|
||||
toSessionDocker,
|
||||
} from '../../docker-hosts.js';
|
||||
import { LRUMap } from '../../utils/lru-map.js';
|
||||
import {
|
||||
getLastTranscriptResponse,
|
||||
isExternalCliTranscriptMode,
|
||||
parseExternalCliTranscript,
|
||||
} from '../response-viewer-transcript.js';
|
||||
|
||||
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
|
||||
const LINKED_CASES_FILE = dataPath('linked-cases.json');
|
||||
@@ -1898,6 +1903,22 @@ export function registerSessionRoutes(
|
||||
return await readCodexLastResponse(session, codexQuery.context === 'full');
|
||||
}
|
||||
|
||||
// OpenCode / Gemini / Antigravity render their own TUIs and write no Claude
|
||||
// transcript, so the scan below finds nothing and the response viewer renders
|
||||
// permanently empty for them. Segment the terminal buffer instead — the pane
|
||||
// IS the transcript for these CLIs. Codex is already handled above, where a
|
||||
// real rollout file is the better source.
|
||||
if (isExternalCliTranscriptMode(session.mode)) {
|
||||
const externalQuery = req.query as { context?: string };
|
||||
const blocks = parseExternalCliTranscript(session.terminalBuffer, session.mode);
|
||||
return {
|
||||
text: getLastTranscriptResponse(blocks),
|
||||
timestamp: '',
|
||||
hasContext: blocks.length > 0,
|
||||
messages: externalQuery.context === 'full' ? blocks : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Scan ~/.claude/projects/*/ for the transcript file
|
||||
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
|
||||
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getLastTranscriptResponse, parseExternalCliTranscript } from '../src/web/response-viewer-transcript.js';
|
||||
|
||||
describe('response viewer transcript parser', () => {
|
||||
it('extracts structured COD transcript blocks and keeps labeled status/tool entries', () => {
|
||||
const transcript = `
|
||||
╭──────────────────────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v0.143.0) │
|
||||
│ model: gpt-5.4 medium /model to change │
|
||||
│ directory: /mnt/c/Users/aakhter/.../kb │
|
||||
╰──────────────────────────────────────────────────────╯
|
||||
|
||||
Tip: Use /side to start a side conversation in a temporary fork without polluting the main thread.
|
||||
|
||||
› i need to create a naming recommendation for project helix
|
||||
|
||||
gpt-5.4 medium · kb · main · Ready · Context 100% left
|
||||
|
||||
• Explored
|
||||
└ Read SKILL.md
|
||||
|
||||
Subject: Naming Recommendation for Project Helix
|
||||
|
||||
The product helps customers move virtual machine workloads between hypervisors while keeping operations
|
||||
stable. It improves visibility into application dependencies, supports pre-migration validation, and reduces
|
||||
the risk
|
||||
and effort involved in moving workloads.
|
||||
|
||||
› Improve documentation in @filename
|
||||
|
||||
gpt-5.4 medium · kb · main · Ready · Context 92% left
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
|
||||
expect(blocks.map((block) => block.kind)).toEqual([
|
||||
'status',
|
||||
'prompt',
|
||||
'status',
|
||||
'tool',
|
||||
'response',
|
||||
'prompt',
|
||||
'status',
|
||||
]);
|
||||
expect(blocks[0]?.label).toBe('Status');
|
||||
expect(blocks[3]?.label).toBe('Tool');
|
||||
expect(blocks[4]?.text).toContain('reduces the risk and effort involved');
|
||||
expect(blocks[4]?.text).not.toContain('reduces\nthe risk');
|
||||
});
|
||||
|
||||
it('returns the most recent completed response when the last block is a new prompt', () => {
|
||||
const transcript = `
|
||||
› say again
|
||||
|
||||
Final polished answer
|
||||
With two lines
|
||||
|
||||
› Improve documentation in @filename
|
||||
|
||||
gpt-5.4 medium · kb · main · Ready · Context 92% left
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
|
||||
expect(getLastTranscriptResponse(blocks)).toBe('Final polished answer\nWith two lines');
|
||||
});
|
||||
|
||||
it('drops box-drawing dividers from the last response and treats worked-for lines as status', () => {
|
||||
const transcript = `
|
||||
────────────────────────────────────────────────────────────────────────
|
||||
|
||||
• The launcher supports CODEMAN_APP_DIR, so I can deploy this exact worktree without merging it back first.
|
||||
|
||||
────────────────────────────────────────────────────────────────────────
|
||||
|
||||
• Deployed.
|
||||
|
||||
The local Codeman service is now running from the worktree at app/.worktrees/cod-215-response-viewer on
|
||||
commit 8bbcf77bafbdbf01a34653386c256b685898ad06.
|
||||
|
||||
Verified:
|
||||
|
||||
- process pid: 2708947
|
||||
- HTTPS health: https://127.0.0.1:3000/ returned 401 as expected
|
||||
|
||||
One detail: I had to restart it outside the sandbox because the sandboxed launch path could not see tmux.
|
||||
|
||||
─ Worked for 1m 51s ───────────────────────────────────────────────────
|
||||
|
||||
› what are these lines in the middle column?
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
|
||||
expect(getLastTranscriptResponse(blocks)).toContain('• Deployed.');
|
||||
expect(getLastTranscriptResponse(blocks)).not.toContain('────────────────');
|
||||
expect(getLastTranscriptResponse(blocks)).not.toContain('Worked for 1m 51s');
|
||||
expect(blocks.some((block) => block.kind === 'status' && block.text === 'Worked for 1m 51s')).toBe(true);
|
||||
});
|
||||
|
||||
// COD-226: multiline prompt continuations (2-space Codex gutter) must stay in the
|
||||
// Prompt block, not be misclassified as Response. The gutter is authoritative ahead
|
||||
// of every structural detector (divider, prompt-marker, tool, status).
|
||||
describe('COD-226 multiline prompt gutter is authoritative', () => {
|
||||
it('keeps bullet/prose continuations and internal blank lines in the Prompt block', () => {
|
||||
const transcript = `
|
||||
› i think we can improve this slide. or maybe a follow on slide. here is what i'm thinking:
|
||||
* left hand side: current AI stack (frontier model, cloud hosted, sovereign concerns)
|
||||
right hand -> future enterprise stack: frontier model (optional, cloud), on-prem model router, OSS models
|
||||
|
||||
make the point that the right hand side addresses the concerns.
|
||||
|
||||
gpt-5.4 medium · kb · main · Ready · Context 100% left
|
||||
|
||||
• Explored
|
||||
└ Read SKILL.md
|
||||
|
||||
Here is the actual assistant answer that starts at column zero and is a real response.
|
||||
|
||||
› next prompt
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
const promptBlocks = blocks.filter((b) => b.kind === 'prompt');
|
||||
|
||||
// Exactly two prompts, and the first holds the whole multiline prompt.
|
||||
expect(promptBlocks).toHaveLength(2);
|
||||
expect(promptBlocks[0]?.text).toContain('left hand side');
|
||||
expect(promptBlocks[0]?.text).toContain('right hand');
|
||||
expect(promptBlocks[0]?.text).toContain('make the point that the right hand side');
|
||||
|
||||
// The continuation must NOT have leaked into a Response block.
|
||||
const responseBlocks = blocks.filter((b) => b.kind === 'response');
|
||||
expect(responseBlocks.some((b) => b.text.includes('make the point'))).toBe(false);
|
||||
expect(getLastTranscriptResponse(blocks)).toContain('actual assistant answer');
|
||||
expect(getLastTranscriptResponse(blocks)).not.toContain('make the point');
|
||||
});
|
||||
|
||||
it('does not let an indented divider inside a prompt flush the Prompt block', () => {
|
||||
const transcript = `
|
||||
› compare these two layouts
|
||||
first layout uses a single column
|
||||
────────────────────────────
|
||||
second layout uses two columns
|
||||
|
||||
The response begins here at column zero.
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
const promptBlocks = blocks.filter((b) => b.kind === 'prompt');
|
||||
|
||||
expect(promptBlocks).toHaveLength(1);
|
||||
expect(promptBlocks[0]?.text).toContain('first layout');
|
||||
expect(promptBlocks[0]?.text).toContain('second layout uses two columns');
|
||||
expect(getLastTranscriptResponse(blocks)).toBe('The response begins here at column zero.');
|
||||
});
|
||||
|
||||
it('treats a gutter-indented literal › as prompt content, not a new prompt', () => {
|
||||
const transcript = `
|
||||
› here is my question about the ui
|
||||
› should this arrow start a new prompt?
|
||||
no it should not — it is part of my question
|
||||
|
||||
Answer at column zero.
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
const promptBlocks = blocks.filter((b) => b.kind === 'prompt');
|
||||
|
||||
// The gutter-indented › must NOT open a second prompt.
|
||||
expect(promptBlocks).toHaveLength(1);
|
||||
expect(promptBlocks[0]?.text).toContain('here is my question');
|
||||
expect(promptBlocks[0]?.text).toContain('no it should not');
|
||||
expect(getLastTranscriptResponse(blocks)).toBe('Answer at column zero.');
|
||||
});
|
||||
|
||||
// The two exact live roadmap-tab examples from the ticket (AC: use both verbatim).
|
||||
it('live example 1: two-line prompt keeps the gutter continuation in the Prompt block', () => {
|
||||
const transcript = `
|
||||
› create uid for each feature so it's easy to ref.
|
||||
for the p200 - why is diffentiation only 2/5?
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
const promptBlocks = blocks.filter((b) => b.kind === 'prompt');
|
||||
|
||||
expect(promptBlocks).toHaveLength(1);
|
||||
expect(promptBlocks[0]?.text).toContain('create uid for each feature');
|
||||
expect(promptBlocks[0]?.text).toContain('for the p200 - why is diffentiation only 2/5?');
|
||||
// The continuation must not have leaked into a Response block.
|
||||
expect(blocks.some((b) => b.kind === 'response')).toBe(false);
|
||||
});
|
||||
|
||||
it('live example 2: five-line prompt (bullet + prose + blank + prose) stays one Prompt block', () => {
|
||||
const transcript = `
|
||||
› i think we can improve this slide. or maybe a follow on slide. here is what i'm thinking:
|
||||
* left hand side... current AI stack: (frontier model), large component cloud hosted, soverign concerns etc.
|
||||
right hand -> future-enterprise-stack: frontier-model (optional, in cloud), on-prem: model router, OSS models, rest of stack (gpus, data, etc.)
|
||||
|
||||
make the point that the right hand side addresses the concerns.
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
const promptBlocks = blocks.filter((b) => b.kind === 'prompt');
|
||||
|
||||
expect(promptBlocks).toHaveLength(1);
|
||||
expect(promptBlocks[0]?.text).toContain('left hand side');
|
||||
expect(promptBlocks[0]?.text).toContain('right hand -> future-enterprise-stack');
|
||||
expect(promptBlocks[0]?.text).toContain('make the point that the right hand side addresses the concerns');
|
||||
expect(blocks.some((b) => b.kind === 'response')).toBe(false);
|
||||
});
|
||||
|
||||
it('still separates a single-line prompt from a column-zero response (no regression)', () => {
|
||||
const transcript = `
|
||||
› say again
|
||||
|
||||
Final polished answer at column zero.
|
||||
|
||||
› next
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
const promptBlocks = blocks.filter((b) => b.kind === 'prompt');
|
||||
|
||||
expect(promptBlocks).toHaveLength(2);
|
||||
expect(promptBlocks[0]?.text).toBe('say again');
|
||||
expect(getLastTranscriptResponse(blocks)).toBe('Final polished answer at column zero.');
|
||||
});
|
||||
});
|
||||
|
||||
// COD-227: Last Response must return the final assistant answer, not tool logs.
|
||||
// A response bullet beginning with a tool-like verb (• Created …) must not be
|
||||
// classified as Tool, and genuine • Calling / • Called blocks must be classified
|
||||
// as Tool. Disambiguator: a verb-bullet is a tool header only when followed by a
|
||||
// box-drawing result tree (└│├); Calling/Called are always tool markers.
|
||||
describe('COD-227 tool-header vs response disambiguation', () => {
|
||||
const MINIMAL_REPRO = `
|
||||
› new jira issue
|
||||
|
||||
• The fresh read shows a formatting problem.
|
||||
|
||||
• Calling
|
||||
└ atlassian.jira_update_issue({})
|
||||
|
||||
• Called atlassian.jira_get_issue({})
|
||||
└ { result: true }
|
||||
|
||||
• Created COD-226: View Response → More misclassifies multiline prompt continuations as responses.
|
||||
|
||||
It includes:
|
||||
|
||||
- Two concrete failures
|
||||
- Regression-test criteria
|
||||
`.trim();
|
||||
|
||||
it('returns the final • Created … answer, not the Jira Calling/Called tool log', () => {
|
||||
const blocks = parseExternalCliTranscript(MINIMAL_REPRO, 'codex');
|
||||
const last = getLastTranscriptResponse(blocks);
|
||||
|
||||
expect(last).toContain('Created COD-226');
|
||||
expect(last).toContain('Two concrete failures');
|
||||
// Must exclude tool invocations, raw results, and earlier commentary.
|
||||
expect(last).not.toContain('atlassian.jira');
|
||||
expect(last).not.toContain('Calling');
|
||||
expect(last).not.toContain('Called');
|
||||
expect(last).not.toContain('fresh read');
|
||||
});
|
||||
|
||||
it('classifies genuine • Calling / • Called (with box-drawing results) as Tool', () => {
|
||||
const blocks = parseExternalCliTranscript(MINIMAL_REPRO, 'codex');
|
||||
const toolText = blocks
|
||||
.filter((b) => b.kind === 'tool')
|
||||
.map((b) => b.text)
|
||||
.join('\n');
|
||||
|
||||
expect(toolText).toContain('Calling');
|
||||
expect(toolText).toContain('Called');
|
||||
expect(toolText).toContain('atlassian.jira_update_issue');
|
||||
// The final answer must not have been swallowed into the tool block.
|
||||
expect(toolText).not.toContain('Created COD-226');
|
||||
});
|
||||
|
||||
it('labels the repro chronologically: Prompt, Response (commentary), Tool, Response (final)', () => {
|
||||
const blocks = parseExternalCliTranscript(MINIMAL_REPRO, 'codex');
|
||||
|
||||
expect(blocks.map((b) => b.kind)).toEqual(['prompt', 'response', 'tool', 'response']);
|
||||
expect(blocks[1]?.text).toContain('fresh read');
|
||||
expect(blocks[3]?.text).toContain('Created COD-226');
|
||||
});
|
||||
|
||||
it('does not classify a verb-prefixed prose bullet as Tool when no result tree follows', () => {
|
||||
const transcript = `
|
||||
› do it
|
||||
|
||||
• Created COD-999: a brand new issue with a descriptive title.
|
||||
|
||||
Follow-up prose that belongs to the same answer.
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
|
||||
expect(blocks.some((b) => b.kind === 'tool')).toBe(false);
|
||||
expect(getLastTranscriptResponse(blocks)).toContain('Created COD-999');
|
||||
expect(getLastTranscriptResponse(blocks)).toContain('Follow-up prose');
|
||||
});
|
||||
|
||||
it('does not regress a genuine verb tool block that has a box-drawing continuation', () => {
|
||||
const transcript = `
|
||||
› look around
|
||||
|
||||
• Explored
|
||||
└ Read SKILL.md
|
||||
|
||||
Here is the assistant answer at column zero.
|
||||
`.trim();
|
||||
|
||||
const blocks = parseExternalCliTranscript(transcript, 'codex');
|
||||
|
||||
expect(blocks.some((b) => b.kind === 'tool' && b.text.includes('Explored'))).toBe(true);
|
||||
expect(getLastTranscriptResponse(blocks)).toBe('Here is the assistant answer at column zero.');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* @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 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
|
||||
* - 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'] 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');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user