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: '',
+70
View File
@@ -460,3 +460,73 @@ Here is the assistant answer at column zero.
});
});
});
describe('GitHub Copilot pane dialect', () => {
// Captured from a real Copilot 1.0.95 pane inside tmux (a throwaway COPILOT_HOME, a local
// OpenAI-compatible endpoint): banner, two turns, status rows, composer between rules, footer.
const rule = '─'.repeat(100);
const pane = [
' ╭─╮╭─╮',
' ╰─╯╰─╯ Copilot v1.0.95 uses AI.',
' █ ▘▝ █ Check for mistakes.',
' ▔▔▔▔',
' ! Model "mock" is not in the built-in catalog. Using defaults for: prompt tokens',
' (COPILOT_PROVIDER_MAX_PROMPT_TOKENS), output tokens (COPILOT_PROVIDER_MAX_OUTPUT_TOKENS). Run copilot help',
' providers for configuration details.',
' ● Tip: /theme',
' └ View or set color mode',
' ❯ multi please 11:16',
' ● First line of the answer. ┃',
' ┃',
' Second paragraph with bold text. ┃',
' ┃',
' • bullet one ┃',
' • bullet two ┃',
' ❯ WORKDONE followed by _4711 11:17',
' ● WORKDONE_4711',
' ● MCP Servers reloaded: 1 server connected',
' /tmp/work/.../scratch-2 Session: credits unavailable',
rule,
'❯',
rule,
' ← open sidebar · Interactive · Manual Approval · / commands · ? help · tab next tab mock',
].join('\n');
it('is a transcript mode', () => {
expect(isExternalCliTranscriptMode('copilot')).toBe(true);
});
it('returns the last answer, with no banner, status rows, composer or footer in it', () => {
const blocks = parseExternalCliTranscript(pane, 'copilot');
expect(getLastTranscriptResponse(blocks)).toBe('WORKDONE_4711');
});
it('keeps a multi-paragraph answer whole and strips the prompt timestamp', () => {
const blocks = parseExternalCliTranscript(pane, 'copilot');
const prompts = blocks.filter((b) => b.kind === 'prompt').map((b) => b.text);
expect(prompts).toEqual(['multi please', 'WORKDONE followed by _4711']);
const first = blocks.find((b) => b.kind === 'response')!;
expect(first.text).toContain('First line of the answer.');
expect(first.text).toContain('Second paragraph with bold text.');
expect(first.text).toContain('• bullet two');
expect(first.text).not.toMatch(/Session:|open sidebar|Tip:/);
// The scrollbar gutter Copilot draws at the right edge of every row never reaches the text.
expect(first.text).not.toContain('┃');
});
it('answers nothing for a session that has only drawn its banner or a trust dialog', () => {
const banner = pane.split('\n').slice(0, 9).join('\n');
expect(getLastTranscriptResponse(parseExternalCliTranscript(banner, 'copilot'))).toBe('');
const trust = [
'│ Do you trust the files in this folder?',
'│ ❯ 1. Yes',
'│ 2. Yes, and remember this folder for future sessions',
].join('\n');
expect(getLastTranscriptResponse(parseExternalCliTranscript(trust, 'copilot'))).toBe('');
});
it('does not take the empty composer glyph for a prompt', () => {
const blocks = parseExternalCliTranscript(pane, 'copilot');
expect(blocks.some((b) => b.kind === 'prompt' && b.text === '')).toBe(false);
});
});