fix(sidechat): read the transcript through the plugin's own events route

DSH 0.1.2-alpha.1 removed the client `ctx.connection.api` face (Remote
gateway migration), so every Side Chat transcript poll (`sessions.history`)
threw TypeError into the view's catch and was swallowed — the tab rendered
an empty transcript forever, silently (no console error, so the mount lane
never caught it). The successor APIs are not a drop-in either:
session/follow|page reject plain session addresses for origin:'subagent'
logs (agent-busy fence) and paging cannot read past the current cursor.

Move the transcript to the plugin's own wire — one transport for every
DSH generation:

- new `sidechat.events` route: live threads read the in-memory
  `agent.session.events`; cold threads read `sessionPersistence.inspect`
  (both seams exist since 0.1.0-rc.8; alpha.1's inspect returns the
  expanded logical log, never the packed chunk-row encoding). The
  inherited seed is cut host-side (`threadOwnLogEvents`), polls ride an
  `afterSeq` delta, and the response is tail-capped at 8000 events (the
  old client walk ceiling).
- SideChatView.fetchThread is single-path; collectOwnEvents (the client
  walk) is gone, and so are the ctx.connection mirror and inject entry
  (no consumer left).
- transcriptRows keeps re-cutting at session/end-seed as defense in
  depth.

Verification:
- unit: sidechat.events route (seed cut, afterSeq delta + never
  resurrecting the seed, cap, cold/live sources, 404/503/bad-request),
  full suite 1104 passed; typecheck and build (purity gate) green.
- mount e2e vs a real dsh-v0.1.2-alpha.1 source build: 14/14, including
  the Side Chat tab sweep polling sidechat.events (performance resource
  assertion) and live + post-dispose (persisted) route reads.
- mount e2e vs PATH dsh 0.1.0-rc.8: 14/14 (no regression).
This commit is contained in:
Menghuan1918
2026-08-30 22:25:45 +08:00
parent 3aab7ca3a5
commit 28447ca2e8
12 changed files with 241 additions and 220 deletions
+1
View File
@@ -31,6 +31,7 @@ tag `dsh-v0.1.2-alpha.1`,**尚未发布 npm**(CI 钉版暂不动)。破坏
2. **Remote gateway 取代 ApiProxy**:点分 `POST /api/workspace.create`(裸参数)→ 斜杠 `POST /api/workspace/create`,payload 恰为 `{args: {...}}`,**args 按控制器 TS 参数名包装**(`workspace/create`、`session/create` → `{args:{request:{...}}}`;`session/list` 参数名 `_request` **不可省略**——`{}` 也被拒 `args fields do not match the descriptor`);envelope `method` 与路径一致,点分 404。`hostRpc(api, 'workspace.create', args)` 点分优先、404 回退斜杠并缓存;`SLASH_ARGS_KEY` 由 `tests/e2e-host-protocol.spec.ts` 锁定。
3. **`@deepseek-ai/dsh-client-runtime` 已移除**(继任 seed 是裸名 `@deepseek-ai/dsh-client-store`,无 `/client` 子路径)。插件源码零引用(externals 白名单解析失败被 `buildExternalsRequire` 宽容跳过,见 `src/client/chunk-loader.ts` 头注释),已从 peerDependencies 移除;`dsh.client.inject` 保留该条目无害。
4. **`MarkdownText` labels 契约(真机验证的破坏)**:labels prop 从扁平可选 `codeLabels` 改为**必填嵌套** `labels: { code: { copyLabel, copiedLabel }, footnotes }`——只传旧 prop 时 fence 渲染抛 `Cannot read properties of undefined (reading 'code')`。四个渲染点(mermaid.tsx / MarkdownHtml.tsx / TextEditor.tsx / SideChatView.tsx)统一走 `src/client/markdown-labels.tsx` 的 `markdownTextProps()`(双形状 + 双 prop 名两版通吃;漏传回退硬编码中文)。
5. **侧边对话转录走自有路由,不碰客户端宿主 RPC**:`ctx.connection.api`(含 `sessions.history`)在 alpha.1 整体移除,继任 `session/follow|page` 又对 `origin:'subagent'` 会话强制 subagent 地址(普通 `{kind:'session'}` 被 `agent-busy` 拒)且分页 `throughSeq` 不得超当前游标(纯 page 拿不到尾页)。转录因此由 **`sidechat.events`** 插件路由供给(`src/sidechat-routes.ts`:live 读 `agent.session.events`、冷读 `sessionPersistence.inspect`(两版同形,alpha.1 的 inspect 返回展开后逻辑日志),服务端 `session/end-seed` 切割 + `afterSeq` 增量);`ctx.connection` 镜像与 inject 已删。设计见 [docs/plans/2026-08-20-sidechat-tab-design.md](docs/plans/2026-08-20-sidechat-tab-design.md) §10。
---
@@ -95,3 +95,4 @@
2. 图标语义:线程切换菜单改自绘 `IconHistoryOutline16`(逆时针时钟)、保存按钮改自绘 `IconSaveOutline16`(软盘),沿用 1.5px 描边规范(primitives 无现成 history/save 图标)。
3. 重开线程 Tab 丢工具行:根因**不在宿主**(`session.history` cold 路径原样返回 `tool/call`,见 `api-proxy.ts:749` 与 `chunk-rows.ts` 的无损 chunk 压缩)而在首挂回源窗口——旧实现 8 事件/页 × 32 页 = 256 事件上限,而 cold 读会把 chunk-rows 展开回每个流式 delta 一条事件,一轮回答动辄数百事件,较早的 `tool/call` 掉出窗口(settled 文本靠最终 `assistant/message` 幸存)。修复 = 抽出 `collectOwnEvents`(200 事件/页 × 40 页帽、日志穷尽/无标记时落定 `seedBoundary:0` 停止每轮重走),单测覆盖四分支。
4. Subagent 页「目录损坏」行:见 §5 修正——种子追加 descriptor + SubagentView/subagent-detect 按前缀过滤(`isSideThreadSummary` 同时修好 autoOpenSubagent 误触发与计数膨胀)。旧版创建的线程仍是 corrupt 诊断行,由 SubagentView 的标题前缀过滤兜底隐藏。
- **转录传输迁至自有路由(DSH 0.1.2-alpha.1 适配)**:§6 原设计的时间线轮询走通用 `connection.api.sessions.history` RPC——该客户端面(`ctx.connection.api`)在 0.1.2-alpha.1 的 Remote-gateway 迁移中被整体移除,且其继任者(`session/follow`/`session/page`)对 `origin:'subagent'` 会话强制 subagent 地址寻址(普通 `{kind:'session'}` 地址被 `agent-busy` 拒绝),纯分页也拿不到最新尾页(`throughSeq` 不得超当前游标)。修复 = 新增 **`sidechat.events`** 路由(`src/sidechat-routes.ts`):host 侧读线程日志(live 走 `agent.session.events`,冷线程走 `ctx.get('sessionPersistence').inspect`——两版 DSH 同形,alpha.1 的 inspect 返回展开后的逻辑日志而非磁盘上的打包 chunk-row),服务端切 `session/end-seed`(`threadOwnLogEvents`,`collectOwnEvents` 的 walk 语义下沉),`afterSeq` 增量 + 8000 事件尾部帽(对齐旧 walk 上限 40×200)。客户端 `fetchThread` 单路径化,删除 `collectOwnEvents` 与 `ctx.connection` 镜像/inject。种子切割仍由 `transcriptRows` 客户端二次防御。挂载冒烟补 live+冷读两条断言。
+28 -48
View File
@@ -13,12 +13,15 @@
*
* Each side thread is a child session the plugin created itself with a
* custom seed (the parent's full log up to the click moment — see
* sidechat-core.ts). Transport: thread creation/follow-up/cancel/dispose/
* info go through the plugin's own /sidebar/api sidechat.* routes
* (subagent-origin identities are fenced from the generic session RPCs);
* the transcript is polled from the generic session.history RPC (seed-cut
* at session/end-seed, boundary row dropped, chunk streaming accumulated)
* — see sidechat-transcript.ts.
* sidechat-core.ts). Transport: EVERY thread operation — creation,
* follow-up, cancel, dispose, info, and the transcript itself — goes
* through the plugin's own /sidebar/api sidechat.* routes (subagent-origin
* identities are fenced from the generic session RPCs, and DSH
* 0.1.2-alpha.1's Remote-gateway migration removed the client
* session-history face the transcript used to poll). The transcript route
* cuts the inherited seed host-side and answers afterSeq deltas; the
* mapping (boundary row dropped, chunk streaming accumulated) lives in
* sidechat-transcript.ts.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useSyncExternalStore } from 'react'
@@ -36,7 +39,7 @@ import {
} from '@deepseek-ai/dsh-client-ui-primitives'
import { markdownTextProps } from './markdown-labels.tsx'
import { IconHistoryOutline16, IconSaveOutline16 } from './icons.tsx'
import type { Context, SidebarHistoryEntry } from '../context-types.ts'
import type { Context, SidebarHistoryEntry, SidebarSessionEvent } from '../context-types.ts'
import {
SIDE_LABEL_PREFIX,
SIDE_NEW_THREAD_TITLE,
@@ -45,20 +48,13 @@ import {
threadTrailingPending,
type SidechatThreadInfo,
} from '../sidechat-core.ts'
import { collectOwnEvents, toolArgsSummary, transcriptRows, type SidechatTranscriptRow } from './sidechat-transcript.ts'
import { toolArgsSummary, transcriptRows, type SidechatTranscriptRow } from './sidechat-transcript.ts'
import { api } from './api.ts'
import { t } from './locales.ts'
import type { SessionScope } from './api.ts'
import type { SidebarTab } from './state.ts'
import css from './SideChatView.module.css'
/** Tail-page size for one transcript poll (events per page). Small on
* purpose: streaming polls ride the tail and merge by seq. */
const PAGE_MESSAGES = 8
/** First-attach walk page size: cold reads re-expand chunk-rows into one
* event per streamed delta, so a single answer can be hundreds of events —
* the walk must page big or earlier tool/call rows fall out of the window. */
const WALK_PAGE_EVENTS = 200
/** Poll cadence while the selected thread is running and the tab visible. */
const POLL_MS = 2000
/** Textarea auto-grow ceiling (px) — the composer scrolls beyond it. */
@@ -92,10 +88,9 @@ export function consumeSidechatSeed(): string | undefined {
* StrictMode / HMR must not mint two threads for one tab). */
const inFlightStarts = new Set<string>()
/** Per-thread transcript cache: seed boundary + thread-own events merged by
* seq (streaming polls never re-download the inherited seed). */
/** Per-thread transcript cache: thread-own events merged by seq (polls ride
* the afterSeq delta and never re-download what they already hold). */
interface ThreadCache {
seedBoundary: number | null
entries: SidebarHistoryEntry[]
}
@@ -274,7 +269,7 @@ export function SideChatView(props: {
const [info, setInfo] = useState<SidechatThreadInfo | null>(null)
const [menuOpen, setMenuOpen] = useState(false)
const cacheRef = useRef<ThreadCache>({ seedBoundary: null, entries: [] })
const cacheRef = useRef<ThreadCache>({ entries: [] })
const controllerRef = useRef<AbortController | null>(null)
const scrollRef = useRef<HTMLDivElement | null>(null)
const composerRef = useRef<HTMLTextAreaElement | null>(null)
@@ -327,43 +322,28 @@ export function SideChatView(props: {
}
}, [summary, tab.id, tab.title, ctx])
/** One transcript pull: the first read walks back to the seed boundary
* (big pages — chunk deltas re-expand on cold reads), later reads fetch
* one tail page and merge (seq-deduped). */
/** One transcript pull: the thread's own events beyond the cached tail
* (first attach = the whole seed-cut slice; polls = afterSeq deltas),
* merged by seq. */
const fetchThread = useCallback(async (childId: string): Promise<void> => {
controllerRef.current?.abort()
const controller = new AbortController()
controllerRef.current = controller
const cache = cacheRef.current
try {
if (cache.seedBoundary === null) {
const walk = await collectOwnEvents(async (beforeSeq) => {
const response = await ctx.connection.api.sessions.history(
{
sessionId: childId,
maxMessages: WALK_PAGE_EVENTS,
...(beforeSeq === undefined ? {} : { beforeSeq }),
},
controller.signal,
)
if (!response.result.ok) throw new Error('history walk failed')
return response.result.value.events
})
cache.seedBoundary = walk.seedBoundary
cache.entries = mergeBySeq(cache.entries, walk.entries)
} else {
const response = await ctx.connection.api.sessions.history(
{ sessionId: childId, maxMessages: PAGE_MESSAGES },
controller.signal,
)
if (!response.result.ok) return
cache.entries = mergeBySeq(cache.entries, response.result.value.events)
const cache = cacheRef.current
const afterSeq = cache.entries.at(-1)?.event.seq
const { events } = await api.sidechatEvents(childId, afterSeq, controller.signal)
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)
}
setRevision(value => value + 1)
} catch {
// Aborted by a newer pull or a wire failure: keep the last rows.
}
}, [ctx])
}, [])
/** The thread header badge pull (live state + preset/model identity). */
const fetchInfo = useCallback(async (childId: string): Promise<void> => {
@@ -377,7 +357,7 @@ export function SideChatView(props: {
// Reset the transcript cache whenever the binding changes, then focus
// the composer — it owns the first message of a fresh thread.
useEffect(() => {
cacheRef.current = { seedBoundary: null, entries: [] }
cacheRef.current = { entries: [] }
controllerRef.current?.abort()
setError(null)
setSaved(false)
+9 -1
View File
@@ -8,7 +8,7 @@
*/
import { encodeHtmlUrl } from '../html-route.ts'
import type { LastActivity } from '../subagent-activity.ts'
import type { SidechatThreadInfo } from '../sidechat-core.ts'
import type { SidechatLogEvent, SidechatThreadInfo } from '../sidechat-core.ts'
import type { BrowserProbeResult } from './browser.ts'
/** One wire failure. */
@@ -317,6 +317,14 @@ export const api = {
/** Live state + agent identity (provider/model/preset) of a thread. */
sidechatInfo: (childId: string) =>
call<SidechatThreadInfo>('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). */
sidechatEvents: (childId: string, afterSeq?: number, signal?: AbortSignal) =>
call<{ events: SidechatLogEvent[] }>('sidechat.events', {
childId,
...(afterSeq !== undefined ? { afterSeq } : {}),
}, signal),
/** The effective terminal shell and its display name (plugin-global). */
shellGet: () =>
call<{ shell: string; name: string }>('shell.get', {}),
+1 -1
View File
@@ -35,7 +35,7 @@ import './layout.css'
* locale service backs the sidebar's copy — see locales.ts). `modules`
* (rc.8+) is the client module system the chunk loader resolves its
* externals through — Cordis guards service access without inject. */
export const inject = ['slots', 'sessions', 'connection', 'workspaces', 'locale', 'modules']
export const inject = ['slots', 'sessions', 'workspaces', 'locale', 'modules']
/**
* Error boundary over the sidebar tree (root scope): a render error in the
+10 -60
View File
@@ -1,13 +1,16 @@
/**
* Side Chat transcript mapping (browser half): turns a thread child's
* history rows (`session.history` — the generic RPC, which reads the durable
* log without activating the child) into compact display rows.
* Side Chat transcript mapping (browser half): turns a thread child's own
* events (`sidechat.events` — the plugin route, which reads the log without
* activating the child and cuts the inherited seed host-side) into compact
* display rows.
*
* A thread child's log starts with the ENTIRE inherited parent log as its
* fork seed. The mapping therefore cuts everything up to the LAST
* `session/end-seed` marker and maps context injections (the "Side
* conversation boundary" prompt, plugin-sourced context) onto a collapsible
* injection row, so the view shows only the thread's own conversation.
* fork seed. The route already cuts everything up to the LAST
* `session/end-seed` marker; the mapping re-applies the same cut so any
* seed event that somehow crosses the wire still never renders, and maps
* context injections (the "Side conversation boundary" prompt,
* 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
@@ -124,59 +127,6 @@ function lastSeedEnd(events: readonly { type: string }[]): number {
return -1
}
/**
* Collect the thread's OWN events on first attach: walk backward from the
* log tail (oldest-first accumulation) until the `session/end-seed` marker
* surfaces, then keep everything after it.
*
* Page size matters: cold reads re-expand persisted chunk-rows into one
* `assistant/chunk` event per delta, so a single streamed answer can be
* HUNDREDS of events. A small walk window (the old 8×32 = 256 events) let
* earlier `tool/call` events fall out of the loaded window — the tool rows
* vanished on re-entry while the settled text survived. The walk therefore
* pages big; tail polls stay small.
*
* Exhaustion (log start reached without a marker — a thread created before
* seeding existed, or a pathological log) returns `seedBoundary: 0` so the
* caller stops re-walking and renders the window as-is.
*
* @param fetchPage - one history page (newest-first window ending at
* `beforeSeq`, exclusive; omit for the tail page).
* @param pageCap - safety bound on backward pages.
*/
export async function collectOwnEvents(
fetchPage: (beforeSeq?: number) => Promise<readonly SidebarHistoryEntry[]>,
pageCap = 40,
): Promise<{ seedBoundary: number; entries: SidebarHistoryEntry[] }> {
const collected: SidebarHistoryEntry[] = []
let beforeSeq: number | undefined
for (let page = 0; page < pageCap; page++) {
const events = await fetchPage(beforeSeq)
if (events.length === 0) {
// Log start reached without a marker: the window IS the whole log.
return { seedBoundary: 0, entries: collected }
}
const olderThan = collected.length > 0 ? collected[0]!.event.seq : undefined
const fresh = olderThan === undefined
? [...events]
: events.filter(entry => entry.event.seq < olderThan)
const seedEnd = fresh.findLastIndex(entry => entry.event.type === 'session/end-seed')
if (seedEnd >= 0) {
collected.unshift(...fresh.slice(seedEnd + 1))
return { seedBoundary: fresh[seedEnd]!.event.seq, entries: collected }
}
collected.unshift(...fresh)
if (fresh.length === 0) {
// The page overlaps entirely with what we have: nothing older exists.
return { seedBoundary: 0, entries: collected }
}
beforeSeq = fresh[0]!.event.seq
}
// Cap hit: accept the window (it is overwhelmingly the thread's own tail)
// rather than re-walking on every poll.
return { seedBoundary: 0, entries: collected }
}
/**
* Map a thread child's history rows onto compact transcript rows: the
* inherited fork seed is cut at the last `session/end-seed`, context
-35
View File
@@ -304,39 +304,6 @@ export interface SidebarSessionPersistenceService {
}>
}
/** RPC result slot mirror (`RpcResult<T>` on the wire). */
export type SidebarRpcResult<T> = { ok: true; value: T } | { ok: false; error: { code: string; message: string } }
/** Unary response mirror (`RpcResponse<T>` on the wire). */
export interface SidebarRpcResponse<T> {
rpcId: unknown
result: SidebarRpcResult<T>
}
/** The generic session-history RPC face the Side Chat transcript polls
* (subagent.history verifies subagent-catalog membership, which our custom
* side-thread children do not have — the generic session.history reads any
* durable log directly). */
export interface SidebarSessionHistoryRpc {
history(
payload: { sessionId: string; beforeSeq?: number; maxMessages?: number },
signal?: AbortSignal,
): Promise<SidebarRpcResponse<{ events: SidebarHistoryEntry[]; hasMore: boolean }>>
}
/** The wire face the Subagent activity summary needs (subset of `ctx.connection`). */
export interface SidebarConnectionHandle {
api: {
sessions: SidebarSessionHistoryRpc
subagents: {
history(
payload: SidebarSubagentAddress & { beforeSeq?: number; maxMessages?: number },
signal?: AbortSignal,
): Promise<SidebarRpcResponse<{ events: SidebarHistoryEntry[]; hasMore: boolean }>>
}
}
}
/** The client session list snapshot the sidebar subscribes to. */
export interface SidebarSessionList {
current: string | undefined
@@ -527,8 +494,6 @@ export interface SidebarContextShape {
webServer: SidebarWebServer
/** The session store (host `.get`) and the client list feed (`.list`) faces. */
sessions: SidebarSessionStore & SidebarSessionsService
/** The wire handle the Side Chat transcript polls through. */
connection: SidebarConnectionHandle
/** The web runtime trust list (bind-derived). */
webRuntime: SidebarWebRuntime
/** The client slot registry (register/inject). */
+9 -4
View File
@@ -416,13 +416,18 @@ export interface SidechatThreadInfo {
}
/** The events a thread produced itself: everything after the LAST
* `session/end-seed` marker (the fork-seed boundary). */
export function threadOwnEvents(entries: readonly SidebarHistoryEntry[]): SidechatLogEvent[] {
const events = entries.map(entry => entry.event)
* `session/end-seed` marker (the fork-seed boundary). A log with no marker
* (a thread created before seeding existed) is returned whole. */
export function threadOwnLogEvents(events: readonly SidechatLogEvent[]): SidechatLogEvent[] {
for (let index = events.length - 1; index >= 0; index--) {
if (events[index]?.type === 'session/end-seed') return events.slice(index + 1)
}
return events
return [...events]
}
/** {@link threadOwnLogEvents} over history rows (the client cache shape). */
export function threadOwnEvents(entries: readonly SidebarHistoryEntry[]): SidechatLogEvent[] {
return threadOwnLogEvents(entries.map(entry => entry.event))
}
/**
+58 -2
View File
@@ -1,6 +1,7 @@
/**
* Side Chat routes of the /sidebar JSON API ('sidechat.start' /
* 'sidechat.prompt' / 'sidechat.cancel' / 'sidechat.dispose').
* 'sidechat.prompt' / 'sidechat.cancel' / 'sidechat.dispose' /
* 'sidechat.info' / 'sidechat.events').
*
* A side thread is a child session the plugin creates ITSELF with a custom
* seed — the parent's full event log up to the click moment, honestly closed
@@ -43,10 +44,11 @@ import {
type SeedEvent,
type SidechatLogEvent,
type SidechatThreadInfo,
threadOwnLogEvents,
} from './sidechat-core.ts'
import { requireString, SidebarError } from './wire.ts'
/** The five Side Chat routes of the sidebar API (wire method names). */
/** The six Side Chat routes of the sidebar API (wire method names). */
export interface SidechatRoutes {
/** Create a side thread child seeded with the parent's log up to now.
* `question` is optional: empty creates an EMPTY thread (Codex-style
@@ -61,12 +63,22 @@ export interface SidechatRoutes {
'sidechat.dispose'(payload: unknown): Promise<{ accepted: true }>
/** Live state + agent identity for the thread header. */
'sidechat.info'(payload: unknown): Promise<SidechatThreadInfo>
/** 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[] }>
}
/** Timeout guarding the create call (the registry detaches it before the
* handle becomes visible, so the child is never cancelled by it). */
const CREATE_TIMEOUT_MS = 15_000
/** Head cap of one `sidechat.events` response (the ceiling the old
* client-side walk could load: 40 pages × 200 events). A pathological
* thread beyond it renders its tail window — the same degradation the
* capped walk had, never a failed poll. */
const EVENTS_CAP = 8_000
/** Per-activation disposers of created thread agents (the dispose route
* releases them; the session and its history always stay persisted). */
const threadDisposers = new Map<string, () => Promise<void>>()
@@ -151,6 +163,37 @@ function liveThreadAgent(ctx: Context, childId: string): Agent | undefined {
return agents?.get(childId)
}
/**
* The thread's event log (seed + its own events, already expanded): the live
* agent's in-memory log while the thread is attached — the freshest read,
* including events not yet flushed — else the persisted logical log. Both
* DSH generations expose these seams with the same shape (the 0.1.2
* persistence layer packs chunk rows on disk but expands them on inspect),
* which is why the transcript reads here instead of the client's
* session-history RPC: that face (`ctx.connection.api`) was removed in
* 0.1.2-alpha.1's Remote-gateway migration.
*/
async function threadLogEvents(ctx: Context, childId: string): Promise<readonly SidechatLogEvent[]> {
const agent = liveThreadAgent(ctx, childId)
if (agent !== undefined) {
return agent.session.events as unknown as readonly SidechatLogEvent[]
}
const persistence = ctx.get('sessionPersistence') as SidebarSessionPersistenceService | undefined
if (persistence === undefined) {
throw new SidebarError('sidechat-error', 'the session persistence service is unavailable', 503)
}
try {
const inspected = await persistence.inspect(childId)
return inspected.events as unknown as readonly SidechatLogEvent[]
} catch (error: unknown) {
throw new SidebarError(
'not-found',
`thread "${childId}" is not available: ${error instanceof Error ? error.message : String(error)}`,
404,
)
}
}
/** Build the Side Chat routes (all optional services degrade to a wire
* error the tab surfaces inline). The record keys are the FULL wire method
* names the /sidebar/api dispatcher looks up (`api[method]`). */
@@ -340,5 +383,18 @@ export function buildSidechatApi(ctx: Context): SidechatRoutes {
}
return { live: false }
},
'sidechat.events': async (payload: unknown) => {
const childId = requireString(payload, 'childId')
const rawAfter = (payload as { afterSeq?: unknown }).afterSeq
if (rawAfter !== undefined
&& (typeof rawAfter !== 'number' || !Number.isSafeInteger(rawAfter) || rawAfter < 0)) {
throw new SidebarError('bad-request', 'afterSeq must be a non-negative integer')
}
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 }
},
}
}
+19
View File
@@ -288,6 +288,13 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
await assertNoCrash()
}
// The sweep opened the Side Chat tab, whose view auto-creates a thread and
// polls the transcript — that poll MUST ride the plugin's own
// sidechat.events route (the DSH-0.1.2-safe transport this lane locks).
const polledTranscript = await page.evaluate(() =>
performance.getEntriesByType('resource').some(entry => entry.name.includes('/sidebar/api/sidechat.events')))
expect(polledTranscript, 'the Side Chat tab must poll sidechat.events for its transcript').toBe(true)
// Side Chat host-route smoke against the REAL host: create a thread child
// under the seeded session (custom-seed creation through AgentRegistry),
// deliver a follow-up, cancel, and release it. The turn itself cannot run
@@ -315,12 +322,24 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
listedItems.some(item => item.sessionId === childId),
'the thread child must appear in the host session list',
).toBe(true)
// Transcript read from the LIVE agent's log (the transport the tab polls;
// the inherited seed is cut host-side, so with no model route the own
// slice carries no message events).
const eventsLive = await api.post(sidebarApi('sidechat.events'), { data: { childId } })
expect(eventsLive.ok(), `sidechat.events (live): ${eventsLive.status()} ${await eventsLive.text()}`).toBe(true)
const eventsLiveBody = (await eventsLive.json()) as { ok: boolean; value?: { events?: Array<{ type: string }> } }
expect(eventsLiveBody.ok, `sidechat.events envelope: ${JSON.stringify(eventsLiveBody)}`).toBe(true)
expect(Array.isArray(eventsLiveBody.value?.events), 'sidechat.events must answer an events array').toBe(true)
for (const method of ['sidechat.prompt', 'sidechat.cancel', 'sidechat.dispose']) {
const response = await api.post(sidebarApi(method), {
data: method === 'sidechat.prompt' ? { childId, text: 'follow-up' } : { childId },
})
expect(response.ok(), `${method}: ${response.status()} ${await response.text()}`).toBe(true)
}
// After dispose the agent is gone: the same read must fall back to the
// PERSISTED log (the cold path a re-opened tab polls).
const eventsCold = await api.post(sidebarApi('sidechat.events'), { data: { childId } })
expect(eventsCold.ok(), `sidechat.events (cold): ${eventsCold.status()} ${await eventsCold.text()}`).toBe(true)
// The Codex-style immediate-create flow: a blank question creates an
// EMPTY thread (no prompt admitted), sidechat.info reports the live
+104 -5
View File
@@ -1,9 +1,11 @@
/**
* Host route tests for the Side Chat API ('sidechat.start' / 'sidechat.prompt'
* / 'sidechat.cancel' / 'sidechat.dispose'): the custom-seed thread creation
* (with the in-progress-turn synthetic close and the dangling-tool-call
* snapshot fallback), the boundary+question first prompt, follow-ups on live
* and cold (resumed) agents, cancel, and dispose.
* / 'sidechat.cancel' / 'sidechat.dispose' / 'sidechat.info' /
* 'sidechat.events'): the custom-seed thread creation (with the
* in-progress-turn synthetic close and the dangling-tool-call snapshot
* fallback), the boundary+question first prompt, follow-ups on live and cold
* (resumed) agents, cancel, dispose, and the transcript reads (seed cut,
* afterSeq deltas, tail cap, live/persisted sources).
*/
import { describe, expect, it, vi } from 'vitest'
import { buildSidechatApi } from '../src/sidechat-routes.ts'
@@ -45,7 +47,7 @@ function happyServices(parent: AgentLike | undefined, child: AgentLike) {
const rename = vi.fn((_session: unknown, _title: string) => ({ title: 'x', eventSeq: 1 }))
const resolve = vi.fn(async (_id?: string) => ({ id: 'preset-a' }))
const mount = vi.fn(async () => {})
const inspect = vi.fn(async () => ({ meta: { agentPreset: 'preset-a' }, events: [] }))
const inspect = vi.fn(async (_id?: string): Promise<{ meta: Record<string, unknown>; events: unknown[] }> => ({ meta: { agentPreset: 'preset-a' }, events: [] }))
return {
agents: { get, create, resume },
agentPresets: { resolve, mount },
@@ -359,3 +361,100 @@ describe('sidechat.info', () => {
await expect(api['sidechat.info']({ childId: 'ghost' })).resolves.toEqual({ live: false })
})
})
/** A realistic thread log: the inherited seed, the end-seed marker, the
* descriptor the seed appends, and the thread's own conversation. */
function threadLog(): Array<ReturnType<typeof ev>> {
return [
ev('user/message', 0, { content: [{ type: 'text', text: 'inherited parent question' }], source: { kind: 'user' } }),
ev('turn/end', 1, { turn: 0, reason: { kind: 'completed' } }),
ev('session/end-seed', 2),
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' } }),
]
}
describe('sidechat.events', () => {
it('returns the thread-own slice from the LIVE agent log (seed cut host-side)', async () => {
const child = agent('child', { events: threadLog() })
const services = happyServices(undefined, child)
const api = buildSidechatApi(ctxWith(services))
const result = await api['sidechat.events']({ childId: 'child' })
expect(result.events.map(event => event.seq)).toEqual([3, 4, 5, 6, 7])
expect(result.events[0]).toMatchObject({ type: 'subagent/descriptor', seq: 3 })
expect(services.inspect).not.toHaveBeenCalled()
})
it('narrows to the afterSeq delta on polls (and never resurrects the seed)', async () => {
const child = agent('child', { events: threadLog() })
const services = happyServices(undefined, child)
const api = buildSidechatApi(ctxWith(services))
const delta = await api['sidechat.events']({ childId: 'child', afterSeq: 6 })
expect(delta.events.map(event => event.seq)).toEqual([7])
// An afterSeq below the boundary must still not return the inherited seed.
const fromZero = await api['sidechat.events']({ childId: 'child', afterSeq: 0 })
expect(fromZero.events.map(event => event.seq)).toEqual([3, 4, 5, 6, 7])
})
it('reads a COLD thread from persistence when no agent is live', async () => {
const child = agent('child')
const services = happyServices(undefined, child)
services.agents.get = vi.fn((_id: unknown) => undefined)
services.inspect.mockImplementation(async () => ({ meta: { agentPreset: 'preset-a' }, events: threadLog() }))
const api = buildSidechatApi(ctxWith(services))
const result = await api['sidechat.events']({ childId: 'child' })
expect(result.events.map(event => event.seq)).toEqual([3, 4, 5, 6, 7])
expect(services.inspect).toHaveBeenCalledWith('child')
})
it('returns a marker-less legacy log whole', async () => {
const child = agent('child', {
events: [
ev('user/message', 0, { content: [{ type: 'text', text: 'legacy' }], source: { kind: 'user' } }),
ev('assistant/message', 1, { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'a' }] } }),
],
})
const services = happyServices(undefined, child)
const api = buildSidechatApi(ctxWith(services))
const result = await api['sidechat.events']({ childId: 'child' })
expect(result.events.map(event => event.seq)).toEqual([0, 1])
})
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 child = agent('child', { events })
const services = happyServices(undefined, child)
const api = buildSidechatApi(ctxWith(services))
const result = await api['sidechat.events']({ childId: 'child' })
expect(result.events).toHaveLength(8_000)
expect(result.events[0]).toMatchObject({ seq: 3 })
expect(result.events.at(-1)).toMatchObject({ seq: 8_002 })
})
it('rejects an invalid afterSeq and reports a missing thread as not-found', async () => {
const child = agent('child', { events: threadLog() })
const services = happyServices(undefined, child)
const api = buildSidechatApi(ctxWith(services))
await expect(api['sidechat.events']({ childId: 'child', afterSeq: -1 }))
.rejects.toMatchObject({ code: 'bad-request' })
await expect(api['sidechat.events']({ childId: 'child', afterSeq: 1.5 }))
.rejects.toMatchObject({ code: 'bad-request' })
services.agents.get = vi.fn((_id: unknown) => undefined)
services.inspect.mockImplementation(async () => { throw new Error('no such session') })
await expect(api['sidechat.events']({ childId: 'ghost' }))
.rejects.toMatchObject({ code: 'not-found', status: 404 })
})
it('fails loudly when a cold thread has no persistence service to read', async () => {
const services = happyServices(undefined, agent('child'))
const api = buildSidechatApi(ctxWith({
agents: { ...services.agents, get: vi.fn((_id: unknown) => undefined) },
}))
await expect(api['sidechat.events']({ childId: 'child' }))
.rejects.toMatchObject({ code: 'sidechat-error', status: 503 })
})
})
+1 -64
View File
@@ -8,7 +8,7 @@
import { describe, expect, it } from 'vitest'
import type { SidebarHistoryEntry, SidebarSessionEvent } from '../src/context-types.ts'
import { SIDE_BOUNDARY_PREFIX, SIDE_BOUNDARY_PROMPT, SIDE_INJECTION_PLUGIN } from '../src/sidechat-core.ts'
import { collectOwnEvents, toolArgsSummary, transcriptRows, type SidechatTranscriptRow } from '../src/client/sidechat-transcript.ts'
import { toolArgsSummary, transcriptRows, type SidechatTranscriptRow } from '../src/client/sidechat-transcript.ts'
/** One history entry (event + optional view). */
function entry(event: SidebarSessionEvent): SidebarHistoryEntry {
@@ -164,69 +164,6 @@ describe('transcriptRows', () => {
})
})
describe('collectOwnEvents', () => {
/** A paginated fake log: fetchPage(beforeSeq) returns the ≤pageSize events
* ENDING before beforeSeq (exclusive); the tail page when omitted. */
function pagedLog(events: SidebarSessionEvent[], pageSize: number) {
const calls: Array<number | undefined> = []
const fetchPage = async (beforeSeq?: number): Promise<SidebarHistoryEntry[]> => {
calls.push(beforeSeq)
const window = beforeSeq === undefined ? events : events.filter(e => e.seq < beforeSeq)
return window.slice(-pageSize).map(entry)
}
return { fetchPage, calls }
}
it('finds the seed boundary on the tail page in one fetch', async () => {
const { fetchPage, calls } = pagedLog([
ev('user/message', 0, { content: textBlocks('inherited'), source: { kind: 'user' } }),
ev('session/end-seed', 1),
ev('user/message', 2, { content: textBlocks('own'), source: { kind: 'user' } }),
ev('assistant/message', 3, { turn: 1, step: 1, message: { content: textBlocks('a') } }),
], 200)
const result = await collectOwnEvents(fetchPage)
expect(calls).toEqual([undefined])
expect(result.seedBoundary).toBe(1)
expect(result.entries.map(e => e.event.seq)).toEqual([2, 3])
})
it('walks back across pages until the boundary surfaces', async () => {
// A dense chunk tail (cold reads re-expand chunk-rows): the boundary is
// several pages back and earlier tool/call events must NOT fall out of
// the loaded window (the re-entry tool-row regression).
const events = [
ev('session/end-seed', 0),
ev('tool/call', 1, { turn: 1, step: 1, callId: 'c1', name: 'bash' }),
...Array.from({ length: 12 }, (_, i) => ev('assistant/chunk', 2 + i, { turn: 1, step: 1 })),
]
const { fetchPage, calls } = pagedLog(events, 5)
const result = await collectOwnEvents(fetchPage)
expect(calls.length).toBeGreaterThan(1)
expect(result.seedBoundary).toBe(0)
// Everything after the marker, oldest-first, deduped.
expect(result.entries.map(e => e.event.seq)).toEqual(events.slice(1).map(e => e.seq))
})
it('stops at the log start when no marker exists and reports boundary 0', async () => {
const { fetchPage } = pagedLog([
ev('user/message', 0, { content: textBlocks('legacy'), source: { kind: 'user' } }),
ev('assistant/message', 1, { turn: 1, step: 1, message: { content: textBlocks('a') } }),
], 200)
const result = await collectOwnEvents(fetchPage)
expect(result.seedBoundary).toBe(0)
expect(result.entries.map(e => e.event.seq)).toEqual([0, 1])
})
it('honours the page cap instead of re-walking forever', async () => {
const events = Array.from({ length: 30 }, (_, i) => ev('assistant/chunk', i, { turn: 1, step: 1 }))
const { fetchPage, calls } = pagedLog(events, 5)
const result = await collectOwnEvents(fetchPage, 2)
expect(calls).toEqual([undefined, 25])
expect(result.seedBoundary).toBe(0)
expect(result.entries.map(e => e.event.seq)).toEqual([20, 21, 22, 23, 24, 25, 26, 27, 28, 29])
})
})
describe('toolArgsSummary', () => {
it('picks the most identifying string field', () => {
expect(toolArgsSummary('{"command":"ls -la","timeout":1000}')).toBe('ls -la')