From d170ad22955d82dc0524926fdc30b36ea2b57ef2 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 18 Oct 2026 08:07:11 +0200 Subject: [PATCH] fix(#592): a rule inside a Copilot answer stays part of it in agent read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/web/response-viewer-transcript.ts | 15 +++++++++++++++ test/response-viewer-transcript.test.ts | 25 ++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/src/web/response-viewer-transcript.ts b/src/web/response-viewer-transcript.ts index eb63eddb..6043aaf1 100644 --- a/src/web/response-viewer-transcript.ts +++ b/src/web/response-viewer-transcript.ts @@ -360,17 +360,28 @@ function parseDialectTranscript(cleaned: string, dialect: PaneDialect): Response } } 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 startupVisible = rows.some((row) => dialect.startup.some((re) => re.test(row))); 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 = () => { if (sawPrompt) pushBlock(blocks, kind, current); kind = null; current = []; + pendingRule = false; }; 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))) { flush(); continue; @@ -407,6 +418,10 @@ function parseDialectTranscript(cleaned: string, dialect: PaneDialect): Response continue; } if (!kind) kind = 'response'; + if (pendingRule) { + current.push('', '---', ''); + pendingRule = false; + } current.push(line); } flush(); diff --git a/test/response-viewer-transcript.test.ts b/test/response-viewer-transcript.test.ts index f1ad4c77..7cbcd26c 100644 --- a/test/response-viewer-transcript.test.ts +++ b/test/response-viewer-transcript.test.ts @@ -551,10 +551,33 @@ describe('GitHub Copilot pane dialect', () => { ...tail, ].join('\n'); 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', () => { const banner = pane.split('\n').slice(0, 9).join('\n'); expect(getLastTranscriptResponse(parseExternalCliTranscript(banner, 'copilot'))).toBe('');