mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-20 09:09:35 +02:00
The pane segmenter ended the response block at every full-width rule, so an answer that draws one (a markdown `---`, a passage quoted between two rules) read as only its last section: `Summary / ──── / Details` came back as "Details", and the existing test pinned that by expecting the quoted passage without its "Here it is:" lead-in. Inside an open response a rule is now held and written into the text as `---` once more of the same answer follows, so a rule that only separates the answer from the footer never trails it. The composer's own two rules (the last rule/❯/rule triple) are still boundaries. The old test now expects both parts, and a new one pins a Summary/rule/Details answer as one block; both fail without the change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
664 lines
28 KiB
TypeScript
664 lines
28 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
||
import {
|
||
getLastTranscriptResponse,
|
||
isExternalCliTranscriptMode,
|
||
parseExternalCliTranscript,
|
||
} from '../src/web/response-viewer-transcript.js';
|
||
import { isExternalCliMode } from '../src/session.js';
|
||
import type { SessionMode } from '../src/types.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');
|
||
|
||
// The frontend's loadFullContext() renders via msg.role: 'user' gets the
|
||
// "You" badge, everything else the agent badge. A block without role
|
||
// rendered every prompt as the agent.
|
||
expect(blocks[1]?.role).toBe('user');
|
||
expect(blocks[5]?.role).toBe('user');
|
||
expect(blocks[0]?.role).toBe('assistant');
|
||
expect(blocks[3]?.role).toBe('assistant');
|
||
expect(blocks[4]?.role).toBe('assistant');
|
||
});
|
||
|
||
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.');
|
||
});
|
||
});
|
||
|
||
// The divider-status detector used to be /^[─-]+\s*(.+?)\s*[─-]{3,}$/, whose
|
||
// lazy middle backtracked catastrophically on a long dash run that does not
|
||
// end in 3+ dashes (measured >2min at 8,000 chars). Pane text is
|
||
// agent-controlled and buffers reach 32MB, so the pattern was replaced by a
|
||
// linear counter walk. Same approach as the glob-matcher ReDoS fix (68ae9a8):
|
||
// the hostile input below fails by timeout with the RegExp version.
|
||
describe('divider-status ReDoS hardening', () => {
|
||
it('classifies a hostile 10k dash run in linear time', () => {
|
||
const hostile = '-'.repeat(10_000) + '>';
|
||
const started = Date.now();
|
||
const blocks = parseExternalCliTranscript(`› q\n${hostile}`, 'opencode');
|
||
const elapsed = Date.now() - started;
|
||
|
||
expect(elapsed).toBeLessThan(2_000);
|
||
// No 3-dash tail, so the line is not a status divider — it stays prose.
|
||
expect(blocks.some((b) => b.kind === 'response' && b.text.includes('>'))).toBe(true);
|
||
expect(blocks.some((b) => b.kind === 'status')).toBe(false);
|
||
});
|
||
|
||
// The counter walk must keep the EXACT accept set and captured content of
|
||
// the old regex (including its backtracking quirks on all-dash lines), so
|
||
// brute-force both against a corpus. The old regex is safe here: probes are
|
||
// capped at 24 chars, far below the blowup threshold.
|
||
it('matches the old regex char-for-char on a brute-force corpus', () => {
|
||
const oldNormalize = (line: string): string | null => {
|
||
const trimmed = line.trim();
|
||
const matched = trimmed.match(/^[─-]+\s*(.+?)\s*[─-]{3,}$/);
|
||
if (!matched) return null;
|
||
return matched[1]?.trim() || null;
|
||
};
|
||
|
||
// Observe the private normalizeDividerStatusLine through the parser: after
|
||
// a prompt, a column-zero probe from this alphabet is a status block iff
|
||
// the divider detector matched, and the status text is its return value.
|
||
// (The alphabet triggers no other status arm; letters are limited to 'x'.)
|
||
const observedStatusText = (probe: string): string | null => {
|
||
const blocks = parseExternalCliTranscript(`› q\n${probe}`, 'opencode');
|
||
const status = blocks.find((b) => b.kind === 'status');
|
||
return status ? status.text : null;
|
||
};
|
||
|
||
const directed = [
|
||
'──── Status ────',
|
||
'─ Worked for 1m 51s ───────',
|
||
'---',
|
||
'----',
|
||
'-----',
|
||
'------',
|
||
'-------',
|
||
'---- ---',
|
||
'- ---',
|
||
'--x',
|
||
'x---',
|
||
'----x',
|
||
'----x--',
|
||
'----x---',
|
||
'─x───',
|
||
'- x ---',
|
||
'--- x -',
|
||
'─── ──',
|
||
'-\tx\t---',
|
||
'--->----',
|
||
'─-─- x x ─-─',
|
||
'- x x x ----',
|
||
];
|
||
|
||
// Deterministic LCG so a failure reproduces byte-identically.
|
||
let seed = 0x2f6e2b1;
|
||
const rand = () => {
|
||
seed = (seed * 1103515245 + 12345) & 0x7fffffff;
|
||
return seed / 0x80000000;
|
||
};
|
||
const alphabet = ['-', '-', '─', '─', ' ', ' ', '\t', 'x', '>'];
|
||
const probes = [...directed];
|
||
for (let i = 0; i < 3000; i += 1) {
|
||
const len = Math.floor(rand() * 25);
|
||
let probe = '';
|
||
for (let j = 0; j < len; j += 1) {
|
||
probe += alphabet[Math.floor(rand() * alphabet.length)];
|
||
}
|
||
probes.push(probe);
|
||
}
|
||
|
||
for (const raw of probes) {
|
||
const probe = raw.trim();
|
||
// Divider-only lines (8+ dash/whitespace chars) are consumed by
|
||
// isDividerOnlyLine before the status detector ever runs, in both the
|
||
// old and new worlds — the detector is unobservable there.
|
||
if (!probe || /^[\s─-]{8,}$/.test(probe)) continue;
|
||
expect(observedStatusText(probe), `probe: ${JSON.stringify(probe)}`).toBe(oldNormalize(probe));
|
||
}
|
||
});
|
||
});
|
||
|
||
// EXTERNAL_CLI_MODES is a local duplicate of isExternalCliMode() in
|
||
// src/session.ts (importing session.ts would drag node-pty and the whole
|
||
// session layer into the pure transcript module). This pin is what keeps the
|
||
// two from drifting — 'pi' was missing here while isExternalCliMode() had it,
|
||
// which left pi sessions with the exact empty-viewer symptom the transcript
|
||
// branch exists to fix.
|
||
describe('mode parity with isExternalCliMode()', () => {
|
||
// Exhaustive by construction: adding a SessionMode without deciding its
|
||
// transcript behavior fails to compile here.
|
||
const ALL_SESSION_MODES: Record<SessionMode, true> = {
|
||
claude: true,
|
||
shell: true,
|
||
opencode: true,
|
||
codex: true,
|
||
gemini: true,
|
||
antigravity: true,
|
||
pi: true,
|
||
};
|
||
|
||
it('agrees with isExternalCliMode() for every SessionMode', () => {
|
||
for (const mode of Object.keys(ALL_SESSION_MODES) as SessionMode[]) {
|
||
expect(isExternalCliTranscriptMode(mode), `mode: ${mode}`).toBe(isExternalCliMode(mode));
|
||
}
|
||
});
|
||
|
||
it('covers pi', () => {
|
||
expect(isExternalCliTranscriptMode('pi')).toBe(true);
|
||
});
|
||
});
|
||
});
|
||
|
||
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('┃');
|
||
});
|
||
|
||
// The footer and composer every screen below ends with.
|
||
const tail = [
|
||
' /tmp/work/.../scratch-2 Session: credits unavailable',
|
||
rule,
|
||
'❯',
|
||
rule,
|
||
' ← open sidebar · Interactive · Manual Approval · / commands · ? help · tab next tab mock',
|
||
];
|
||
|
||
it("keeps a shell prompt inside an answer's code block as answer text, not a new prompt", () => {
|
||
const screen = [
|
||
' ❯ how do I run the tests 11:20',
|
||
' ● Run this from the repo root:',
|
||
'',
|
||
' ❯ npm test',
|
||
'',
|
||
' Then read the summary line.',
|
||
...tail,
|
||
].join('\n');
|
||
const blocks = parseExternalCliTranscript(screen, 'copilot');
|
||
expect(blocks.filter((b) => b.kind === 'prompt').map((b) => b.text)).toEqual(['how do I run the tests']);
|
||
const answer = getLastTranscriptResponse(blocks);
|
||
expect(answer).toContain('Run this from the repo root:');
|
||
expect(answer).toContain('❯ npm test');
|
||
expect(answer).toContain('Then read the summary line.');
|
||
});
|
||
|
||
it('keeps a paragraph between two rules inside an answer, and treats only the last triple as the composer', () => {
|
||
const screen = [
|
||
' ❯ quote the passage 11:21',
|
||
' ● Here it is:',
|
||
rule,
|
||
' Quoted passage between two rules.',
|
||
rule,
|
||
...tail,
|
||
].join('\n');
|
||
expect(getLastTranscriptResponse(parseExternalCliTranscript(screen, 'copilot'))).toBe(
|
||
'Here it is:\n\n---\n\nQuoted passage between two rules.'
|
||
);
|
||
});
|
||
|
||
it('keeps an answer whole across a rule inside it, so agent read never returns only its tail', () => {
|
||
const screen = [
|
||
' ❯ summarize the change 11:22',
|
||
' ● Summary',
|
||
'',
|
||
' One line of summary.',
|
||
rule,
|
||
' Details',
|
||
'',
|
||
' The longer explanation.',
|
||
...tail,
|
||
].join('\n');
|
||
const blocks = parseExternalCliTranscript(screen, 'copilot');
|
||
expect(blocks.filter((b) => b.kind === 'response')).toHaveLength(1);
|
||
const answer = getLastTranscriptResponse(blocks);
|
||
expect(answer).toContain('Summary');
|
||
expect(answer).toContain('One line of summary.');
|
||
expect(answer).toContain('Details');
|
||
expect(answer).toContain('The longer explanation.');
|
||
// The rule before the footer separates the answer from the chrome and never trails it.
|
||
expect(answer.endsWith('---')).toBe(false);
|
||
});
|
||
|
||
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);
|
||
});
|
||
|
||
// The pane is all there is to read, so a long answer scrolls the prompt row (and the banner) off
|
||
// the top. That must read as the tail of the answer, not as "nothing yet".
|
||
describe('an answer taller than the pane', () => {
|
||
const rule = '─'.repeat(100);
|
||
const tail = (n: number) =>
|
||
Array.from({ length: n }, (_, i) => ` line ${String(i + 1).padStart(3, '0')} of the long answer`);
|
||
const footer = [
|
||
' /tmp/work/.../scratch-2 Session: 0 AIC used',
|
||
rule,
|
||
'❯',
|
||
rule,
|
||
' ← open sidebar · Interactive · Manual Approval · / commands · ? help · tab next tab mock',
|
||
];
|
||
|
||
it('returns the visible rows when the prompt row has scrolled off', () => {
|
||
const screen = [...tail(35), ...footer].join('\n');
|
||
const text = getLastTranscriptResponse(parseExternalCliTranscript(screen, 'copilot'));
|
||
expect(text).toContain('line 001 of the long answer');
|
||
expect(text).toContain('line 035 of the long answer');
|
||
expect(text).not.toMatch(/Session:|open sidebar|commands/);
|
||
});
|
||
|
||
it('still reads the tail when the answer has a leading bullet row on screen', () => {
|
||
const screen = [' ● the answer begins here', ...tail(20), ...footer].join('\n');
|
||
const text = getLastTranscriptResponse(parseExternalCliTranscript(screen, 'copilot'));
|
||
expect(text).toContain('the answer begins here');
|
||
expect(text).toContain('line 020 of the long answer');
|
||
});
|
||
|
||
it('is not fooled by text typed in the composer under the answer', () => {
|
||
const typed = [
|
||
...tail(30),
|
||
' /tmp/work/.../scratch-2 Session: 0 AIC used',
|
||
rule,
|
||
'❯ half a thought',
|
||
rule,
|
||
footer[4],
|
||
];
|
||
const text = getLastTranscriptResponse(parseExternalCliTranscript(typed.join('\n'), 'copilot'));
|
||
expect(text).toContain('line 030 of the long answer');
|
||
expect(text).not.toContain('half a thought');
|
||
});
|
||
|
||
it('still answers nothing while the banner or a trust dialog is on screen', () => {
|
||
const banner = [
|
||
' ╭─╮╭─╮',
|
||
' ╰─╯╰─╯ Copilot v1.0.95 uses AI.',
|
||
' ! Model "mock" is not in the built-in catalog.',
|
||
' /tmp/work Session: 0 AIC used',
|
||
rule,
|
||
'❯',
|
||
rule,
|
||
footer[4],
|
||
].join('\n');
|
||
expect(getLastTranscriptResponse(parseExternalCliTranscript(banner, 'copilot'))).toBe('');
|
||
});
|
||
|
||
it('prefers the prompt row when it is on screen', () => {
|
||
const screen = [
|
||
' ❯ do the thing 11:16',
|
||
' ● short answer',
|
||
...footer,
|
||
].join('\n');
|
||
expect(getLastTranscriptResponse(parseExternalCliTranscript(screen, 'copilot'))).toBe('short answer');
|
||
});
|
||
});
|
||
});
|