feat(deepseek): read dsh session transcripts for last-response

`GET /api/sessions/:id/last-response` is how an agent (and the Response
Viewer) reads what a worker said. DeepSeek was falling through to the
pane segmenter with the other external CLIs, which for this mode is not
merely coarse but wrong: dsh-TUI paints a full-screen splash, so a
`last-response` call on a fresh dsh session answered with its ASCII-art
logo -- and anything polling for a worker's first reply reads that as a
reply.

dsh does not belong in that group. It writes a structured JSONL
transcript per session, so read it. Four things in that file shaped the
reader, all measured against real transcripts on disk:

1. dsh appends ONE ZSTD FRAME PER WRITE, and Node's zlib zstd decoder
   (one-shot and streaming alike) stops at the first frame end: a real
   56-line transcript decoded as 1 line / 158 bytes -- the session header
   alone, i.e. a silent truncation that reads as "nothing said yet"
   forever. `zstdFrameRanges()` walks frame and block headers to find
   exact boundaries; splitting on the 4-byte magic would corrupt
   everything after a magic sequence occurring inside compressed data.
   zstd is resolved at RUNTIME because it landed in Node 22.15 while the
   project floor is 22.0, so an older Node keeps the pane behaviour.
2. Every turn also records a plugin-sourced `user/message` (the runtime
   context snapshot), which must not render as the user's own words.
3. A turn that ends in an error carries the provider's message; it is
   surfaced as `Turn error: …` (and a non-error early stop as
   `Turn ended: …`) rather than as an empty string, which an agent reads
   as "still thinking" through fifteen polls.
4. Reply text is assembled per (turn, step): a finalized message wins and
   the streamed deltas fill in only for a step that never finalized, so a
   partial answer is readable mid-turn and never doubled. "Finalized" is
   tracked as a set of steps rather than as non-empty text, because a
   step whose whole reply was reasoning strips to '' at the `</think>`
   boundary and would otherwise resurrect the raw deltas in its place.

Session-to-transcript pairing is by the transcript's own header `cwd`
plus a boot window against the session's createdAt, never by
reproducing dsh's directory mangling (already two forms on disk) and
never by newest-mtime alone -- mtime alone handed a freshly spawned
worker its predecessor's answer in the same case directory.

An empty result still wins over the pane; only a Node that cannot decode
zstd falls back to it.
This commit is contained in:
Codeman maintainer
2026-08-25 04:14:26 +02:00
parent c30dfaf0e7
commit d1bc0c517d
4 changed files with 1053 additions and 0 deletions
+23
View File
@@ -141,6 +141,7 @@ import {
isExternalCliTranscriptMode,
parseExternalCliTranscript,
} from '../response-viewer-transcript.js';
import { readDeepSeekLastResponse } from '../../deepseek-transcript.js';
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
const LINKED_CASES_FILE = dataPath('linked-cases.json');
@@ -2042,6 +2043,28 @@ export function registerSessionRoutes(
return await readCodexLastResponse(session, codexQuery.context === 'full');
}
// DeepSeek Harness writes a real structured transcript under
// `$DSH_HOME/sessions/**`, so read that rather than segmenting the pane.
// ⚠️ For dsh the pane fallback is not merely coarse, it is WRONG: dsh-TUI
// paints a full-screen splash, and the segmenter served its ASCII-art logo
// back as the worker's answer (measured), which an agent polling for a
// reply reads as a reply. So an EMPTY transcript result still wins over the
// pane — "nothing said yet" is the honest answer. Only `null`, meaning a
// Node too old to decode zstd, falls through to the segmenter below.
if (session.mode === 'deepseek') {
const deepSeekQuery = req.query as { context?: string };
const full = deepSeekQuery.context === 'full';
const transcript = await readDeepSeekLastResponse(session, { blocks: full });
if (transcript) {
return {
text: transcript.text,
timestamp: transcript.timestamp,
hasContext: transcript.text.length > 0 || transcript.blocks.length > 0,
messages: full ? transcript.blocks : undefined,
};
}
}
// OpenCode / Gemini / Antigravity / Pi render their own TUIs and write no
// Claude transcript, so the scan below finds nothing and the response viewer
// renders permanently empty for them. Segment the terminal buffer instead —