fix(response-viewer): role on full-context blocks, divider ReDoS, pi mode (#326 follow-up)

Three post-merge fixes for the external-CLI response viewer:

- ?context=full blocks now carry role ('user' for prompts, 'assistant'
  for response/status/tool). The frontend's loadFullContext() renders
  via msg.role, so the roleless blocks lost the "You" badge and every
  turn rendered as the agent. kind/label/text are unchanged and the
  frontend needs no change.

- normalizeDividerStatusLine() dropped its backtracking regex
  (/^[─-]+\s*(.+?)\s*[─-]{3,}$/): the lazy middle went catastrophic on
  a long dash run without a 3-dash tail (measured 15.5s at 4,000 chars,
  minutes at 10,000), and pane text is agent-controlled with buffers up
  to 32MB. Replaced by a linear counter walk with the identical accept
  set and captured content, pinned char-for-char against the old regex
  by a brute-force corpus test plus a hostile-input regression test
  that fails by timeout with the RegExp version (same approach as the
  glob-matcher hardening in 68ae9a8).

- 'pi' joins EXTERNAL_CLI_MODES: pi sessions had the identical
  empty-viewer symptom the transcript branch exists to fix. The list
  stays a local duplicate of isExternalCliMode() (importing session.ts
  would drag node-pty into the pure module); a new exhaustive parity
  test asserts the two mode sets can no longer drift.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-08-21 02:25:43 +02:00
parent 96ee8b536d
commit d7ad73bc9b
4 changed files with 214 additions and 17 deletions
@@ -4,12 +4,14 @@
* Uses app.inject() — no real HTTP ports needed.
* Port: N/A (app.inject doesn't open ports)
*
* OpenCode / Gemini / Antigravity render their own TUIs and never write a Claude
* transcript under ~/.claude/projects, so before this branch existed the handler
* fell through to the Claude scan, found nothing, and the response viewer was
* permanently empty for those modes. These tests pin:
* OpenCode / Gemini / Antigravity / Pi render their own TUIs and never write a
* Claude transcript under ~/.claude/projects, so before this branch existed the
* handler fell through to the Claude scan, found nothing, and the response viewer
* was permanently empty for those modes. These tests pin:
* - the pane buffer is segmented and the LAST response is returned
* - ?context=full carries the parsed blocks, and the short form omits them
* - ?context=full blocks carry role — the frontend renders via msg.role, so a
* block without it lost the "You" badge on prompts
* - a pane that has produced no output reports hasContext: false rather than 404ing
* - Claude mode still takes the Claude path (regression guard)
*/
@@ -100,7 +102,7 @@ describe('GET /api/sessions/:id/last-response — external CLI panes', () => {
return { res, body: JSON.parse(res.body) };
}
for (const mode of ['opencode', 'gemini', 'antigravity'] as const) {
for (const mode of ['opencode', 'gemini', 'antigravity', 'pi'] as const) {
it(`returns the last assistant response from the ${mode} pane buffer`, async () => {
session.mode = mode;
session.terminalBuffer = PANE;
@@ -131,6 +133,12 @@ describe('GET /api/sessions/:id/last-response — external CLI panes', () => {
const prompts = body.data.messages.filter((b: { kind: string }) => b.kind === 'prompt');
expect(prompts).toHaveLength(2);
expect(prompts[1].text).toContain('now document it');
// loadFullContext() renders via msg.role — without it every block got the
// agent badge and the user's own prompts lost their "You" attribution.
for (const block of body.data.messages as Array<{ kind: string; role: string }>) {
expect(block.role).toBe(block.kind === 'prompt' ? 'user' : 'assistant');
}
});
it('reports hasContext false for a pane that has produced no output', async () => {