fix(#592): a rule inside a Copilot answer stays part of it in agent read

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>
This commit is contained in:
Codeman maintainer committed 2026-10-18 08:07:11 +02:00
1 parent 007ea8cfb3
commit d170ad2295
2 files changed
+39 -1

No files matched your search

+15
View File
@@ -360,17 +360,28 @@ function parseDialectTranscript(cleaned: string, dialect: PaneDialect): Response
} }
} }
const isComposerRow = (i: number) => i === composerIndex; const isComposerRow = (i: number) => i === composerIndex;
const isComposerRule = (i: number) => composerIndex !== -1 && (i === composerIndex - 1 || i === composerIndex + 1);
const hasPrompt = rows.some((row, i) => dialect.prompt.test(row) && !isComposerRow(i)); const hasPrompt = rows.some((row, i) => dialect.prompt.test(row) && !isComposerRow(i));
const startupVisible = rows.some((row) => dialect.startup.some((re) => re.test(row))); const startupVisible = rows.some((row) => dialect.startup.some((re) => re.test(row)));
let sawPrompt = !hasPrompt && !startupVisible; let sawPrompt = !hasPrompt && !startupVisible;
// A rule drawn INSIDE an answer (a markdown `---`, a quoted passage between two rules) is part
// of the answer, so it must not end the block, or `agent read` returns only the text after the
// last rule. It is held until 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 stay boundaries.
let pendingRule = false;
const flush = () => { const flush = () => {
if (sawPrompt) pushBlock(blocks, kind, current); if (sawPrompt) pushBlock(blocks, kind, current);
kind = null; kind = null;
current = []; current = [];
pendingRule = false;
}; };
for (const [index, line] of rows.entries()) { for (const [index, line] of rows.entries()) {
if (kind === 'response' && isDividerOnlyLine(line) && !isComposerRule(index)) {
pendingRule = true;
continue;
}
if (isDividerOnlyLine(line) || isComposerRow(index) || dialect.ignore.some((re) => re.test(line))) { if (isDividerOnlyLine(line) || isComposerRow(index) || dialect.ignore.some((re) => re.test(line))) {
flush(); flush();
continue; continue;
@@ -407,6 +418,10 @@ function parseDialectTranscript(cleaned: string, dialect: PaneDialect): Response
continue; continue;
} }
if (!kind) kind = 'response'; if (!kind) kind = 'response';
if (pendingRule) {
current.push('', '---', '');
pendingRule = false;
}
current.push(line); current.push(line);
} }
flush(); flush();
+24 -1
View File
@@ -551,10 +551,33 @@ describe('GitHub Copilot pane dialect', () => {
...tail, ...tail,
].join('\n'); ].join('\n');
expect(getLastTranscriptResponse(parseExternalCliTranscript(screen, 'copilot'))).toBe( expect(getLastTranscriptResponse(parseExternalCliTranscript(screen, 'copilot'))).toBe(
'Quoted passage between two rules.' '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', () => { 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'); const banner = pane.split('\n').slice(0, 9).join('\n');
expect(getLastTranscriptResponse(parseExternalCliTranscript(banner, 'copilot'))).toBe(''); expect(getLastTranscriptResponse(parseExternalCliTranscript(banner, 'copilot'))).toBe('');