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
+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')