mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
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:
1 parent
d7c7564415
commit
89a82c68d1
3 files changed
+199
-2
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user