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:
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user