From 86b8d738458b9c3db99ba348061cbc6ffd8f4e26 Mon Sep 17 00:00:00 2001 From: later0day Date: Sat, 5 Sep 2026 21:38:27 +0800 Subject: [PATCH 1/2] fix: replace deleted assistant/chunk with live agent/assistant-stream mirror MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The durable assistant/chunk event was deleted in DSH 0.1.3-alpha.1. Replacements: - durable: settled assistant/message and each assistant/attempt embed the step's timed stream (expandAssistantStream extracts first token time) - live: host event agent/assistant-stream with {agent, frame} payload Changes: - New src/sidechat-live.ts: createAssistantStreamMirror() buffers one in-flight prefix per session from agent/assistant-stream frames. Guard: reject by attemptId mismatch or revision <= seen revision. revision is allocated PER FRAME by the host agent-loop, not per attempt — only attemptId carries identity. - sidechat-core.ts: buildOpenTurnSnapshot now reads settled assistant/message content blocks (text → text, reasoning → reasoning), skipping superseded assistant/attempt. - sidechat-transcript.ts: appendLiveRows() renders the live prefix as an unsettled tail row, dropped once the step's assistant/message is in the log. - SideChatView.tsx: ThreadCache carries live prefix; fetchThread computes liveChanged and bumps revision on change. - sidechat-routes.ts: sidechat.events reads live AFTER the log, so the settled step and its live prefix never appear together. - context-types.ts: typed on('agent/assistant-stream') overload + SidebarAssistantStreamFrame union. - Tests: 7 new unit tests for the mirror; existing tests updated. Verified: RPC-level (live prefix grows monotonically and equals settled text prefix) and UI-level (DOM shows mid-stream half-sentence then settled full text). Co-Authored-By: Claude Opus 4.8 --- src/client/SideChatView.tsx | 24 +++- src/client/api.ts | 8 +- src/client/sidechat-transcript.ts | 90 ++++++++------- src/context-types.ts | 29 +++++ src/sidechat-core.ts | 47 ++++++-- src/sidechat-live.ts | 102 +++++++++++++++++ src/sidechat-routes.ts | 17 ++- src/subagent-activity.ts | 9 +- tests/sidechat-core.spec.ts | 62 ++++++++-- tests/sidechat-live.spec.ts | 151 +++++++++++++++++++++++++ tests/sidechat-routes.spec.ts | 39 ++++++- tests/sidechat-seed-validation.spec.ts | 4 +- tests/sidechat-transcript.spec.ts | 42 +++++-- tests/subagent-activity.spec.ts | 2 +- 14 files changed, 527 insertions(+), 99 deletions(-) create mode 100644 src/sidechat-live.ts create mode 100644 tests/sidechat-live.spec.ts diff --git a/src/client/SideChatView.tsx b/src/client/SideChatView.tsx index 367ee8830..318f71c77 100644 --- a/src/client/SideChatView.tsx +++ b/src/client/SideChatView.tsx @@ -58,6 +58,7 @@ import { sideThreadRows, threadHasCompletedTurn, threadTrailingPending, + type SidechatLiveStep, type SidechatThreadInfo, } from '../sidechat-core.ts' import { @@ -109,9 +110,12 @@ export function consumeSidechatSeed(): string | undefined { const inFlightStarts = new Set() /** Per-thread transcript cache: thread-own events merged by seq (polls ride - * the afterSeq delta and never re-download what they already hold). */ + * the afterSeq delta and never re-download what they already hold), plus the + * last poll's in-flight assistant prefix (transient — replaced or dropped by + * every poll, never merged into `entries`). */ interface ThreadCache { entries: SidebarHistoryEntry[] + live?: SidechatLiveStep } /** Row-render labels (locale-dependent, memoized once per mount). */ @@ -457,14 +461,18 @@ export function SideChatView(props: { try { const cache = cacheRef.current const afterSeq = cache.entries.at(-1)?.event.seq - const { events } = await api.sidechatEvents(childId, afterSeq, controller.signal) + const { events, live } = await api.sidechatEvents(childId, afterSeq, controller.signal) + // The in-flight prefix grows without any new event, so its change is a + // re-render trigger of its own. + const liveChanged = live?.text !== cache.live?.text || live?.reasoning !== cache.live?.reasoning + cache.live = live if (events.length > 0) { // Wire events arrive as parsed JSON; the mirror narrows data to the // record the mapping reads. const incoming = events.map(event => ({ event: event as SidebarSessionEvent })) cache.entries = mergeBySeq(cache.entries, incoming) - setRevision(value => value + 1) } + if (events.length > 0 || liveChanged) setRevision(value => value + 1) } catch { // Aborted by a newer pull or a wire failure: keep the last rows. } @@ -533,7 +541,9 @@ export function SideChatView(props: { // their object identity (see reuseRows): the 2s poll re-renders only the // changed tail instead of re-parsing markdown for the whole transcript. const rows = useMemo(() => { - const next = threadId === undefined ? [] : transcriptRows(cacheRef.current.entries, prevRowsRef.current) + const next = threadId === undefined + ? [] + : transcriptRows(cacheRef.current.entries, prevRowsRef.current, cacheRef.current.live) prevRowsRef.current = next return next }, @@ -545,12 +555,14 @@ export function SideChatView(props: { const trailingPending = threadId !== undefined && threadTrailingPending(cacheRef.current.entries) const freshThread = threadId !== undefined && rows.length === 0 - // Follow the stream: stick to the bottom while the log grows. + // Follow the stream: stick to the bottom while the transcript grows. The + // in-flight prefix grows a row's text without adding one, so this tracks + // the mapped rows themselves (a fresh array per changed poll). useEffect(() => { const scroller = scrollRef.current if (scroller === null) return scroller.scrollTop = scroller.scrollHeight - }, [rows.length, threadId]) + }, [rows, threadId]) /** Open a NEW thread tab (createTab mints the autoCreate tab; its view * creates the thread on mount). */ diff --git a/src/client/api.ts b/src/client/api.ts index 3235a6f2f..779648313 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -8,7 +8,7 @@ */ import { encodeHtmlUrl } from '../html-route.ts' import type { LastActivity } from '../subagent-activity.ts' -import type { SidechatLogEvent, SidechatThreadInfo } from '../sidechat-core.ts' +import type { SidechatLiveStep, SidechatLogEvent, SidechatThreadInfo } from '../sidechat-core.ts' import type { SidebarSessionEvent } from '../context-types.ts' import type { BrowserProbeResult } from './browser.ts' @@ -385,9 +385,11 @@ export const api = { call('sidechat.info', { childId }), /** One transcript pull of a Side Chat thread: the thread's OWN events * (the inherited seed is cut host-side and never crosses the wire). - * `afterSeq` narrows the response to the delta beyond it (poll tail). */ + * `afterSeq` narrows the response to the delta beyond it (poll tail). + * `live` carries the in-flight step's assistant prefix — no durable event + * holds it until the step settles — and is absent when nothing streams. */ sidechatEvents: (childId: string, afterSeq?: number, signal?: AbortSignal) => - call<{ events: SidechatLogEvent[] }>('sidechat.events', { + call<{ events: SidechatLogEvent[]; live?: SidechatLiveStep }>('sidechat.events', { childId, ...(afterSeq !== undefined ? { afterSeq } : {}), }, signal), diff --git a/src/client/sidechat-transcript.ts b/src/client/sidechat-transcript.ts index 0c9e40080..ef10a81d5 100644 --- a/src/client/sidechat-transcript.ts +++ b/src/client/sidechat-transcript.ts @@ -12,14 +12,15 @@ * plugin-sourced context) onto a collapsible injection row, so the view * shows only the thread's own conversation. * - * Live streaming: `assistant/message` events only land when a step - * completes, but `assistant/chunk` events stream token-level text and - * reasoning deltas. The mapping accumulates both per block and supersedes - * them with the assembled message once it lands (settled rows). + * Live output: DSH 0.1.3-alpha.1 removed the durable `assistant/chunk` + * event, so the log carries a step's prose only at its settled + * `assistant/message`. The in-flight prefix rides the same poll response + * (`live`) from the host's process-local stream mirror and renders as + * unsettled tail rows until the settled blocks replace it. */ import type { DiffHunk, ReadBlockLine } from '@deepseek-ai/dsh-client-ui-primitives' import type { SidebarHistoryEntry } from '../context-types.ts' -import { isContextInjectionMessage, SIDE_BOUNDARY_PROMPT } from '../sidechat-core.ts' +import { isContextInjectionMessage, type SidechatLiveStep, SIDE_BOUNDARY_PROMPT } from '../sidechat-core.ts' /** * Structured render payload for a tool row that maps onto one of the host's @@ -299,20 +300,31 @@ function resultCard( /** * Map a thread child's history rows onto compact transcript rows: the * inherited fork seed is cut at the last `session/end-seed`, context - * injections map onto a collapsible injection row, `assistant/chunk` - * deltas accumulate into streaming rows per (turn, step, block) and are - * superseded by the assembled `assistant/message`, and tool invocations - * render one expandable line each (arguments, paired result text, failure - * marker; a still-executing call is marked until its result lands). + * injections map onto a collapsible injection row, each settled + * `assistant/message` renders its text and reasoning blocks, and tool + * invocations render one expandable line each (arguments, paired result + * text, failure marker; a still-executing call is marked until its result + * lands). + * + * `live` is the in-flight step's assistant prefix from the same poll. It + * renders as unsettled tail rows, and is ignored once that step's + * `assistant/message` is in `entries` — the settled blocks are the same text + * and the poll can observe both. * @param entries - history rows (event + host-computed view) in seq order. + * @param prev - the previous poll's rows (row-identity reuse). + * @param live - the in-flight step's prefix, when the thread is streaming. * @returns display rows in log order. */ -export function transcriptRows(entries: readonly SidebarHistoryEntry[], prev?: readonly SidechatTranscriptRow[]): SidechatTranscriptRow[] { +export function transcriptRows( + entries: readonly SidebarHistoryEntry[], + prev?: readonly SidechatTranscriptRow[], + live?: SidechatLiveStep, +): SidechatTranscriptRow[] { const events = entries.map(entry => entry.event) const seedEnd = lastSeedEnd(events) const rows: SidechatTranscriptRow[] = [] - /** (turn, step, index, kind) key → index of its accumulating stream row. */ - const streamRows = new Map() + /** "turn:step" of every step whose assembled message already landed. */ + const settledSteps = new Set() /** tool callId → index of its tool row in `rows` (result pairing). */ const callRows = new Map() /** turn → envelope time of its `turn/start` (turn-tail duration basis). */ @@ -374,27 +386,6 @@ export function transcriptRows(entries: readonly SidebarHistoryEntry[], prev?: r rows.push({ kind: 'user', seq: event.seq, text }) break } - case 'assistant/chunk': { - const chunk = data.chunk as { type?: unknown; text?: unknown } | undefined - if (chunk === null || typeof chunk !== 'object') break - const kind = chunk.type === 'text-delta' ? 'assistant' : chunk.type === 'reasoning-delta' ? 'reasoning' : null - if (kind === null || typeof chunk.text !== 'string' || chunk.text === '') break - const turn = data.turn - const step = data.step - const blockIndex = (chunk as { index?: unknown }).index - const key = `${String(turn)}:${String(step)}:${String(blockIndex)}:${kind}` - const existing = streamRows.get(key) - if (existing !== undefined) { - const row = rows[existing] - if (row !== undefined && row.kind === kind && !row.settled) { - rows[existing] = { ...row, text: row.text + chunk.text } - } - } else { - streamRows.set(key, rows.length) - rows.push({ kind, seq: event.seq, text: chunk.text, settled: false }) - } - break - } case 'assistant/message': { // Turn-tail usage: each assembled message carries its step's token // accounting (absent when the adapter reported none). @@ -409,13 +400,7 @@ export function transcriptRows(entries: readonly SidebarHistoryEntry[], prev?: r else turnUsage.set(usageTurn, { inputTokens: input, outputTokens: aggregate.outputTokens + output }) } } - const prefix = `${String(data.turn)}:${String(data.step)}:` - const streamed = [...streamRows.entries()] - .filter(([key]) => key.startsWith(prefix)) - .map(([, rowIndex]) => rowIndex) - for (const key of [...streamRows.keys()]) { - if (key.startsWith(prefix)) streamRows.delete(key) - } + settledSteps.add(`${String(data.turn)}:${String(data.step)}`) const content = Array.isArray((data.message as { content?: unknown } | undefined)?.content) ? (data.message as { content: readonly unknown[] }).content : [] @@ -430,8 +415,7 @@ export function transcriptRows(entries: readonly SidebarHistoryEntry[], prev?: r } return [] }) - if (streamed.length === 0) rows.push(...settled) - else rows.splice(Math.min(...streamed), streamed.length, ...settled) + rows.push(...settled) break } case 'tool/call': { @@ -484,9 +468,29 @@ export function transcriptRows(entries: readonly SidebarHistoryEntry[], prev?: r } } } + appendLiveRows(rows, events, settledSteps, live) return reuseRows(rows, prev) } +/** + * Append the in-flight step's prefix as unsettled reasoning/assistant rows. + * Their `seq` is one past the log tail — stable while the step streams, + * because a step appends nothing durable between its first token and its + * settlement — so the rows keep their React key as the text grows. + */ +function appendLiveRows( + rows: SidechatTranscriptRow[], + events: readonly SidebarHistoryEntry['event'][], + settledSteps: ReadonlySet, + live: SidechatLiveStep | undefined, +): void { + if (live === undefined) return + if (settledSteps.has(`${String(live.turn)}:${String(live.step)}`)) return + const seq = (events.at(-1)?.seq ?? -1) + 1 + if (live.reasoning !== '') rows.push({ kind: 'reasoning', seq, text: live.reasoning, settled: false }) + if (live.text !== '') rows.push({ kind: 'assistant', seq, text: live.text, settled: false }) +} + /** Whether two rows carry identical display content (identity fields plus * every rendered field of their kind). */ function rowsEqual(a: SidechatTranscriptRow, b: SidechatTranscriptRow): boolean { diff --git a/src/context-types.ts b/src/context-types.ts index 373074924..6b77ee8c2 100644 --- a/src/context-types.ts +++ b/src/context-types.ts @@ -557,6 +557,17 @@ export interface SidebarContextShape { * {@link ./client/index.tsx}); undefined on the host side. */ betterSidebar: BetterSidebarService + /** + * Live assistant-stream subscribe (`agent/assistant-stream`): the + * process-local publication that replaced the durable `assistant/chunk` + * event in DSH 0.1.3-alpha.1. Declared here because the vendored cordis + * Events map this plugin compiles against does not carry the agent + * runtime's entries. + */ + on( + event: 'agent/assistant-stream', + listener: (payload: { agent: unknown; frame: SidebarAssistantStreamFrame }) => void, + ): () => void /** * String-keyed session feed subscribe (the vendored cordis `on` is keyed * to its typed Events map; the harness session feed is a plain string @@ -566,6 +577,24 @@ export interface SidebarContextShape { on(event: string, listener: (session: unknown, event: SidebarSessionEvent) => void): () => void } +/** + * One live assistant-stream publication — mirror of @deepseek-ai/dsh-agent's + * `AssistantStreamFrame`, narrowed to the fields the sidebar reads. Chunk + * frames are transient; the loop appends the step's durable settlement + * before the `end` frame. + */ +export type SidebarAssistantStreamFrame = + | { type: 'start'; attemptId: string; revision: number; turn: number; step: number } + | { type: 'chunk'; attemptId: string; revision: number; index: number; time: number; chunk: unknown } + | { + type: 'end' + attemptId: string + revision: number + index: number + /** Whether a durable settlement committed, or the attempt was dropped. */ + outcome: { kind: 'committed'; eventType: string; seq: number } | { kind: 'abandoned' } + } + /** * The Context this plugin sees: the vendored cordis Context intersected with * the structural service faces above. Re-exported from the package root so a diff --git a/src/sidechat-core.ts b/src/sidechat-core.ts index af18208e1..9e1fd476f 100644 --- a/src/sidechat-core.ts +++ b/src/sidechat-core.ts @@ -81,6 +81,21 @@ export interface SidechatLogEvent { data: unknown } +/** + * The partial assistant output of one step still streaming, carried by the + * transcript poll because no durable event holds it: DSH 0.1.3-alpha.1 + * removed `assistant/chunk`, so a step's prose reaches the log only at its + * settled `assistant/message`. + */ +export interface SidechatLiveStep { + turn: number + step: number + /** Text delivered by the attempt in flight (may be empty). */ + text: string + /** Reasoning delivered by the attempt in flight (may be empty). */ + reasoning: string +} + /** The result of cutting a parent log into a side-thread inheritance. */ export interface SidechatInheritance { /** The child seed: contiguous from seq 0, ends outside any open turn. */ @@ -245,12 +260,26 @@ export function buildSidechatInheritance(events: readonly SidechatLogEvent[]): S return { seed, snapshot: null } } +/** The content blocks of one assistant/message event's message. */ +function assistantBlocks(data: Record): { type?: unknown; text?: unknown }[] { + const message = data.message as { content?: unknown } | undefined + const content = message?.content + if (!Array.isArray(content)) return [] + return content.filter(block => block !== null && typeof block === 'object') +} + /** * Structured text snapshot of the parent's OPEN turn (from its `turn/start` - * to the log tail): the accumulated assistant/reasoning output verbatim - * (code blocks ride the raw deltas) and the tool activity — executed tools - * with their result text, the still-executing one marked. Returns null when - * there is no open turn or nothing to show. + * to the log tail): the assistant/reasoning output of the turn's settled + * steps and the tool activity — executed tools with their result text, the + * still-executing one marked. Returns null when there is no open turn or + * nothing to show. + * + * The text comes from each step's settled `assistant/message`, which the loop + * appends before dispatching that step's tool calls — so a turn held open by + * an executing tool has all of its output durably present. Superseded model + * attempts (`assistant/attempt`) are left out: they were retried and never + * became model-visible history. */ export function buildOpenTurnSnapshot(events: readonly SidechatLogEvent[]): string | null { const boundary = lastTurnBoundary(events) @@ -267,11 +296,11 @@ export function buildOpenTurnSnapshot(events: readonly SidechatLogEvent[]): stri pendingCalls.clear() continue } - if (event.type === 'assistant/chunk') { - const chunk = data.chunk as { type?: unknown; text?: unknown } | undefined - if (chunk === null || typeof chunk !== 'object') continue - if (chunk.type === 'text-delta' && typeof chunk.text === 'string') text += chunk.text - else if (chunk.type === 'reasoning-delta' && typeof chunk.text === 'string') reasoning += chunk.text + if (event.type === 'assistant/message') { + for (const block of assistantBlocks(data)) { + if (block.type === 'text' && typeof block.text === 'string') text += block.text + else if (block.type === 'reasoning' && typeof block.text === 'string') reasoning += block.text + } continue } if (event.type === 'tool/call') { diff --git a/src/sidechat-live.ts b/src/sidechat-live.ts new file mode 100644 index 000000000..7291d2695 --- /dev/null +++ b/src/sidechat-live.ts @@ -0,0 +1,102 @@ +/** + * The live assistant output of a Side Chat thread's in-flight step. + * + * DSH 0.1.3-alpha.1 removed the durable `assistant/chunk` event: a step's + * token stream is now embedded in the settled `assistant/message` / + * `assistant/attempt` and published live — process-locally, not durably — + * as `agent/assistant-stream` frames. The thread transcript polls the + * durable log, which therefore shows a step's prose only once the step + * settles. This mirror keeps the in-flight prefix in memory so the poll can + * carry it alongside the events. + * + * The prefix is transient by construction: it is dropped at the frame that + * ends the attempt, because the loop appends the durable settlement BEFORE + * that end frame — the very next poll reads the same text from the log. + */ +import type { Context } from './context-types.ts' +import type { SidechatLiveStep } from './sidechat-core.ts' + +/** Cap on one buffered in-flight prefix (a runaway step cannot grow the + * host's memory without bound; the durable settlement carries the whole + * text regardless, so the cap only bounds the live preview). */ +const LIVE_PREFIX_CAP = 64_000 + +/** The live-prefix reader the Side Chat routes consult per poll. */ +export interface SidechatStreamMirror { + /** The in-flight step of one session, or undefined when nothing streams. */ + step(sessionId: string): SidechatLiveStep | undefined +} + +/** One buffered attempt: its identity plus the prefix delivered so far. */ +interface LiveAttempt extends SidechatLiveStep { + /** The publishing attempt; a later attempt of the same step replaces it. */ + attemptId: string + /** + * Highest frame revision applied. The host allocates `revision` per FRAME + * (monotone within one attached agent lifecycle), not per attempt — so it + * orders frames and identifies stale ones; it never identifies the attempt. + */ + revision: number +} + +/** The text of one live frame chunk, by delta kind. */ +function deltaOf(chunk: unknown): { kind: 'text' | 'reasoning'; text: string } | undefined { + if (chunk === null || typeof chunk !== 'object') return undefined + const { type, text } = chunk as { type?: unknown; text?: unknown } + if (typeof text !== 'string' || text === '') return undefined + if (type === 'text-delta') return { kind: 'text', text } + if (type === 'reasoning-delta') return { kind: 'reasoning', text } + return undefined +} + +/** + * Mirror every attached agent's in-flight assistant prefix. + * @param ctx - host plugin context (a double without `on` degrades to a + * mirror that never reports a live step — the transcript then waits for + * the durable settlement, its behavior before the live channel existed). + * @returns the per-session reader the `sidechat.events` route consults. + */ +export function createAssistantStreamMirror(ctx: Context): SidechatStreamMirror { + const perSession = new Map() + if (typeof ctx.on !== 'function') return { step: () => undefined } + const dispose = ctx.on('agent/assistant-stream', ({ agent, frame }) => { + const sessionId = (agent as { session?: { id?: unknown } } | null)?.session?.id + if (typeof sessionId !== 'string') return + if (frame.type === 'start') { + perSession.set(sessionId, { + turn: frame.turn, + step: frame.step, + text: '', + reasoning: '', + attemptId: frame.attemptId, + revision: frame.revision, + }) + return + } + const live = perSession.get(sessionId) + // A frame of a superseded attempt (its replacement already published a + // start) must not append to the current prefix, and a frame older than + // one already applied is a stale duplicate. + if (live === undefined || live.attemptId !== frame.attemptId || frame.revision <= live.revision) return + live.revision = frame.revision + if (frame.type === 'end') { + // The durable `assistant/message` / `assistant/attempt` is already + // appended (an abandoned attempt has nothing to show), so the prefix + // has no reader left. + perSession.delete(sessionId) + return + } + const delta = deltaOf(frame.chunk) + if (delta === undefined) return + if (delta.kind === 'text') live.text = (live.text + delta.text).slice(-LIVE_PREFIX_CAP) + else live.reasoning = (live.reasoning + delta.text).slice(-LIVE_PREFIX_CAP) + }) + ctx.effect(() => dispose, 'dsh-better-sidebar: side-chat assistant-stream mirror') + return { + step(sessionId) { + const live = perSession.get(sessionId) + if (live === undefined || (live.text === '' && live.reasoning === '')) return undefined + return { turn: live.turn, step: live.step, text: live.text, reasoning: live.reasoning } + }, + } +} diff --git a/src/sidechat-routes.ts b/src/sidechat-routes.ts index acdeadba0..40dff0c16 100644 --- a/src/sidechat-routes.ts +++ b/src/sidechat-routes.ts @@ -43,9 +43,11 @@ import { sideLabel, type SeedEvent, type SidechatLogEvent, + type SidechatLiveStep, type SidechatThreadInfo, threadOwnLogEvents, } from './sidechat-core.ts' +import { createAssistantStreamMirror } from './sidechat-live.ts' import { requireString, SidebarError } from './wire.ts' /** The six Side Chat routes of the sidebar API (wire method names). */ @@ -65,8 +67,10 @@ export interface SidechatRoutes { 'sidechat.info'(payload: unknown): Promise /** The thread's OWN transcript events, seed-cut host-side (the inherited * parent log never crosses the wire); `afterSeq` narrows the response to - * the delta beyond it (poll tail). */ - 'sidechat.events'(payload: unknown): Promise<{ events: SidechatLogEvent[] }> + * the delta beyond it (poll tail). `live` carries the in-flight step's + * assistant prefix, which no durable event holds until the step settles; + * it is absent when nothing streams. */ + 'sidechat.events'(payload: unknown): Promise<{ events: SidechatLogEvent[]; live?: SidechatLiveStep }> } /** Timeout guarding the create call (the registry detaches it before the @@ -200,6 +204,7 @@ async function threadLogEvents(ctx: Context, childId: string): Promise { const sessionId = requireString(payload, 'sessionId') @@ -395,7 +400,13 @@ export function buildSidechatApi(ctx: Context): SidechatRoutes { const events = await threadLogEvents(ctx, childId) const own = threadOwnLogEvents(events) const fresh = rawAfter === undefined ? own : own.filter(event => event.seq > rawAfter) - return { events: fresh.length > EVENTS_CAP ? fresh.slice(fresh.length - EVENTS_CAP) : fresh } + // The live prefix is read AFTER the log so a step settling mid-call is + // reported once, by the log, never twice. + const live = liveStreams.step(childId) + return { + events: fresh.length > EVENTS_CAP ? fresh.slice(fresh.length - EVENTS_CAP) : fresh, + ...(live === undefined ? {} : { live }), + } }, } } diff --git a/src/subagent-activity.ts b/src/subagent-activity.ts index 6a5d58239..656116155 100644 --- a/src/subagent-activity.ts +++ b/src/subagent-activity.ts @@ -39,11 +39,10 @@ export interface LastActivity { /** * Fold a session event log into the last text output + last tool call (each - * is the LAST occurrence in event order). Lifecycle events and raw - * `assistant/chunk` rows are ignored — the card shows what the subagent is - * doing right now, not its plumbing. The scan runs BACKWARD from the newest - * event and stops once both fields are found, so a long history costs only - * the recent tail in the common case. + * is the LAST occurrence in event order). Lifecycle events are ignored — the + * card shows what the subagent is doing right now, not its plumbing. The scan + * runs BACKWARD from the newest event and stops once both fields are found, + * so a long history costs only the recent tail in the common case. * @param events - the session's append-only event log (oldest → newest). * @param maxMessages - optional message-boundary window: only the tail's * last `maxMessages` surface messages (`user/message`, `assistant/message`) diff --git a/tests/sidechat-core.spec.ts b/tests/sidechat-core.spec.ts index 8c3a20795..b0567ead2 100644 --- a/tests/sidechat-core.spec.ts +++ b/tests/sidechat-core.spec.ts @@ -136,7 +136,7 @@ describe('buildSidechatInheritance', () => { ev('user/message', 6, { content: [{ type: 'text', text: 'next?' }], source: { kind: 'user' } }), ev('turn/start', 7, { turn: 2 }), ev('step/start', 8, { turn: 2, step: 1 }), - ev('assistant/chunk', 9, { turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: 'partial answer' } }), + ev('assistant/message', 9, { turn: 2, step: 1, message: { content: [{ type: 'text', text: 'partial answer' }] } }), ] const { seed, snapshot } = buildSidechatInheritance(events) expectContiguous(seed) @@ -145,10 +145,10 @@ describe('buildSidechatInheritance', () => { const turnEnd = seed.at(-1)! expect(stepEnd).toMatchObject({ type: 'step/end', data: { turn: 2, step: 1 } }) expect(turnEnd).toMatchObject({ type: 'turn/end', data: { turn: 2, reason: { kind: 'interrupted' } } }) - // The pending user message and the partial chunk survive as REAL events. + // The pending user message and the step's message survive as REAL events. expect(seed.some(event => event.type === 'user/message' && (event.data.content as Array<{ text: string }>)[0]?.text === 'next?')).toBe(true) - expect(seed.some(event => event.type === 'assistant/chunk')).toBe(true) + expect(seed.some(event => event.type === 'assistant/message' && event.seq === 9)).toBe(true) expect(snapshot).toBeNull() }) @@ -166,7 +166,7 @@ describe('buildSidechatInheritance', () => { content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'hit' }] }], }, }), - ev('assistant/chunk', 5, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'found ' } }), + ev('assistant/message', 5, { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'found ' }] } }), ] const { seed, snapshot } = buildSidechatInheritance(events) expectContiguous(seed) @@ -180,7 +180,7 @@ describe('buildSidechatInheritance', () => { ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }), ev('turn/start', 1, { turn: 1 }), ev('step/start', 2, { turn: 1, step: 1 }), - ev('assistant/chunk', 3, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'running ' } }), + ev('assistant/message', 3, { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'running ' }] } }), ev('tool/call', 4, { turn: 1, step: 1, callId: 'c1', name: 'bash', arguments: '{"cmd":"sleep 9"}' }), ] expect(hasDanglingToolCall(events, 1)).toBe(true) @@ -203,7 +203,7 @@ describe('buildSidechatInheritance', () => { ev('assistant/message', 3, { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'first' }] } }), ev('step/end', 4, { turn: 1, step: 1 }), ev('step/start', 5, { turn: 1, step: 2 }), - ev('assistant/chunk', 6, { turn: 1, step: 2, chunk: { type: 'text-delta', index: 0, text: 'second' } }), + ev('assistant/message', 6, { turn: 1, step: 2, message: { content: [{ type: 'text', text: 'second' }] } }), ] const { seed } = buildSidechatInheritance(events) expectContiguous(seed) @@ -218,13 +218,21 @@ describe('buildOpenTurnSnapshot', () => { expect(buildOpenTurnSnapshot(completedTurn(0, 1, { text: 'x' }))).toBeNull() }) - it('preserves streamed text and tool detail verbatim', () => { + it('preserves settled text and tool detail verbatim', () => { const events = [ ev('turn/start', 0, { turn: 1 }), ev('step/start', 1, { turn: 1, step: 1 }), - ev('assistant/chunk', 2, { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'think' } }), - ev('assistant/chunk', 3, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '```js\ncode' } }), - ev('assistant/chunk', 4, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '\n```' } }), + ev('assistant/message', 2, { + turn: 1, + step: 1, + message: { + content: [ + { type: 'reasoning', text: 'think' }, + { type: 'text', text: '```js\ncode\n```' }, + { type: 'tool-call', id: 'c1', name: 'read', arguments: '{"path":"a.txt"}' }, + ], + }, + }), ev('tool/call', 5, { turn: 1, step: 1, callId: 'c1', name: 'read', arguments: '{"path":"a.txt"}' }), ev('tool/result', 6, { turn: 1, @@ -244,6 +252,40 @@ describe('buildOpenTurnSnapshot', () => { expect(snapshot).toContain('Result: file body') expect(snapshot).toContain('`bash` (executing)') }) + + it('leaves out a superseded attempt: only settled messages are output', () => { + const events = [ + ev('turn/start', 0, { turn: 1 }), + ev('step/start', 1, { turn: 1, step: 1 }), + // A failed attempt's stream is durable but was never model-visible. + ev('assistant/attempt', 2, { + turn: 1, + step: 1, + stream: [{ type: 'text-chunks', time0: 1, index: 0, dt: [], texts: ['abandoned'] }], + }), + ev('assistant/message', 3, { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'retried' }] } }), + ev('tool/call', 4, { turn: 1, step: 1, callId: 'c1', name: 'bash', arguments: '{}' }), + ] + const snapshot = buildOpenTurnSnapshot(events) + expect(snapshot).toContain('retried') + expect(snapshot).not.toContain('abandoned') + }) + + it('skips malformed assistant content instead of throwing', () => { + const events = [ + ev('turn/start', 0, { turn: 1 }), + ev('step/start', 1, { turn: 1, step: 1 }), + ev('assistant/message', 2, { turn: 1, step: 1, message: { content: 'not-an-array' } }), + ev('assistant/message', 3, { turn: 1, step: 1 }), + ev('assistant/message', 4, { + turn: 1, + step: 1, + message: { content: [null, { type: 'text' }, { type: 'text', text: 'kept' }] }, + }), + ev('tool/call', 5, { turn: 1, step: 1, callId: 'c1', name: 'bash', arguments: '{}' }), + ] + expect(buildOpenTurnSnapshot(events)).toContain('kept') + }) }) describe('sideLabel', () => { diff --git a/tests/sidechat-live.spec.ts b/tests/sidechat-live.spec.ts new file mode 100644 index 000000000..d0978b04d --- /dev/null +++ b/tests/sidechat-live.spec.ts @@ -0,0 +1,151 @@ +/** + * Host tests for the live assistant-stream mirror: the in-memory stand-in for + * the durable `assistant/chunk` event DSH 0.1.3-alpha.1 removed. The mirror + * buffers one in-flight prefix per session from `agent/assistant-stream` + * frames so the transcript poll can carry it; it must drop that prefix the + * moment the attempt ends (the durable settlement is already in the log by + * then) and must never let a superseded attempt append to its replacement. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createAssistantStreamMirror } from '../src/sidechat-live.ts' +import type { Context, SidebarAssistantStreamFrame } from '../src/context-types.ts' + +type Frame = SidebarAssistantStreamFrame +type Listener = (payload: { agent: unknown; frame: Frame }) => void + +/** A host context double capturing the one `agent/assistant-stream` listener. */ +function ctxDouble() { + let listener: Listener | undefined + const dispose = vi.fn() + const effect = vi.fn((setup: () => unknown, _description?: string) => setup()) + const on = vi.fn((event: string, handler: Listener) => { + expect(event).toBe('agent/assistant-stream') + listener = handler + return dispose + }) + const ctx = { on, effect } as unknown as Context + /** Deliver one frame as the named session's agent. */ + const emit = (sessionId: string | undefined, frame: Frame): void => { + listener?.({ agent: sessionId === undefined ? null : { session: { id: sessionId } }, frame }) + } + return { ctx, emit, on, effect, dispose } +} + +const START: Frame = { type: 'start', attemptId: 'a1', revision: 1, turn: 2, step: 3 } + +/** The host allocates `revision` per FRAME, not per attempt — so every frame + * of one attempt carries a fresh, higher number. */ +let revision = START.revision + +/** A text/reasoning delta frame of the started attempt. */ +function delta(type: 'text-delta' | 'reasoning-delta', text: string): Frame { + revision += 1 + return { type: 'chunk', attemptId: 'a1', revision, index: revision, time: revision, chunk: { type, text } } +} + +/** The terminal frame of the started attempt, with a durable settlement. */ +function end(attemptId = 'a1'): Frame { + revision += 1 + return { + type: 'end', attemptId, revision, index: revision, + outcome: { kind: 'committed', eventType: 'assistant/message', seq: 42 }, + } +} + +beforeEach(() => { revision = START.revision }) + +describe('createAssistantStreamMirror', () => { + it('accumulates text and reasoning of the started attempt under its session', () => { + const { ctx, emit } = ctxDouble() + const mirror = createAssistantStreamMirror(ctx) + emit('child', START) + // A started attempt with nothing delivered yet has nothing to show. + expect(mirror.step('child')).toBeUndefined() + emit('child', delta('reasoning-delta', 'thin')) + emit('child', delta('reasoning-delta', 'king')) + emit('child', delta('text-delta', 'Hel')) + emit('child', delta('text-delta', 'lo')) + expect(mirror.step('child')).toEqual({ turn: 2, step: 3, text: 'Hello', reasoning: 'thinking' }) + // Other sessions are unaffected: the buffer is keyed per session. + expect(mirror.step('other')).toBeUndefined() + }) + + it('drops the prefix at the end frame, because the log already holds it', () => { + const { ctx, emit } = ctxDouble() + const mirror = createAssistantStreamMirror(ctx) + emit('child', START) + emit('child', delta('text-delta', 'answer')) + emit('child', end()) + expect(mirror.step('child')).toBeUndefined() + }) + + it('ignores frames of a superseded attempt once its retry has started', () => { + const { ctx, emit } = ctxDouble() + const mirror = createAssistantStreamMirror(ctx) + emit('child', START) + emit('child', delta('text-delta', 'abandoned')) + emit('child', { type: 'start', attemptId: 'a2', revision: 10, turn: 2, step: 3 }) + // A late frame of the abandoned attempt, numbered ABOVE the replacement's + // start: only the attempt id can reject it. + emit('child', { type: 'chunk', attemptId: 'a1', revision: 11, index: 3, time: 3, chunk: { type: 'text-delta', text: ' late' } }) + emit('child', { type: 'chunk', attemptId: 'a2', revision: 12, index: 0, time: 4, chunk: { type: 'text-delta', text: 'retried' } }) + expect(mirror.step('child')).toEqual({ turn: 2, step: 3, text: 'retried', reasoning: '' }) + // The stale attempt's end frame must not clear the live replacement. + emit('child', end('a1')) + expect(mirror.step('child')).toMatchObject({ text: 'retried' }) + }) + + it('ignores a frame numbered at or below one already applied (stale duplicate)', () => { + const { ctx, emit } = ctxDouble() + const mirror = createAssistantStreamMirror(ctx) + emit('child', START) + const applied = delta('text-delta', 'kept') + emit('child', applied) + // The same revision redelivered, and one below it: both already accounted for. + const at = applied.revision + emit('child', { type: 'chunk', attemptId: 'a1', revision: at, index: 1, time: 1, chunk: { type: 'text-delta', text: 'redelivered' } }) + emit('child', { type: 'chunk', attemptId: 'a1', revision: at - 1, index: 0, time: 0, chunk: { type: 'text-delta', text: 'older' } }) + expect(mirror.step('child')).toMatchObject({ text: 'kept' }) + }) + + it('ignores chunks with no started attempt, no session id, or no usable delta', () => { + const { ctx, emit } = ctxDouble() + const mirror = createAssistantStreamMirror(ctx) + // No start yet. + emit('child', delta('text-delta', 'orphan')) + expect(mirror.step('child')).toBeUndefined() + // An agent without a string session id (a detached or malformed payload). + emit(undefined, START) + emit('child', START) + const chunks = [null, 'not-an-object', {}, { type: 'text-delta' }, { type: 'text-delta', text: '' }, { type: 'tool-call-delta', text: 'x' }] + chunks.forEach((chunk, offset) => { + emit('child', { type: 'chunk', attemptId: 'a1', revision: START.revision + 1 + offset, index: offset, time: offset, chunk }) + }) + expect(mirror.step('child')).toBeUndefined() + }) + + it('caps one buffered prefix and keeps its newest text', () => { + const { ctx, emit } = ctxDouble() + const mirror = createAssistantStreamMirror(ctx) + emit('child', START) + emit('child', delta('text-delta', 'x'.repeat(64_000))) + emit('child', delta('text-delta', 'tail')) + emit('child', delta('reasoning-delta', 'y'.repeat(70_000))) + const live = mirror.step('child') + expect(live?.text).toHaveLength(64_000) + expect(live?.text.endsWith('tail')).toBe(true) + expect(live?.reasoning).toHaveLength(64_000) + }) + + it('registers the listener for disposal and degrades without ctx.on', () => { + const { ctx, effect, dispose } = ctxDouble() + createAssistantStreamMirror(ctx) + expect(effect).toHaveBeenCalledTimes(1) + expect(effect.mock.calls[0]![1]).toContain('dsh-better-sidebar') + // The effect setup returns the listener's own disposer. + expect(effect.mock.results[0]!.value).toBe(dispose) + // A test double or a host without the event bus: no live step, ever. + const blind = createAssistantStreamMirror({} as unknown as Context) + expect(blind.step('child')).toBeUndefined() + }) +}) diff --git a/tests/sidechat-routes.spec.ts b/tests/sidechat-routes.spec.ts index ddce8af5a..a03c1469d 100644 --- a/tests/sidechat-routes.spec.ts +++ b/tests/sidechat-routes.spec.ts @@ -164,7 +164,7 @@ describe('sidechat.start', () => { ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }), ev('turn/start', 1, { turn: 1 }), ev('step/start', 2, { turn: 1, step: 1 }), - ev('assistant/chunk', 3, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'streaming' } }), + ev('assistant/message', 3, { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'streaming' }] } }), ], }) const child = agent('child') @@ -175,7 +175,7 @@ describe('sidechat.start', () => { const options = services.create.mock.calls[0]![0] as { seed: Array<{ type: string; data: Record }> } expect(options.seed.map(event => event.type)).toEqual([ - 'user/message', 'turn/start', 'step/start', 'assistant/chunk', 'step/end', 'turn/end', + 'user/message', 'turn/start', 'step/start', 'assistant/message', 'step/end', 'turn/end', 'subagent/descriptor', ]) expect(options.seed.at(-2)?.data).toEqual({ turn: 1, reason: { kind: 'interrupted' } }) @@ -375,8 +375,8 @@ function threadLog(): Array> { ev('subagent/descriptor', 3, { mode: 'continuable' }), ev('user/message', 4, { content: [{ type: 'text', text: 'Side conversation boundary.' }], source: { kind: 'plugin', plugin: 'dsh-better-sidebar' } }), ev('user/message', 5, { content: [{ type: 'text', text: 'the side question' }], source: { kind: 'user' } }), - ev('assistant/chunk', 6, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'an' } }), - ev('assistant/chunk', 7, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'swer' } }), + ev('tool/call', 6, { turn: 1, step: 1, callId: 'c1', name: 'bash', arguments: '{}' }), + ev('assistant/message', 7, { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'answer' }] } }), ] } @@ -427,7 +427,7 @@ describe('sidechat.events', () => { }) it('caps a pathological response at its tail (8000 events)', async () => { - const events = Array.from({ length: 8_003 }, (_, index) => ev('assistant/chunk', index, { turn: 1, step: 1 })) + const events = Array.from({ length: 8_003 }, (_, index) => ev('tool/call', index, { turn: 1, step: 1 })) const child = agent('child', { events }) const services = happyServices(undefined, child) const api = buildSidechatApi(ctxWith(services)) @@ -452,6 +452,35 @@ describe('sidechat.events', () => { .rejects.toMatchObject({ code: 'not-found', status: 404 }) }) + it('carries the in-flight prefix of a streaming step alongside the events', async () => { + // `assistant/chunk` is gone as of DSH 0.1.3-alpha.1, so a step's prose is + // durable only once it settles; the poll carries the live prefix instead. + const child = agent('child', { events: threadLog() }) + const services = happyServices(undefined, child) + let listener: ((payload: { agent: unknown; frame: unknown }) => void) | undefined + const ctx = { + get: (key: string) => (key === 'agents' ? services.agents : key === 'sessionPersistence' ? services.sessionPersistence : undefined), + on: (_event: string, handler: (payload: { agent: unknown; frame: unknown }) => void) => { + listener = handler + return () => {} + }, + effect: (setup: () => unknown) => setup(), + } as unknown as Context + const api = buildSidechatApi(ctx) + + const before = await api['sidechat.events']({ childId: 'child' }) + expect(before.live).toBeUndefined() + + const agentPayload = { session: { id: 'child' } } + // `revision` is allocated per FRAME by the host, so each frame is higher. + listener?.({ agent: agentPayload, frame: { type: 'start', attemptId: 'a1', revision: 1, turn: 1, step: 2 } }) + listener?.({ agent: agentPayload, frame: { type: 'chunk', attemptId: 'a1', revision: 2, index: 0, time: 0, chunk: { type: 'text-delta', text: 'so far' } } }) + + const during = await api['sidechat.events']({ childId: 'child', afterSeq: 7 }) + expect(during.events).toEqual([]) + expect(during.live).toEqual({ turn: 1, step: 2, text: 'so far', reasoning: '' }) + }) + it('fails loudly when a cold thread has no persistence service to read', async () => { const services = happyServices(undefined, agent('child')) const api = buildSidechatApi(ctxWith({ diff --git a/tests/sidechat-seed-validation.spec.ts b/tests/sidechat-seed-validation.spec.ts index 3f929d4ff..06b0cb2de 100644 --- a/tests/sidechat-seed-validation.spec.ts +++ b/tests/sidechat-seed-validation.spec.ts @@ -53,7 +53,7 @@ function parentLog(): SidebarSessionEvent[] { ev('user/message', 6, userMessage('pending question')), ev('turn/start', 7, { turn: 2 }), ev('step/start', 8, { turn: 2, step: 1 }), - ev('assistant/chunk', 9, { turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: 'in-progress' } }), + ev('assistant/message', 9, { turn: 2, step: 1, message: assistantMessage('in-progress') }), ] } @@ -67,7 +67,7 @@ describe('sidechat seed against the real dsh-session validator', () => { const types = child.snapshotEvents().map(event => event.type) expect(types).toEqual([ 'user/message', 'turn/start', 'step/start', 'assistant/message', 'step/end', 'turn/end', - 'user/message', 'turn/start', 'step/start', 'assistant/chunk', 'step/end', 'turn/end', + 'user/message', 'turn/start', 'step/start', 'assistant/message', 'step/end', 'turn/end', 'session/end-seed', ]) // The synthetic close is honest: the frozen turn ends interrupted. diff --git a/tests/sidechat-transcript.spec.ts b/tests/sidechat-transcript.spec.ts index 6c8e53202..f5d9d88be 100644 --- a/tests/sidechat-transcript.spec.ts +++ b/tests/sidechat-transcript.spec.ts @@ -80,39 +80,57 @@ describe('transcriptRows', () => { ]) }) - it('accumulates chunk deltas per block and supersedes them on settle', () => { + it('appends the in-flight prefix as unsettled rows after the log tail', () => { const entries = [ entry(ev('session/end-seed', 0)), entry(ev('user/message', 1, { content: textBlocks('q'), source: { kind: 'user' } })), entry(ev('turn/start', 2, { turn: 1 })), entry(ev('step/start', 3, { turn: 1, step: 1 })), - entry(ev('assistant/chunk', 4, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'Hel' } })), - entry(ev('assistant/chunk', 5, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'lo' } })), - entry(ev('assistant/chunk', 6, { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 1, text: 'think' } })), ] - const rows = transcriptRows(entries) - const assistant = rows.find(row => row.kind === 'assistant') as Extract - expect(assistant.text).toBe('Hello') - expect(assistant.settled).toBe(false) + const rows = transcriptRows(entries, undefined, { turn: 1, step: 1, text: 'Hello', reasoning: 'think' }) const reasoning = rows.find(row => row.kind === 'reasoning') as Extract - expect(reasoning.text).toBe('think') + expect(reasoning).toEqual({ kind: 'reasoning', seq: 4, text: 'think', settled: false }) + const assistant = rows.find(row => row.kind === 'assistant') as Extract + expect(assistant).toEqual({ kind: 'assistant', seq: 4, text: 'Hello', settled: false }) + // Reasoning precedes the answer it produced. + expect(rows.indexOf(reasoning)).toBeLessThan(rows.indexOf(assistant)) }) - it('replaces streaming rows with the settled assistant message', () => { + it('omits an empty half of the prefix instead of pushing a blank row', () => { + const entries = [entry(ev('step/start', 3, { turn: 1, step: 1 }))] + const rows = transcriptRows(entries, undefined, { turn: 1, step: 1, text: 'answer', reasoning: '' }) + expect(rows).toEqual([{ kind: 'assistant', seq: 4, text: 'answer', settled: false }]) + }) + + it('drops the prefix once that step’s assistant message has settled', () => { + // The poll reads the log before the live prefix, so a step settling + // mid-call can arrive in both; the settled message wins outright. const entries = [ entry(ev('session/end-seed', 0)), entry(ev('user/message', 1, { content: textBlocks('q'), source: { kind: 'user' } })), entry(ev('turn/start', 2, { turn: 1 })), entry(ev('step/start', 3, { turn: 1, step: 1 })), - entry(ev('assistant/chunk', 4, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'par' } })), entry(ev('assistant/message', 5, { turn: 1, step: 1, message: { content: textBlocks('final answer') } })), ] - const rows = transcriptRows(entries) + const rows = transcriptRows(entries, undefined, { turn: 1, step: 1, text: 'par', reasoning: '' }) const assistants = rows.filter(row => row.kind === 'assistant') expect(assistants).toHaveLength(1) expect(assistants[0]).toMatchObject({ kind: 'assistant', text: 'final answer', settled: true }) }) + it('keeps the prefix of a LATER step whose earlier sibling already settled', () => { + const entries = [ + entry(ev('step/start', 3, { turn: 1, step: 1 })), + entry(ev('assistant/message', 4, { turn: 1, step: 1, message: { content: textBlocks('step one') } })), + entry(ev('step/start', 5, { turn: 1, step: 2 })), + ] + const rows = transcriptRows(entries, undefined, { turn: 1, step: 2, text: 'step two so far', reasoning: '' }) + expect(rows.filter(row => row.kind === 'assistant')).toEqual([ + { kind: 'assistant', seq: 4, text: 'step one', settled: true }, + { kind: 'assistant', seq: 6, text: 'step two so far', settled: false }, + ]) + }) + it('pairs tool calls with results and marks failures', () => { const entries = [ entry(ev('session/end-seed', 0)), diff --git a/tests/subagent-activity.spec.ts b/tests/subagent-activity.spec.ts index 603ef03fd..6f401b7e8 100644 --- a/tests/subagent-activity.spec.ts +++ b/tests/subagent-activity.spec.ts @@ -50,7 +50,7 @@ describe('subagent activity summary parser', () => { const live = lastActivity([ entry('turn/end', { turn: 1, reason: 'success' }), entry('step/start', { turn: 1, step: 1 }), - entry('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text', delta: 'x' } }), + entry('step/start', { turn: 1, step: 1 }), entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'tool_use', name: 'bash' }] } }), ]) expect(live).toEqual({}) From 7a983e9db070c7a5e7078899d16c101cdb5785d7 Mon Sep 17 00:00:00 2001 From: later0day Date: Sat, 19 Sep 2026 02:08:15 +0800 Subject: [PATCH 2/2] fix(sidebar): adapt turn-tail row to the list-kind slot contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit conversation.chat.turnTail moved from chain-kind (select/priority takeover) to list-kind (id/order, unconditional mount), and ui-deliverables' own entry no longer renders a produced-files row at all — so the old takeover framing throws "requires options.id" and no longer needs same-id shadowing. Register under a distinct id instead; move the render-or-not decision (suspended, editor tab disabled, no match) into the component itself. --- src/client/intercept.tsx | 51 +++++++++++++++++++------------ tests/turn-tail-intercept.spec.ts | 35 +++++++++++---------- 2 files changed, 51 insertions(+), 35 deletions(-) diff --git a/src/client/intercept.tsx b/src/client/intercept.tsx index b4f130bcd..65d21600e 100644 --- a/src/client/intercept.tsx +++ b/src/client/intercept.tsx @@ -1,10 +1,10 @@ /** - * Interception of the chat's produced-files row: the turn-tail chain entry - * that replaces ui-deliverables' row when the closing turn produced files. - * The takeover looks identical (same chip row); the chips open the file in - * the sidebar instead of the host OS. Priority -1 runs before the default-0 - * deliverables entry; when nothing was produced the selector returns null - * and the original row renders unchanged. + * Adds a produced-files row to the chat's turn-tail list: a chip per file + * the closing turn wrote or edited, opening in the sidebar instead of the + * host OS. ui-deliverables' own turn-tail entry covers change announcements + * and explicitly presented files under a different id, so this is an + * additional row, not a takeover — `order: -1` only positions it ahead of + * that entry when both render for the same turn. */ import { IconCodeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context } from '../context-types.ts' @@ -13,6 +13,12 @@ import { t } from './locales.ts' import { resolveSidebarPath, selectProducedFiles } from './produced-files.ts' import css from './sidebar.module.css' +/** Registration-time seats plus the runtime owner currency the component reads its match from. */ +type ProducedFilesTailProps = Record & { + openInSidebar: (path: string) => void + onShowInFolder: (files: readonly string[]) => void +} + /** Open a file in the sidebar's editor (used by the intercepted row and the explorer). */ export function openSidebarFile(ctx: Context, store: SidebarStore, sessionId: string, path: string): void { const summary = ctx.sessions.list.getSnapshot().byId[sessionId] @@ -96,10 +102,10 @@ export function SidebarProducedFiles(props: { } /** - * Register the turn-tail interception (returns the disposer). + * Register the turn-tail row (returns the disposer). * * The slot is a CHILD slot the host's ui-conversation declares in its - * `conversation.chat.node` children table (kind: chain, scope: session). + * `conversation.chat.node` children table (kind: list, scope: session). * Registering it directly races the declaration — the ui-slots core's * load-time validation throws "not declared (a parent entry's children * table must declare it)" when the parent entry is not on the ledger yet. @@ -108,24 +114,31 @@ export function SidebarProducedFiles(props: { * register() call once the declaration commits; declaration collapse * disposes the entry and a later declaration re-registers it. This mirrors * @deepseek-ai/dsh-client-ui-deliverables' registration of the same slot. + * + * List entries carry no `select`: the ui-slots core mounts every registered + * id unconditionally, so the "should this row render" decision (produced + * files present, editor tab enabled, sidebar not suspended) moves into the + * component itself, which returns null to render nothing. */ export function registerTurnTailInterception(ctx: Context, store: SidebarStore): () => void { + function ProducedFilesTail(props: ProducedFilesTailProps) { + // Decline while the editor tab type is disabled in the side card + // settings (chips that cannot open must not be offered), or while the + // sidebar is externally disabled (aionui-panel chosen) or suspended. + if (store.getSuspended()) return null + if (store.getPrefs().tabsEnabled['editor'] === false) return null + const matched = selectProducedFiles(props) + if (matched === null) return null + return + } return ctx.slots.inject('conversation.chat.turnTail', () => ctx.slots.register({ name: 'conversation.chat.turnTail', - // Decline the takeover while the editor tab type is disabled in the side - // card settings: the produced-files row falls back to the default - // deliverables behavior instead of offering chips that cannot open. Also - // while the sidebar is externally disabled (aionui-panel chosen). - select: (owner) => { - if (store.getSuspended()) return null - if (store.getPrefs().tabsEnabled['editor'] === false) return null - return selectProducedFiles(owner) - }, - priority: -1, + id: 'dsh-better-sidebar:produced-files', + order: -1, registrant: 'dsh-better-sidebar', inject: (sessionId: string) => ({ openInSidebar: (path: string) => { openSidebarFile(ctx, store, sessionId, path) }, onShowInFolder: (files: readonly string[]) => { revealInExplorer(ctx, store, sessionId, files) }, }), - }, SidebarProducedFiles)) + }, ProducedFilesTail)) } diff --git a/tests/turn-tail-intercept.spec.ts b/tests/turn-tail-intercept.spec.ts index 352100c44..c1aeb45a8 100644 --- a/tests/turn-tail-intercept.spec.ts +++ b/tests/turn-tail-intercept.spec.ts @@ -1,5 +1,5 @@ /** - * Turn-tail interception registration spec (issue #15): `registerTurnTailInterception` + * Turn-tail row registration spec (issue #15): `registerTurnTailInterception` * must go through `ctx.slots.inject` — the slot is a CHILD slot the host's * ui-conversation declares in its `conversation.chat.node` children table, so a * direct `slots.register` races the declaration and the ui-slots core throws @@ -87,19 +87,20 @@ const clientCtx = (slots: unknown): Context => { } as unknown as Context } -describe('turn-tail interception registration (issue #15)', () => { +describe('turn-tail row registration (issue #15)', () => { it('registers through slots.inject and lands once the slot is already declared', () => { const fake = fakeSlots(true) const store = createSidebarStore() const restore = registerTurnTailInterception(clientCtx(fake.slots), store) - // Exactly one registration, with the takeover descriptor. + // Exactly one registration, with the list-kind row descriptor. expect(fake.registered).toHaveLength(1) const { options, component } = fake.registered[0]! expect(options.name).toBe('conversation.chat.turnTail') - expect(options.priority).toBe(-1) + expect(options.id).toBe('dsh-better-sidebar:produced-files') + expect(options.order).toBe(-1) expect(options.registrant).toBe('dsh-better-sidebar') - expect(options.select).toBeTypeOf('function') + expect(options.select).toBeUndefined() expect(options.inject).toBeTypeOf('function') expect(component).toBeTypeOf('function') @@ -142,26 +143,28 @@ describe('turn-tail interception registration (issue #15)', () => { expect(fake.registered).toHaveLength(0) }) - it('declines the takeover while the editor tab is disabled in the settings', () => { + it('renders nothing while the editor tab is disabled in the settings', () => { const fake = fakeSlots(true) const store = createSidebarStore() const restore = registerTurnTailInterception(clientCtx(fake.slots), store) - const select = fake.registered[0]!.options.select as (owner: unknown) => unknown + const ProducedFilesTail = fake.registered[0]!.component as (props: Record) => { props: { matched: readonly string[] } } | null + const seats = { openInSidebar: vi.fn(), onShowInFolder: vi.fn() } - // Enabled (default): a produced turn claims the chain; an empty one declines. - expect(select(producedOwner(['a.ts', 'b.ts']))).toEqual(['a.ts', 'b.ts']) - expect(select(emptyOwner())).toBeNull() + // Enabled (default): a produced turn renders a row; an empty one renders nothing. + expect(ProducedFilesTail({ ...producedOwner(['a.ts', 'b.ts']) as object, ...seats })?.props.matched).toEqual(['a.ts', 'b.ts']) + expect(ProducedFilesTail({ ...emptyOwner() as object, ...seats })).toBeNull() // The engine Turn data path (the real owner currency: { turn, seq, - // openFile }) claims through the deliverables record too. - expect(select({ + // openFile }) matches through the deliverables record too. + expect(ProducedFilesTail({ turn: { data: { get: (key: string) => key === 'deliverables' ? { produced: [{ seq: 1, path: 'a.ts' }] } : undefined } }, seq: 1, - })).toEqual(['a.ts']) + ...seats, + })?.props.matched).toEqual(['a.ts']) - // Editor tab disabled: even a produced turn falls back to the default - // deliverables row (chips that cannot open must not be offered). + // Editor tab disabled: even a produced turn renders nothing (chips that + // cannot open must not be offered). store.setPrefs({ ...store.getPrefs(), tabsEnabled: { editor: false } }) - expect(select(producedOwner(['a.ts']))).toBeNull() + expect(ProducedFilesTail({ ...producedOwner(['a.ts']) as object, ...seats })).toBeNull() restore() })