Files
Codeman/test/response-viewer-last-turn.test.ts
T
Codeman maintainer 02b0e27898 fix: merge-time follow-ups for #400, #401, #362 and #388
Each item is from the pre-merge review of the PR it names, applied on master
rather than by pushing to a contributor branch.

#400 (response viewer, shenlvkang-collab)
- The brief view opened at `scrollTop = 0`, right when it was a single card
  holding the last row. Now that it renders the whole turn, the top is the
  turn's first narration line and the answer can be screens below it, while
  loadFullContext already scrolls to the bottom of the same turn. A multi-row
  turn now opens at its newest text; a single card still opens at the top.

#401 (loopback links as web tabs, shenlvkang-collab)
- Drop `*.localhost` from the auto-route set. Every other member is an address
  literal that can only mean this box; a `*.localhost` DNS name is not one, and
  a resolver with a search domain retries `evil.localhost` as
  `evil.localhost.<search domain>`. The link source is agent-written terminal
  output, so that set is the whole confinement on a tap that makes Codeman
  fetch a URL server-side and persist it. The page-side test stays broader
  (`isOnBoxHostname`), where a false positive only declines to proxy.
- A link to the origin root navigated nothing: the path was flattened to '',
  which openWebview reads as "no deep link", leaving an open frame where it was.
- `this.webviews` being set does not mean it is loaded. initWebviews() assigns a
  truthy empty map and only then awaits the list, so a tap during page load
  found nothing to reuse and POSTed a duplicate record. Join the in-flight
  refresh instead.
- One dashboard per dev server rather than per host spelling, which is what the
  method's own comment already promised.
- Toast on the auto-create: it writes webviews.json, broadcasts over SSE and
  adds a Run-dropdown row on every signed-in device, with a new tab as its only
  previous signal.

#362 (remote omp continuation, timkjr)
- Accept the allowlisted `mode === 'omp'` arm as-is; a blanket registry render
  would hand deepseek a locally-resolved --profile and bypass claude's own
  overlay. A registry-declared switch is the follow-up if a third mode needs it.
- Revert the whole-file Prettier reformat of docs/remote-sessions.md (docs/ is
  hand-formatted and outside `npm run format`), keeping only the two new
  sections.
- Correct three stale passages: architecture-invariants' `exec claude
  --dangerously-skip-permissions`, the `exec <cli>` paragraph (claude and omp
  now have their own arms, and the claude pane's PID is the login shell), and
  omp-integration's `-c 'omp'`. RemoteCommandMode gains deepseek and omp.
- Add the missing `_maybeCaptureOmpSessionId` remote-guard test; the sibling
  guard in `_pinOmpRespawnId` had one and this path runs earlier, on the first
  idle turn.

#388 (keyCode 229 recovery, aakhter)
- Gate notifyCanonicalData on shouldSuppressTerminalQueryResponse and
  isTerminalFocusOrMouseReport. onData also carries the DA/DSR/CPR/OSC replies
  xterm answers during Ink redraws and its SGR mouse and focus reports; any of
  those landing between the keydown and the candidate's resolution was read as
  "xterm spoke for this keystroke", standing the recovery down and leaving the
  character dropped, worst on a busy agent pane. Reached through
  window.CodemanTerminalInput: the predicates live in a module IIFE that closes
  long before this call site, so bare references would throw into the
  surrounding try/catch and stop the notify from ever running.

Every fix has a test that fails without it (verified by reverting each).
Full gate green on the combined tree: 358 files, 6849 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-12 05:15:41 +02:00

219 lines
8.5 KiB
TypeScript

/**
* @fileoverview The brief ("Last Response") view renders the last ANSWERED turn.
*
* `data.text` is one row — the last assistant row — and a Claude turn is a
* median of 3 rows, so the eye button used to show the tail of an answer
* ("Done.") while the More view showed the whole thing. The brief view now asks
* for `context=turn` and renders those rows the way the full view does: one
* badge, then continuation segments. Pinned here:
*
* 1. `selectLastAnsweredTurn` picks the highest turn that HAS an assistant
* message, so a prompt queued after the answer (a new, unanswered turn)
* does not blank the view; and it yields nothing without numeric turns.
* 2. The brief view falls back to `text` when the server sends no messages
* (Codex, the pane parser, an older server), so those keep their one card.
* 3. The continuation gate is the numeric `turn`, as in loadFullContext.
*
* app.js is loaded via `vm` with a jsdom document, as in
* response-viewer-turn-segments.test.ts.
* Port: N/A
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { selectLastAnsweredTurn } from '../src/web/response-viewer-transcript.js';
describe('selectLastAnsweredTurn', () => {
const user = (text: string, turn: number) => ({ role: 'user', text, turn });
const assistant = (text: string, turn: number) => ({ role: 'assistant', text, turn });
it('returns every assistant message of the highest answered turn', () => {
const messages = [
user('a', 1),
assistant('a1', 1),
user('b', 2),
assistant('b1', 2),
assistant('b2', 2),
user('c', 3),
];
expect(selectLastAnsweredTurn(messages).map((m) => m.text)).toEqual(['b1', 'b2']);
});
it('yields nothing for messages without numeric turns, so callers fall back to text', () => {
const messages = [
{ role: 'user', text: 'a' },
{ role: 'assistant', text: 'a1' },
{ role: 'assistant', text: 'a2' },
];
expect(selectLastAnsweredTurn(messages)).toEqual([]);
expect(selectLastAnsweredTurn([])).toEqual([]);
});
it('keeps turn-0 output emitted before the first prompt', () => {
expect(selectLastAnsweredTurn([assistant('hello', 0)]).map((m) => m.text)).toEqual(['hello']);
});
});
describe('response viewer brief view (last answered turn)', () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>');
const { document, NodeFilter } = dom.window;
function loadCodemanAppClass() {
const constants = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8');
const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
const context = vm.createContext({
console,
performance,
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
fetch: vi.fn(),
document,
NodeFilter,
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
MobileDetection: {},
});
vm.runInContext(`${constants}\n${source}\nglobalThis.__CodemanApp = CodemanApp;`, context);
return { CodemanApp: (context as { __CodemanApp: { prototype: object } }).__CodemanApp, context };
}
const { CodemanApp, context: appContext } = loadCodemanAppClass();
interface ViewerApp {
toggleResponseViewer(): Promise<void>;
activeSessionId?: string;
sessions?: Map<string, { mode: string }>;
}
function mountViewer() {
const viewer = document.createElement('div');
viewer.id = 'responseViewer';
const backdrop = document.createElement('div');
backdrop.id = 'responseViewerBackdrop';
const body = document.createElement('div');
body.id = 'responseViewerBody';
const title = document.createElement('div');
title.id = 'responseViewerTitle';
const more = document.createElement('button');
more.id = 'responseViewerMore';
document.body.append(viewer, backdrop, body, title, more);
return { viewer, body, title, more };
}
function makeApp(payload: unknown): { app: ViewerApp; fetchMock: ReturnType<typeof vi.fn> } {
const app = Object.create(CodemanApp.prototype) as ViewerApp;
app.activeSessionId = 's1';
app.sessions = new Map([['s1', { mode: 'claude' }]]);
const fetchMock = vi.fn(async () => ({ json: async () => ({ data: payload }) }));
(appContext as { fetch: unknown }).fetch = fetchMock;
return { app, fetchMock };
}
afterEach(() => {
document.body.innerHTML = '';
});
it('asks for context=turn and renders the whole turn as one badged card with segments', async () => {
const { body, viewer } = mountViewer();
const { app, fetchMock } = makeApp({
text: 'Done.',
timestamp: 't',
messages: [
{ role: 'assistant', text: 'Looking at the file.', turn: 2 },
{ role: 'assistant', text: 'The bug is on line 3.', turn: 2 },
{ role: 'assistant', text: 'Done.', turn: 2 },
],
});
await app.toggleResponseViewer();
expect(String(fetchMock.mock.calls[0][0])).toBe('/api/sessions/s1/last-response?context=turn');
const cards = body.querySelectorAll('.rv-message');
expect(cards).toHaveLength(3);
expect(body.querySelectorAll('.rv-role')).toHaveLength(1);
expect(cards[0].classList.contains('rv-msg-cont')).toBe(false);
expect(cards[1].classList.contains('rv-msg-cont')).toBe(true);
expect(cards[2].classList.contains('rv-msg-cont')).toBe(true);
expect(body.textContent).toContain('The bug is on line 3.');
expect(viewer.classList.contains('visible')).toBe(true);
});
it('opens a multi-row turn at its NEWEST text, and a single card at the top', async () => {
// `body.scrollTop = 0` was right when the brief view was one card holding
// the last row. With the whole turn rendered, the top of the scroller is
// the turn's FIRST narration line and the answer the eye button exists to
// show can be several screens below it; loadFullContext already scrolls to
// the bottom for the same turn, so the two views disagreed.
// jsdom does no layout, so scrollHeight is stubbed and the write recorded.
const spyScroll = (body: HTMLElement) => {
const writes: number[] = [];
Object.defineProperty(body, 'scrollHeight', { configurable: true, get: () => 4200 });
Object.defineProperty(body, 'scrollTop', {
configurable: true,
get: () => writes[writes.length - 1] ?? 0,
set: (v: number) => void writes.push(v),
});
return writes;
};
const many = mountViewer();
const manyWrites = spyScroll(many.body);
await makeApp({
text: 'Done.',
timestamp: 't',
messages: [
{ role: 'assistant', text: 'Looking at the file.', turn: 2 },
{ role: 'assistant', text: 'Done.', turn: 2 },
],
}).app.toggleResponseViewer();
expect(manyWrites.at(-1)).toBe(4200);
document.body.innerHTML = '';
const one = mountViewer();
const oneWrites = spyScroll(one.body);
await makeApp({
text: 'Done.',
timestamp: 't',
messages: [{ role: 'assistant', text: 'Done.', turn: 2 }],
}).app.toggleResponseViewer();
expect(oneWrites.at(-1)).toBe(0);
});
it('falls back to text when the server sends no messages, keeping one badged card', async () => {
const { body } = mountViewer();
const { app } = makeApp({ text: 'Only the last row.', timestamp: 't' });
await app.toggleResponseViewer();
expect(body.querySelectorAll('.rv-message')).toHaveLength(1);
expect(body.querySelectorAll('.rv-role')).toHaveLength(1);
expect(body.textContent).toContain('Only the last row.');
});
it('ignores user rows and blank rows in a turn payload', async () => {
const { body } = mountViewer();
const { app } = makeApp({
text: 'answer',
timestamp: 't',
messages: [
{ role: 'user', text: 'prompt', turn: 1 },
{ role: 'assistant', text: ' ', turn: 1 },
{ role: 'assistant', text: 'answer', turn: 1 },
],
});
await app.toggleResponseViewer();
expect(body.querySelectorAll('.rv-message')).toHaveLength(1);
expect(body.textContent).not.toContain('prompt');
expect(body.textContent).toContain('answer');
});
});