diff --git a/AGENTS.md b/AGENTS.md index 6e5ada3..37f28aa 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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。 --- diff --git a/docs/plans/2026-08-20-sidechat-tab-design.md b/docs/plans/2026-08-20-sidechat-tab-design.md index 47b54b2..26461ad 100644 --- a/docs/plans/2026-08-20-sidechat-tab-design.md +++ b/docs/plans/2026-08-20-sidechat-tab-design.md @@ -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+冷读两条断言。 diff --git a/src/client/SideChatView.tsx b/src/client/SideChatView.tsx index 0dc500d..b021acb 100644 --- a/src/client/SideChatView.tsx +++ b/src/client/SideChatView.tsx @@ -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() -/** 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(null) const [menuOpen, setMenuOpen] = useState(false) - const cacheRef = useRef({ seedBoundary: null, entries: [] }) + const cacheRef = useRef({ entries: [] }) const controllerRef = useRef(null) const scrollRef = useRef(null) const composerRef = useRef(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 => { 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 => { @@ -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) diff --git a/src/client/api.ts b/src/client/api.ts index 7825dbf..9391c4a 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 { 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('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', {}), diff --git a/src/client/index.tsx b/src/client/index.tsx index 3dfb0d7..6923c5e 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -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 diff --git a/src/client/sidechat-transcript.ts b/src/client/sidechat-transcript.ts index 37ddbb1..9850d31 100644 --- a/src/client/sidechat-transcript.ts +++ b/src/client/sidechat-transcript.ts @@ -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, - 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 diff --git a/src/context-types.ts b/src/context-types.ts index a62333b..251fc5b 100644 --- a/src/context-types.ts +++ b/src/context-types.ts @@ -304,39 +304,6 @@ export interface SidebarSessionPersistenceService { }> } -/** RPC result slot mirror (`RpcResult` on the wire). */ -export type SidebarRpcResult = { ok: true; value: T } | { ok: false; error: { code: string; message: string } } - -/** Unary response mirror (`RpcResponse` on the wire). */ -export interface SidebarRpcResponse { - rpcId: unknown - result: SidebarRpcResult -} - -/** 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> -} - -/** 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> - } - } -} - /** 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). */ diff --git a/src/sidechat-core.ts b/src/sidechat-core.ts index 1171dd3..a818ef6 100644 --- a/src/sidechat-core.ts +++ b/src/sidechat-core.ts @@ -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)) } /** diff --git a/src/sidechat-routes.ts b/src/sidechat-routes.ts index c718de9..5149c20 100644 --- a/src/sidechat-routes.ts +++ b/src/sidechat-routes.ts @@ -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 + /** 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 Promise>() @@ -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 { + 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 } + }, } } diff --git a/tests/e2e/mount.e2e.ts b/tests/e2e/mount.e2e.ts index ce053ca..25ad5ce 100644 --- a/tests/e2e/mount.e2e.ts +++ b/tests/e2e/mount.e2e.ts @@ -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 diff --git a/tests/sidechat-routes.spec.ts b/tests/sidechat-routes.spec.ts index 749ed97..f762801 100644 --- a/tests/sidechat-routes.spec.ts +++ b/tests/sidechat-routes.spec.ts @@ -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; 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> { + 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 }) + }) +}) diff --git a/tests/sidechat-transcript.spec.ts b/tests/sidechat-transcript.spec.ts index 06c8164..d47650d 100644 --- a/tests/sidechat-transcript.spec.ts +++ b/tests/sidechat-transcript.spec.ts @@ -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 = [] - const fetchPage = async (beforeSeq?: number): Promise => { - 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')