fix(#592): pin the screen-capture branch of last-response for copilot

GET /api/sessions/:id/last-response reads a copilot pane through
capturePaneText, since a repainting full-screen TUI streams cursor moves
rather than lines, and nothing pinned that at the route: had the branch
stopped capturing, copilot would quietly have been segmented off the
fused stream again. Three app.inject cases with a stubbed mux: the
captured screen is what gets segmented (the stream text never shows),
a null capture falls back to the buffer, and a line-streaming mode
(opencode) never asks for the screen. The first two fail with the
capture switched off.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-18 08:08:24 +02:00
1 parent d170ad2295
commit e7a6fb5067
1 file changed
+62 -1
@@ -14,9 +14,11 @@
* block without it lost the "You" badge on prompts * block without it lost the "You" badge on prompts
* - a pane that has produced no output reports hasContext: false rather than 404ing * - a pane that has produced no output reports hasContext: false rather than 404ing
* - Claude mode still takes the Claude path (regression guard) * - Claude mode still takes the Claude path (regression guard)
* - copilot (a repainting full-screen TUI) is read off the pane SCREEN via
* capturePaneText, and falls back to the buffer only when there is no screen
*/ */
import { describe, it, expect, beforeEach } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify'; import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie'; import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, createMockSession, type MockRouteContext } from '../mocks/index.js'; import { createMockRouteContext, createMockSession, type MockRouteContext } from '../mocks/index.js';
@@ -154,6 +156,65 @@ describe('GET /api/sessions/:id/last-response — external CLI panes', () => {
expect(body.data.hasContext).toBe(false); expect(body.data.hasContext).toBe(false);
}); });
describe('copilot reads the pane screen, not the stream', () => {
const rule = '─'.repeat(80);
const screenOf = (answer: string) =>
[
' ❯ what changed 11:30',
` ● ${answer}`,
' /tmp/work Session: 0 AIC used',
rule,
'❯',
rule,
' ← open sidebar · Interactive · Manual Approval · / commands · ? help',
].join('\n');
function stubCapture(result: string | null) {
const capturePaneText = vi.fn((_muxName: string) => result);
(harness.ctx.mux as unknown as { capturePaneText: typeof capturePaneText }).capturePaneText = capturePaneText;
return capturePaneText;
}
it('segments the captured screen of the session pane', async () => {
session.mode = 'copilot';
// What the stream looks like for a repainting TUI once escapes are stripped: rows fused
// together, so a reader of the buffer would return this run of text.
session.terminalBuffer = 'STREAM-ONLY fused rows that are not the answer';
const capture = stubCapture(screenOf('The screen answer.'));
const { res, body } = await lastResponse();
expect(res.statusCode).toBe(200);
expect(capture).toHaveBeenCalledWith(session.muxName);
expect(body.data.text).toBe('The screen answer.');
expect(body.data.text).not.toContain('STREAM-ONLY');
expect(body.data.hasContext).toBe(true);
});
it('falls back to the buffer when the pane has no screen to capture', async () => {
session.mode = 'copilot';
session.terminalBuffer = screenOf('The buffer answer.');
const capture = stubCapture(null);
const { res, body } = await lastResponse();
expect(res.statusCode).toBe(200);
expect(capture).toHaveBeenCalledWith(session.muxName);
expect(body.data.text).toBe('The buffer answer.');
});
it('never captures the screen for a mode that streams lines', async () => {
session.mode = 'opencode';
session.terminalBuffer = PANE;
const capture = stubCapture(screenOf('must not be read'));
const { body } = await lastResponse();
expect(capture).not.toHaveBeenCalled();
expect(body.data.text).toContain('Documented the helper in docs/retry.md');
});
});
it('leaves Claude mode on the Claude transcript path', async () => { it('leaves Claude mode on the Claude transcript path', async () => {
// Regression guard: a claude pane must NOT be segmented off its terminal // Regression guard: a claude pane must NOT be segmented off its terminal
// buffer, or a real transcript would be shadowed by scraped pane text. // buffer, or a real transcript would be shadowed by scraped pane text.