fix(web): show the whole last turn in the Claude response viewer's brief view

The eye button rendered `data.text`, which is one row: the last assistant
row of the transcript. A Claude answer is a median of 3 model messages
(p90 11) split around tool calls, so the brief view usually showed the tail
of an answer ("Done.", "Let me look.") and the substance appeared only after
More. The full view was fine, which is why the brief one read as broken by
comparison.

The brief view now asks `?context=turn`. The reader answers with the
assistant messages of the last ANSWERED turn (`selectLastAnsweredTurn`: the
highest `turn` that has an assistant row, so a prompt queued after the
answer does not blank the view) and the frontend renders them exactly as
the full view renders that turn: one badge, then continuation segments,
gated on the numeric `turn` as before.

`data.text` is unchanged in every context — still the last assistant row,
never `messages.at(-1)` — because agent pollers hash it. Readers that emit
no turns (Codex, the pane parser, DeepSeek, an older server) return `text`
only for `context=turn`, and the brief view keeps its single card for them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01McLWqCWBuQYGuPMScb4Aou
This commit is contained in:
shenlvkang-collab
2026-09-10 12:45:16 +08:00
co-authored by Claude Fable 5.1
parent 5b667264b4
commit bd61735393
8 changed files with 325 additions and 7 deletions
@@ -336,6 +336,69 @@ describe('GET /api/sessions/:id/last-response (claude)', () => {
expect(body.data.text).toBe('Let me look.');
});
it('answers context=turn with the last answered turn only, text still on the last row', async () => {
const sessionId = harness.ctx._session.id;
const session = harness.ctx._session as typeof harness.ctx._session & {
claudeSessionId: string;
adoptClaudeSessionId: ReturnType<typeof vi.fn>;
};
session.claudeSessionId = sessionId;
session.adoptClaudeSessionId = vi.fn();
writeTranscript(sessionId, [
userEntry('first'),
assistantEntry('Old answer.', '2026-07-21T00:00:01Z'),
userEntry('second'),
assistantEntry('Looking at the file.', '2026-07-21T00:00:02Z'),
{ type: 'assistant', message: { content: [{ type: 'tool_use', id: 'x' }] } },
{ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'x' }] } },
assistantEntry('The bug is on line 3.', '2026-07-21T00:00:03Z'),
assistantEntry('Done.', '2026-07-21T00:00:04Z'),
// A prompt queued after the answer opens a new, unanswered turn.
queuedEntry('third', '2026-07-21T00:00:05Z'),
]);
const response = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/last-response?context=turn`,
});
expect(response.statusCode).toBe(200);
const data = JSON.parse(response.body).data as {
text: string;
timestamp: string;
messages: Array<{ role: string; text: string; turn: number }>;
};
// The frozen brief contract holds: still the last assistant row.
expect(data.text).toBe('Done.');
expect(data.timestamp).toBe('2026-07-21T00:00:04Z');
// The turn view is that row's whole turn, assistant rows only, and not the
// queued prompt that has no answer yet.
expect(data.messages.map((m) => [m.role, m.text, m.turn])).toEqual([
['assistant', 'Looking at the file.', 2],
['assistant', 'The bug is on line 3.', 2],
['assistant', 'Done.', 2],
]);
const brief = await getLastResponse(sessionId);
expect(brief.body.data).toEqual({ text: 'Done.', timestamp: '2026-07-21T00:00:04Z' });
});
it('answers context=turn with an empty list when nothing has been answered yet', async () => {
const sessionId = harness.ctx._session.id;
const session = harness.ctx._session as typeof harness.ctx._session & {
claudeSessionId: string;
adoptClaudeSessionId: ReturnType<typeof vi.fn>;
};
session.claudeSessionId = sessionId;
session.adoptClaudeSessionId = vi.fn();
writeTranscript(sessionId, [userEntry('go')]);
const response = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${sessionId}/last-response?context=turn`,
});
expect(JSON.parse(response.body).data).toEqual({ text: '', timestamp: '', messages: [] });
});
/**
* A multi-line paste absorbed mid-turn arrives as N queued rows within a few
* hundred milliseconds (observed: 5 rows inside ~360ms). They are one turn, so