fix(copilot): codeman agent read returns the last answer

`/api/sessions/:id/last-response` fell through to the Claude transcript scan for copilot (not in the
external-CLI list), so `codeman agent read` answered empty even with an answer on screen. Copilot
is a repainting full-screen TUI whose stream is absolute cursor moves, so the buffer cannot be cut
into lines either; the route now reads the pane's visible screen (tmux capture-pane) for such
modes and falls back to the buffer where there is no pane.

The segmenter gets a small per-CLI "pane dialect" row instead of another branch in the
Codex-oriented loop: `❯ <prompt>  HH:MM`, `● <answer>` with indented continuation, the cwd /
`Session: N AIC used` row, composer, footer and the `┃` scrollbar gutter are chrome, and
everything before the first prompt (banner, trust dialog) is dropped so a session that has not
been asked anything answers nothing rather than serving its logo.

Fixture is a real Copilot 1.0.95 pane. Tool-call rows are not modelled and read as answer text.
This commit is contained in:
Devvyn committed 2026-10-18 00:47:47 +08:00
1 parent d7c7564415
commit 89a82c68d1
3 files changed
+199 -2

No files matched your search

+120 -1
View File
@@ -11,7 +11,49 @@ export interface ResponseViewerTranscriptBlock {
// Keep in lockstep with isExternalCliMode() in src/session.ts. Importing it here
// would drag node-pty and the whole session layer into this pure module, so the
// list is duplicated and test/response-viewer-transcript.test.ts pins the parity.
const EXTERNAL_CLI_MODES = new Set(['codex', 'gemini', 'opencode', 'antigravity', 'pi', 'grok', 'deepseek']);
const EXTERNAL_CLI_MODES = new Set(['codex', 'gemini', 'opencode', 'antigravity', 'pi', 'grok', 'deepseek', 'copilot']);
/**
* A pane that is not Codex-shaped (`›` prompt, `•` bullets) describes its own dialect as data, so
* a new CLI adds a row here instead of another branch in the Codex-oriented loop below.
*/
interface PaneDialect {
/** Group 1 is the prompt text. A bare composer glyph with nothing after it must NOT match. */
prompt: RegExp;
/** A right-aligned timestamp the TUI appends to a submitted prompt. */
promptTrailer?: RegExp;
/** The glyph that opens an answer. */
responseBullet: RegExp;
/** Chrome that can sit between turns: banner notes, the cwd/credits row, the footer, spinners. */
status: RegExp[];
/** Rows that carry nothing (the empty composer), dropped and treated as a block boundary. */
ignore: RegExp[];
/** Furniture drawn at the end of a row (a scrollbar gutter), removed before anything else reads it. */
lineTrailer?: RegExp;
}
const PANE_DIALECTS: Record<string, PaneDialect> = {
// GitHub Copilot CLI, measured on 1.0.95 inside tmux: ` ❯ <prompt> HH:MM`, then ` ● <answer>`
// with 3-space continuation lines, a cwd + `Session: N AIC used` row, the composer between two
// rules, and a `← open sidebar · … · / commands · ? help` footer. Tool-call rows are not
// modelled: they open with the same bullet and read as part of the answer.
copilot: {
prompt: /^\s*❯\s+(\S.*)$/,
promptTrailer: /\s{2,}\d{1,2}:\d{2}\s*$/,
responseBullet: /^\s*●\s+/,
status: [
/^\s*(?:!|●)\s+(?:Tip:|MCP Servers?\b|Model\b)/,
/^\s*!\s+/,
/\bSession: .*(?:AIC used|credits unavailable)/,
/^\s*←\s+open sidebar/,
/\/ commands · \? help/,
/^\s*[◉◎]\s+Working\b/,
],
ignore: [/^\s*❯\s*$/],
// The right-hand scrollbar gutter, `┃` on a row (or alone on a blank one).
lineTrailer: /\s*[┃▐]\s*$/,
},
};
function isPromptLine(line: string): boolean {
return /^\s*›\s*/.test(line);
@@ -265,6 +307,80 @@ export function isExternalCliTranscriptMode(mode: string | null | undefined): bo
return EXTERNAL_CLI_MODES.has(String(mode || ''));
}
/**
* Whether this mode's pane is a repainting full-screen TUI whose output stream is absolute cursor
* moves rather than lines. The segmenter needs the SCREEN for those (tmux capture-pane), because
* stripping the escapes from the stream concatenates every row into one run of text.
*/
export function transcriptNeedsScreenCapture(mode: string | null | undefined): boolean {
return Object.prototype.hasOwnProperty.call(PANE_DIALECTS, String(mode || ''));
}
/**
* Segment a pane that has its own dialect. Everything before the first prompt is the banner (or a
* trust dialog) and is dropped, so a session that has not been asked anything answers "nothing
* yet" rather than serving its logo as a reply.
*/
function parseDialectTranscript(cleaned: string, dialect: PaneDialect): ResponseViewerTranscriptBlock[] {
const blocks: ResponseViewerTranscriptBlock[] = [];
let kind: ResponseViewerTranscriptKind | null = null;
let current: string[] = [];
let sawPrompt = false;
const flush = () => {
if (sawPrompt) pushBlock(blocks, kind, current);
kind = null;
current = [];
};
for (const rawLine of cleaned.split('\n')) {
const line = dialect.lineTrailer ? rawLine.replace(dialect.lineTrailer, '') : rawLine;
if (isDividerOnlyLine(line)) {
flush();
continue;
}
if (dialect.ignore.some((re) => re.test(line))) {
flush();
continue;
}
const prompt = dialect.prompt.exec(line);
if (prompt) {
flush();
sawPrompt = true;
kind = 'prompt';
current = [dialect.promptTrailer ? prompt[1].replace(dialect.promptTrailer, '').trim() : prompt[1].trim()];
continue;
}
if (!sawPrompt) continue;
if (dialect.status.some((re) => re.test(line))) {
if (kind !== 'status') flush();
kind = 'status';
current.push(line.trim());
continue;
}
// A wrapped warning or tip: indented under its status row.
if (kind === 'status' && /^\s{3,}\S/.test(line)) {
current.push(line.trim());
continue;
}
if (!line.trim()) {
if (kind) current.push('');
continue;
}
const bullet = dialect.responseBullet.exec(line);
if (bullet) {
flush();
kind = 'response';
current = [line.slice(bullet[0].length)];
continue;
}
if (!kind) kind = 'response';
current.push(line);
}
flush();
return blocks.filter((block) => block.text.trim().length > 0);
}
export function parseExternalCliTranscript(
buffer: string,
mode: string | null | undefined
@@ -275,6 +391,9 @@ export function parseExternalCliTranscript(
const cleaned = cleanTerminalTranscript(buffer);
if (!cleaned) return [];
const dialect = PANE_DIALECTS[resolvedMode];
if (dialect) return parseDialectTranscript(cleaned, dialect);
const lines = trimLeadingStartup(cleaned.split('\n'));
const blocks: ResponseViewerTranscriptBlock[] = [];
let currentKind: ResponseViewerTranscriptKind | null = null;
+9 -1
View File
@@ -190,6 +190,7 @@ import { findLatestOmpSessionId } from '../../utils/omp-session-resolver.js';
import { scanOmpSessionsHistory } from '../../omp-transcript.js';
import { scanCodexSessionsHistory, codexThreadBySessionId } from '../../codex-transcript.js';
import {
transcriptNeedsScreenCapture,
getLastTranscriptResponse,
isExternalCliTranscriptMode,
parseExternalCliTranscript,
@@ -2818,7 +2819,14 @@ export function registerSessionRoutes(
// 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);
// A repainting full-screen TUI (copilot) streams absolute cursor moves, not lines, so the
// buffer cannot be segmented; read the pane's visible screen instead, and fall back to the
// buffer when there is no tmux pane to ask (direct PTY, tests).
const screen =
transcriptNeedsScreenCapture(session.mode) && session.muxName
? (ctx.mux.capturePaneText?.(session.muxName) ?? null)
: null;
const blocks = parseExternalCliTranscript(screen ?? session.terminalBuffer, session.mode);
return {
text: getLastTranscriptResponse(blocks),
timestamp: '',