feat(dsh): adapt to DSH 0.1.5-alpha.1

Move the plugin onto the DSH 0.1.5-alpha.1 baseline (peer floor
^0.1.5-alpha.1, CI pinned to the alpha dist-tag) and fix the two host
contract changes that alpha line introduced.

Baseline:
- all @deepseek-ai/dsh-* peer/dev deps -> 0.1.5-alpha.1; optional peer
  @deepseek-ai/dsh-client-ui-sidebar-right (declared, unused until the
  native-surface phase); devDep @deepseek-ai/dsh-agent-loop (inbox fold)
- dsh.plugin.json engines >=0.1.5-alpha.1; version 0.19.0-alpha.0;
  SIDEBAR_SERVICE_VERSION follows

Live assistant stream (assistant/chunk is gone):
- 0.1.5 publishes in-flight model deltas as process-local
  agent/assistant-stream frames instead of durable assistant/chunk events
  and embeds the settled stream in assistant/message / assistant/attempt.
  src/assistant-live.ts folds the frames into a bounded per-session buffer;
  sidechat.events serves the current attempt as `live` rows (replaced every
  poll, never a delta) and the transcript renders them; the inherited
  in-progress snapshot reads message content / attempt stream / live frames.

Cold session reads (persistence.inspect is gone):
- 0.1.5 reads a stored session through an explicit read handle
  (open(id,'read') -> handle.read() -> close()). src/session-store.ts owns
  that protocol; every cold read (sidechat transcript, cold-resume preset,
  session.cwd, changes replay) goes through it.

Dead code and docs:
- drop the remote.session.openWorkspacePath interception: 0.1.5's chat opens
  files through ctx.sidebarRight.openResource instead, so the wrapper had no
  caller and would hijack the host's "open in app" gesture; the
  interceptOpenPath pref, its settings row and its locale keys go with it
- AGENTS.md §3 + external-plugin-guide.md describe the new baseline
- vitest setup bridges Node's localStorage accessor to jsdom's store (the
  pre-existing 26 failures on Node >= 22)
- desktop-layout e2e seeds through host RPC and types into the Lexical
  composer (0.1.5 replaced the textarea) instead of driving the directory
  picker

Verified: pnpm typecheck / lint / build / test (124 files, 1327 tests) /
check:consumer-types, plus pnpm test:mount against a real
@deepseek-ai/dsh@0.1.5-alpha.1 host (18/18).
This commit is contained in:
Menghuan1918
2026-09-09 11:15:30 +08:00
parent d88dcfc3a5
commit ba0114749f
59 changed files with 1163 additions and 1011 deletions
+132
View File
@@ -0,0 +1,132 @@
/**
* Unit tests for the live assistant stream buffer (src/assistant-live.ts):
* the fold that replaces DSH 0.1.2's durable `assistant/chunk` events with
* the process-local `agent/assistant-stream` frames 0.1.5 publishes instead.
*/
import { describe, expect, it, vi } from 'vitest'
import { createAssistantLiveBuffer, LIVE_CHUNK_CAP, LIVE_SESSION_CAP } from '../src/assistant-live.ts'
import type { Context } from '../src/context-types.ts'
/** A context whose `on` records the stream listener the buffer installs. */
function harness(): {
ctx: Context
emit: (payload: unknown) => void
off: ReturnType<typeof vi.fn>
} {
let listener: ((payload: unknown) => void) | undefined
const off = vi.fn()
const ctx = {
on: (event: string, fn: (payload: unknown) => void) => {
expect(event).toBe('agent/assistant-stream')
listener = fn
return off
},
} as unknown as Context
return { ctx, emit: payload => { listener?.(payload) }, off }
}
/** One frame as DSH emits it, for the session named by `sessionId`. */
function frame(sessionId: string, frame: Record<string, unknown>): Record<string, unknown> {
return { agent: { session: { id: sessionId } }, frame }
}
describe('createAssistantLiveBuffer', () => {
it('folds start + chunk frames into ordered per-session chunks', () => {
const { ctx, emit } = harness()
const live = createAssistantLiveBuffer(ctx)
emit(frame('s1', { type: 'start', attemptId: 'a1', revision: 1, turn: 2, step: 1 }))
emit(frame('s1', { type: 'chunk', attemptId: 'a1', revision: 1, index: 0, time: 10, chunk: { type: 'text-delta', index: 0, text: 'he' } }))
emit(frame('s1', { type: 'chunk', attemptId: 'a1', revision: 1, index: 1, time: 11, chunk: { type: 'reasoning-delta', index: 1, text: 'hmm' } }))
const chunks = live.chunksFor('s1')
expect(chunks.map(chunk => [chunk.turn, chunk.step, chunk.index, chunk.time])).toEqual([
[2, 1, 0, 10],
[2, 1, 1, 11],
])
expect(chunks[1]?.chunk).toEqual({ type: 'reasoning-delta', index: 1, text: 'hmm' })
expect(live.chunksFor('other')).toEqual([])
})
it('ignores payloads it cannot identify', () => {
const { ctx, emit } = harness()
const live = createAssistantLiveBuffer(ctx)
emit(undefined)
emit({})
emit({ agent: {}, frame: { type: 'chunk', attemptId: 'a1', index: 0, chunk: {} } })
emit({ agent: { session: { id: 's1' } }, frame: { type: 'chunk', attemptId: 'a1', index: 0, chunk: {} } })
// A chunk with no start is not an attempt this buffer knows.
expect(live.chunksFor('s1')).toEqual([])
emit(frame('s1', { type: 'start', attemptId: 'a1', turn: 1, step: 1 }))
emit(frame('s1', { type: 'chunk', attemptId: 'a1', index: 0, chunk: 'not-a-record' }))
expect(live.chunksFor('s1')).toEqual([])
})
it('drops the attempt on a gap rather than splicing incomplete text', () => {
const { ctx, emit } = harness()
const live = createAssistantLiveBuffer(ctx)
emit(frame('s1', { type: 'start', attemptId: 'a1', turn: 1, step: 1 }))
emit(frame('s1', { type: 'chunk', attemptId: 'a1', index: 0, chunk: { type: 'text-delta', text: 'a' } }))
emit(frame('s1', { type: 'chunk', attemptId: 'a1', index: 2, chunk: { type: 'text-delta', text: 'c' } }))
expect(live.chunksFor('s1')).toEqual([])
})
it('drops the attempt when a chunk names another attempt', () => {
const { ctx, emit } = harness()
const live = createAssistantLiveBuffer(ctx)
emit(frame('s1', { type: 'start', attemptId: 'a1', turn: 1, step: 1 }))
emit(frame('s1', { type: 'chunk', attemptId: 'a2', index: 0, chunk: { type: 'text-delta', text: 'a' } }))
expect(live.chunksFor('s1')).toEqual([])
})
it('clears the buffer when the attempt ends', () => {
const { ctx, emit } = harness()
const live = createAssistantLiveBuffer(ctx)
emit(frame('s1', { type: 'start', attemptId: 'a1', turn: 1, step: 1 }))
emit(frame('s1', { type: 'chunk', attemptId: 'a1', index: 0, chunk: { type: 'text-delta', text: 'a' } }))
emit(frame('s1', { type: 'end', attemptId: 'a1', index: 1, outcome: { kind: 'abandoned' } }))
expect(live.chunksFor('s1')).toEqual([])
})
it('a new start replaces the previous attempt of the same session', () => {
const { ctx, emit } = harness()
const live = createAssistantLiveBuffer(ctx)
emit(frame('s1', { type: 'start', attemptId: 'a1', turn: 1, step: 1 }))
emit(frame('s1', { type: 'chunk', attemptId: 'a1', index: 0, chunk: { type: 'text-delta', text: 'old' } }))
emit(frame('s1', { type: 'start', attemptId: 'a2', turn: 1, step: 2 }))
expect(live.chunksFor('s1')).toEqual([])
emit(frame('s1', { type: 'chunk', attemptId: 'a2', index: 0, chunk: { type: 'text-delta', text: 'new' } }))
expect(live.chunksFor('s1').map(chunk => chunk.chunk['text'])).toEqual(['new'])
})
it('caps one attempt at LIVE_CHUNK_CAP chunks (oldest dropped)', () => {
const { ctx, emit } = harness()
const live = createAssistantLiveBuffer(ctx)
emit(frame('s1', { type: 'start', attemptId: 'a1', turn: 1, step: 1 }))
for (let index = 0; index < LIVE_CHUNK_CAP + 3; index++) {
emit(frame('s1', { type: 'chunk', attemptId: 'a1', index, chunk: { type: 'text-delta', text: String(index) } }))
}
const chunks = live.chunksFor('s1')
expect(chunks).toHaveLength(LIVE_CHUNK_CAP)
expect(chunks[0]?.index).toBe(3)
expect(chunks.at(-1)?.index).toBe(LIVE_CHUNK_CAP + 2)
})
it('caps tracked sessions at LIVE_SESSION_CAP (oldest dropped)', () => {
const { ctx, emit } = harness()
const live = createAssistantLiveBuffer(ctx)
for (let index = 0; index < LIVE_SESSION_CAP + 1; index++) {
emit(frame(`s${index}`, { type: 'start', attemptId: 'a1', turn: 1, step: 1 }))
}
expect(live.chunksFor('s0')).toEqual([])
expect(live.chunksFor(`s${LIVE_SESSION_CAP}`)).toEqual([])
// The most recent session is the only live one, and it has no chunks yet.
emit(frame(`s${LIVE_SESSION_CAP}`, { type: 'chunk', attemptId: 'a1', index: 0, chunk: { type: 'text-delta', text: 'x' } }))
expect(live.chunksFor(`s${LIVE_SESSION_CAP}`)).toHaveLength(1)
})
it('dispose unbinds the stream listener', () => {
const { ctx, off } = harness()
const live = createAssistantLiveBuffer(ctx)
live.dispose()
expect(off).toHaveBeenCalledTimes(1)
})
})
+40 -24
View File
@@ -2,14 +2,16 @@
import { mkdirSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import { expect, test, type Page } from '@playwright/test'
import { PAGE_URL } from './host'
import { expect, test, type APIRequestContext, type Page } from '@playwright/test'
import { PAGE_URL, createHostApi, hostRpc } from './host'
const WORKSPACE_PATH = process.env.DSH_E2E_DESKTOP_WORKSPACE
?? (process.env.DSH_E2E_WORKSPACE === undefined
? join(homedir(), 'dsh-e2e-desktop-layout-workspace')
: `${process.env.DSH_E2E_WORKSPACE}-desktop-layout`)
let api: APIRequestContext
async function dismissOnboarding(page: Page): Promise<void> {
await expect
.poll(() => page.getByRole('button', { name: /^(Continue|Configure later|继续|稍后配置)$/ }).count(), { timeout: 60_000 })
@@ -28,8 +30,20 @@ async function dismissOnboarding(page: Page): Promise<void> {
throw new Error('onboarding takeovers did not settle after visible dismissal')
}
test.beforeAll(() => {
test.beforeAll(async () => {
mkdirSync(WORKSPACE_PATH, { recursive: true })
// Seed one workspace + one session through the host's own RPC surface (the
// calls the UI makes) instead of driving the workspace picker: DSH 0.1.5
// ships BOTH a browser directory dialog and an OS-native chooser and picks
// one by platform, so a UI-driven flow only works on hosts whose chooser
// renders in the page.
api = await createHostApi()
const workspace = await hostRpc<{ workspace: { workspaceId: string } }>(api, 'workspace.create', { path: WORKSPACE_PATH })
await hostRpc(api, 'session.create', { workspaceId: workspace.value.workspace.workspaceId })
})
test.afterAll(async () => {
await api?.dispose()
})
test('right panel keeps desktop session actions in their header positions', async ({ page }) => {
@@ -45,38 +59,37 @@ test('right panel keeps desktop session actions in their header positions', asyn
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' })
await dismissOnboarding(page)
await page.getByRole('button', { name: /^(Choose workspace|选择工作区)$/ }).click()
const picker = page.getByRole('dialog', { name: /^(Select Workspace Directory|选择工作区目录)$/ })
await expect(picker).toBeVisible()
await picker.getByRole('button', { name: /^(Edit path|编辑路径)$/ }).click()
const pathInput = picker.getByRole('textbox', { name: /^(Edit path|编辑路径)$/ })
await pathInput.fill(WORKSPACE_PATH)
await pathInput.press('Enter')
await expect(pathInput).toBeHidden()
await picker.getByRole('button', { name: /^(Open|打开)$/ }).click()
// Target the ACTIVE composer by its accessible name. DSH 0.1.2-alpha hosts
// keep an INERT `data-composer-input` "Choose workspace" textbox (with
// contenteditable="false") later in DOM order than the real composer — a
// bare `getByRole('textbox').last()` resolved to that one and fill() threw
// ("not an <input>, <textarea> or [contenteditable] element").
// The seeded blank session opens with its workspace already attached (the
// chip shows the workspace title), so the hero composer is live without
// touching the workspace picker at all. One message gives the header its
// real actions, which this spec measures.
const composer = page.getByRole('textbox', { name: /^(Describe what you want to build|描述你想要构建)/ })
await expect(composer).toBeVisible()
await composer.fill('Create a desktop side-card layout test session.')
await page.getByRole('button', { name: /^(Send message|发送消息)$/ }).click()
// DSH 0.1.5's composer is a Lexical contenteditable: fill() writes the DOM
// but not the editor model, so the send button would stay disabled — type
// real keys instead.
await composer.click()
await page.keyboard.type('Create a desktop side-card layout test session.')
const send = page.getByRole('button', { name: /^(Send message|发送消息)$/ })
await expect(send).toBeEnabled({ timeout: 30_000 })
await send.click()
const sessionLog = page.getByRole('button', { name: 'Session log', exact: true })
const sessionLogLabel = sessionLog.getByText('Session log', { exact: true })
await expect(sessionLogLabel, 'the desktop session-log action starts as a text button').toBeVisible({ timeout: 30_000 })
const root = page.locator('#root')
const frame = page.locator('#root [data-dsh-frame], #root > [data-slot="root"] > div').first()
const appSidebarExpanded = root.getByRole('button', { name: /^(Collapse sidebar|收起侧边栏)$/ })
const sidebar = page.locator('[data-dsh-better-sidebar]')
const sessionLog = page.getByRole('button', { name: 'Session log', exact: true })
const sessionLogLabel = sessionLog.getByText('Session log', { exact: true })
await expect(sessionLogLabel, 'the desktop session-log action starts as a text button').toBeVisible({ timeout: 30_000 })
await expect(appSidebarExpanded, 'the desktop frame starts with its app sidebar expanded').toBeVisible()
const beforeRoot = await root.boundingBox()
const beforeFrame = await frame.boundingBox()
expect(beforeRoot).not.toBeNull()
expect(beforeFrame).not.toBeNull()
// The header action's own width is the baseline: the assertion below is
// "the panel does not squeeze it", not a host-styling constant.
const beforeSessionLog = await sessionLog.boundingBox()
expect(beforeSessionLog).not.toBeNull()
await sidebar.getByRole('button', { name: /^(Expand sidebar|展开侧边栏)$/ }).click()
const panel = page.locator('[data-dsh-panel]:not([data-dsh-bottom-panel])')
@@ -103,7 +116,10 @@ test('right panel keeps desktop session actions in their header positions', asyn
const [sessionLogBox, panelBox] = await Promise.all([sessionLog.boundingBox(), panel.boundingBox()])
expect(sessionLogBox).not.toBeNull()
expect(panelBox).not.toBeNull()
expect(sessionLogBox!.width, 'the desktop session-log action must keep its full button width').toBeGreaterThanOrEqual(110)
expect(
sessionLogBox!.width,
'the desktop session-log action must keep its full button width',
).toBeGreaterThanOrEqual(beforeSessionLog!.width - 1)
expect(sessionLogBox!.x + sessionLogBox!.width, 'the session-log action must not overlap the plugin panel chrome').toBeLessThanOrEqual(panelBox!.x - 8)
expect(pageErrors).toEqual([])
expect(consoleErrors).toEqual([])
-281
View File
@@ -1,281 +0,0 @@
import { describe, expect, it } from 'vitest'
import { registerOpenPathInterception } from '../src/client/intercept.tsx'
import {
isFolderRevealPath,
wrapOpenWorkspacePath,
type OpenPathInterceptDeps,
type OpenWorkspacePathService,
} from '../src/client/openpath-intercept.ts'
import { createSidebarStore } from '../src/client/state.ts'
import type { Context } from '../src/context-types.ts'
/**
* A fake of the gateway client's RemoteNamespaceService: the method is an
* ACCESSOR property (getter returning the invocation closure), exactly like
* `Object.defineProperty(service, method, { configurable: true, get })` in
* the real implementation — the wrapper must shadow it with a data property
* and restore the accessor on dispose.
*/
function fakeNamespaceService(opened: string[]): OpenWorkspacePathService {
const target = {} as Record<string, unknown>
Object.defineProperty(target, 'openWorkspacePath', {
configurable: true,
enumerable: true,
get() {
return async (request: { path: string }) => {
opened.push(request.path)
return { ok: true, value: { opened: true } } as const
}
},
})
return target as unknown as OpenWorkspacePathService
}
describe('open-path interception (wrapOpenWorkspacePath)', () => {
const deps = (overrides: Partial<OpenPathInterceptDeps> = {}): OpenPathInterceptDeps & {
sidebar: string[]
revealed: string[]
} => {
const sidebar: string[] = []
const revealed: string[] = []
return {
sidebar,
revealed,
takeoverEnabled: () => true,
currentSessionId: () => 's1',
openInSidebar: (path, sessionId) => { sidebar.push(`${sessionId}:${path}`) },
revealInExplorer: (path, sessionId) => { revealed.push(`${sessionId}:${path}`) },
...overrides,
}
}
it('routes an intercepted open into the sidebar and resolves with the remote success envelope', async () => {
const opened: string[] = []
const service = fakeNamespaceService(opened)
const d = deps()
const restore = wrapOpenWorkspacePath(service, d)
// The caller (ChatView's openFile) branches on `result.ok` and reads
// `result.error.message` on falsy — a bare business value would surface
// as a bogus "cannot open" dialog.
await expect(service.openWorkspacePath({ path: '/abs/a.ts' }))
.resolves.toEqual({ ok: true, value: { opened: true } })
expect(opened).toEqual([])
expect(d.sidebar).toEqual(['s1:/abs/a.ts'])
restore()
})
it('falls through to the original when the takeover is disabled', async () => {
const opened: string[] = []
const service = fakeNamespaceService(opened)
const d = deps({ takeoverEnabled: () => false })
const restore = wrapOpenWorkspacePath(service, d)
await service.openWorkspacePath({ path: '/abs/a.ts' })
expect(opened).toEqual(['/abs/a.ts'])
expect(d.sidebar).toEqual([])
restore()
})
it('falls through when no session is current (nothing to scope the editor load to)', async () => {
const opened: string[] = []
const service = fakeNamespaceService(opened)
const d = deps({ currentSessionId: () => undefined })
const restore = wrapOpenWorkspacePath(service, d)
await service.openWorkspacePath({ path: '/abs/a.ts' })
expect(opened).toEqual(['/abs/a.ts'])
expect(d.sidebar).toEqual([])
restore()
})
it('passes the current session into the sidebar opener', async () => {
const opened: string[] = []
const service = fakeNamespaceService(opened)
let current = 's1'
const d = deps({ currentSessionId: () => current })
const restore = wrapOpenWorkspacePath(service, d)
await service.openWorkspacePath({ path: '/abs/a.ts' })
current = 's2'
await service.openWorkspacePath({ path: '/abs/b.ts' })
expect(d.sidebar).toEqual(['s1:/abs/a.ts', 's2:/abs/b.ts'])
restore()
})
it('routes the folder-reveal gesture to the explorer, not the editor', async () => {
const opened: string[] = []
const service = fakeNamespaceService(opened)
const d = deps()
const restore = wrapOpenWorkspacePath(service, d)
for (const path of ['.', './', '/w/.', 'C:\\w\\.']) {
await service.openWorkspacePath({ path })
}
expect(opened).toEqual([])
expect(d.sidebar).toEqual([])
expect(d.revealed).toEqual(['s1:.', 's1:./', 's1:/w/.', 's1:C:\\w\\.'])
restore()
})
it('restores the original accessor on dispose (HMR-safe)', async () => {
const opened: string[] = []
const service = fakeNamespaceService(opened)
const before = Object.getOwnPropertyDescriptor(service, 'openWorkspacePath')
expect(typeof before?.get).toBe('function')
const d = deps()
const restore = wrapOpenWorkspacePath(service, d)
// While wrapped, the property is our data-property shadow.
const during = Object.getOwnPropertyDescriptor(service, 'openWorkspacePath')
expect(during?.get).toBeUndefined()
expect(typeof during?.value).toBe('function')
restore()
const after = Object.getOwnPropertyDescriptor(service, 'openWorkspacePath')
expect(typeof after?.get).toBe('function')
await service.openWorkspacePath({ path: '/abs/a.ts' })
expect(opened).toEqual(['/abs/a.ts'])
expect(d.sidebar).toEqual([])
})
it('propagates a rejection from the original (no swallowing)', async () => {
const target = {} as Record<string, unknown>
Object.defineProperty(target, 'openWorkspacePath', {
configurable: true,
enumerable: true,
get: () => async () => { throw new Error('host refused') },
})
const service = target as unknown as OpenWorkspacePathService
const d = deps({ takeoverEnabled: () => false })
const restore = wrapOpenWorkspacePath(service, d)
await expect(service.openWorkspacePath({ path: '/abs/a.ts' })).rejects.toThrow('host refused')
restore()
})
it('declines to wrap when the method is not installed (hand-rolled composition)', async () => {
const service = {} as OpenWorkspacePathService
const d = deps()
const restore = wrapOpenWorkspacePath(service, d)
expect(service.openWorkspacePath).toBeUndefined()
expect(d.sidebar).toEqual([])
restore()
})
})
describe('isFolderRevealPath', () => {
it('recognizes the dot gestures on both separator styles', () => {
expect(isFolderRevealPath('.')).toBe(true)
expect(isFolderRevealPath('./')).toBe(true)
expect(isFolderRevealPath('/w/.')).toBe(true)
expect(isFolderRevealPath('/w/./')).toBe(true)
expect(isFolderRevealPath('C:\\w\\.')).toBe(true)
expect(isFolderRevealPath('/w/a.ts')).toBe(false)
expect(isFolderRevealPath('/w/.hidden')).toBe(false)
})
})
describe('open-path interception wiring', () => {
/**
* A client-context fake whose `inject` mimics cordis: the callback runs
* once the dependency appears (here: when `mount()` is called) and its
* effects dispose with the fiber.
*/
function fakeCtx(opened: Array<Record<string, unknown>>, remoteSession: OpenWorkspacePathService) {
let effectDisposer: (() => void) | undefined
let injectCallback: ((fctx: unknown) => void) | undefined
const fctx = {
get: (name: string) => (name === 'remote.session' ? remoteSession : undefined),
effect: (fn: () => () => void) => { effectDisposer = fn() },
}
const ctx = {
sessions: {
list: { getSnapshot: () => ({ current: 's1', byId: { s1: { cwd: '/w' } } }) },
},
get: (name: string) => name === 'betterSidebar'
? { openTab: (seed: unknown) => { opened.push(seed as Record<string, unknown>) } }
: undefined,
inject: (_deps: string[], callback: (fctx: unknown) => void) => {
injectCallback = callback
return {
dispose: async () => { effectDisposer?.() },
}
},
/** Test helper: simulate the namespace service appearing. */
mount: () => { injectCallback?.(fctx) },
}
return ctx
}
it('registers through ctx.inject and routes chat opens into the editor tab', async () => {
const opened: Array<Record<string, unknown>> = []
const hostOpened: string[] = []
const remoteSession = fakeNamespaceService(hostOpened)
const ctx = fakeCtx(opened, remoteSession)
const store = createSidebarStore()
const restore = registerOpenPathInterception(ctx as unknown as Context, store)
// Before the namespace mounts, nothing is wrapped.
expect(Object.getOwnPropertyDescriptor(remoteSession, 'openWorkspacePath')?.get).toBeDefined()
ctx.mount()
// Default prefs: the takeover routes the open into the sidebar editor
// with the session-scoped absolute path (chat already resolved it).
await remoteSession.openWorkspacePath({ path: '/w/src/a.ts' })
expect(opened).toEqual([{
type: 'editor',
title: 'a.ts',
path: '/w/src/a.ts',
id: 'editor:/w/src/a.ts',
}])
expect(hostOpened).toEqual([])
// The interceptOpenPath pref off → the original funnel runs untouched.
store.setPrefs({ ...store.getPrefs(), interceptOpenPath: false })
await remoteSession.openWorkspacePath({ path: '/w/src/b.ts' })
expect(hostOpened).toEqual(['/w/src/b.ts'])
expect(opened).toHaveLength(1)
// The editor tab disabled → falls through too (an editor that cannot
// open must not swallow opens).
store.setPrefs({ ...store.getPrefs(), interceptOpenPath: true, tabsEnabled: { editor: false } })
await remoteSession.openWorkspacePath({ path: '/w/src/c.ts' })
expect(hostOpened).toEqual(['/w/src/b.ts', '/w/src/c.ts'])
// Disposal restores the accessor (HMR-safe).
restore()
const descriptor = Object.getOwnPropertyDescriptor(remoteSession, 'openWorkspacePath')
expect(typeof descriptor?.get).toBe('function')
})
it('re-applies the shadow when the namespace service is remounted', async () => {
const opened: Array<Record<string, unknown>> = []
const hostOpened: string[] = []
// A ctx whose inject can fire multiple times (service recreate).
let effectDisposer: (() => void) | undefined
let injectCallback: ((fctx: unknown) => void) | undefined
let current = fakeNamespaceService(hostOpened)
const ctx = {
sessions: { list: { getSnapshot: () => ({ current: 's1', byId: { s1: { cwd: '/w' } } }) } },
get: (name: string) => name === 'betterSidebar'
? { openTab: (seed: unknown) => { opened.push(seed as Record<string, unknown>) } }
: undefined,
inject: (_deps: string[], callback: (fctx: unknown) => void) => {
injectCallback = callback
return { dispose: async () => { effectDisposer?.() } }
},
remount: () => {
effectDisposer?.()
current = fakeNamespaceService(hostOpened)
injectCallback?.({
get: (name: string) => (name === 'remote.session' ? current : undefined),
effect: (fn: () => () => void) => { effectDisposer = fn() },
})
},
}
const store = createSidebarStore()
registerOpenPathInterception(ctx as unknown as Context, store)
ctx.remount()
await current.openWorkspacePath({ path: '/w/src/a.ts' })
expect(opened).toHaveLength(1)
// A remount (dispose + fresh service) must not leak the old shadow and
// must wrap the fresh instance.
ctx.remount()
await current.openWorkspacePath({ path: '/w/src/b.ts' })
expect(opened).toHaveLength(2)
expect(hostOpened).toEqual([])
})
})
+1 -1
View File
@@ -97,6 +97,6 @@ describe('dsh-better-sidebar plugin export shape', () => {
const overridden = (PrefsSchema as unknown as {
(input: Record<string, unknown> | undefined): Record<string, unknown>
})({ openByDefault: false, defaultWidthPercent: 45 })
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: false, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: '', changesDiffFloat: true, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, editorExplorer: false, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: '', changesDiffFloat: true, tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
})
})
+5 -18
View File
@@ -41,7 +41,6 @@ describe('side card preferences', () => {
bottomPanelAutoTerminal: true,
terminalFontFamily: '',
terminalFontSize: 13,
interceptOpenPath: true,
editorExplorer: false, changesDiffFloat: true,
workspaceFence: true,
terminalShell: '',
@@ -75,7 +74,6 @@ describe('side card preferences', () => {
bottomPanelAutoTerminal: true,
terminalFontFamily: '',
terminalFontSize: 13,
interceptOpenPath: true,
editorExplorer: false, changesDiffFloat: true,
workspaceFence: true,
terminalShell: '',
@@ -109,7 +107,6 @@ describe('side card preferences', () => {
bottomPanelAutoTerminal: true,
terminalFontFamily: '',
terminalFontSize: 13,
interceptOpenPath: true,
editorExplorer: false, changesDiffFloat: true,
workspaceFence: true,
terminalShell: '',
@@ -153,16 +150,6 @@ describe('side card preferences', () => {
.toBe(false)
})
it('defaults interceptOpenPath to true; only an explicit false turns the takeover off', async () => {
// Absent or malformed → on (the takeover is the safe default).
expect((await loadPrefs(wire({}))).interceptOpenPath).toBe(true)
expect((await loadPrefs(wire({ interceptOpenPath: 'yes' }))).interceptOpenPath).toBe(true)
expect((await loadPrefs(wire({ interceptOpenPath: 0 }))).interceptOpenPath).toBe(true)
// Explicit booleans survive verbatim.
expect((await loadPrefs(wire({ interceptOpenPath: false }))).interceptOpenPath).toBe(false)
expect((await loadPrefs(wire({ interceptOpenPath: true }))).interceptOpenPath).toBe(true)
})
it('defaults editorExplorer to false; only an explicit true enables the merged editor-explorer', async () => {
// Absent or malformed → off (separate file windows are the default).
expect((await loadPrefs(wire({}))).editorExplorer).toBe(false)
@@ -282,9 +269,9 @@ describe('side card preferences', () => {
const store = createSidebarStore()
// Node environment: no window → the width falls back to PANEL_DEFAULT,
// while the open flag still follows the preference.
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, editorExplorer: true, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
store.setSession('fresh-session')
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, editorExplorer: true, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
const snapshot = store.getSnapshot()
expect(snapshot.sessionId).toBe('fresh-session')
expect(snapshot.state?.panelOpen).toBe(false)
@@ -320,7 +307,7 @@ describe('side card preferences', () => {
it('skips the default seed tab when the editor (files window) type is disabled', () => {
const store = createSidebarStore()
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer: true, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: { editor: false }, viewersEnabled: {}, pluginSettings: {} })
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, editorExplorer: true, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: { editor: false }, viewersEnabled: {}, pluginSettings: {} })
store.setSession('no-editor')
const state = store.getSnapshot().state!
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
@@ -330,7 +317,7 @@ describe('side card preferences', () => {
// editorExplorer modes.
for (const editorExplorer of [true, false]) {
const openStore = createSidebarStore()
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, editorExplorer, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
openStore.setSession(`with-editor-${editorExplorer}`)
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
expect(openTabs.map(tab => tab.type)).toEqual(['editor'])
@@ -340,7 +327,7 @@ describe('side card preferences', () => {
it('seeds the empty editor home tab (files window) in both editorExplorer modes', () => {
for (const editorExplorer of [true, false]) {
const store = createSidebarStore()
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, interceptOpenPath: true, editorExplorer, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, autoOpenJobs: true, agentTerminalTools: false, agentOpenTools: false, bottomPanelAutoTerminal: true, terminalFontFamily: '', terminalFontSize: 13, editorExplorer, changesDiffFloat: true, workspaceFence: true, terminalShell: '', terminalShellArgs: '', titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false, titleBarStripPx: 40, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, browserInterceptHttp: true, browserInterceptHttps: false, browserAllowedLoopback: "", tabsEnabled: {}, viewersEnabled: {}, pluginSettings: {} })
store.setSession(`fresh-${editorExplorer}`)
const tabs = allLeaves(store.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
expect(tabs).toHaveLength(1)
+24
View File
@@ -0,0 +1,24 @@
/**
* Vitest global setup.
*
* Node ≥ 22 defines a `localStorage` accessor on globalThis that resolves to
* `undefined` unless the process was started with `--localstorage-file`
* ("localStorage is not available because --localstorage-file was not
* provided"). Because that key already exists, the jsdom environment's
* global population never installs the document's own store, so a test
* calling the bare global `localStorage` throws "Cannot read properties of
* undefined" even though jsdom has a working one.
*
* Bridge the two: point the global at the jsdom window's Storage. The jsdom
* environment exposes its instance as `globalThis.jsdom`; node-only suites
* (no jsdom global) keep Node's accessor untouched.
*/
const jsdomWindow = (globalThis as { jsdom?: { window?: Window } }).jsdom?.window
const storage = jsdomWindow?.localStorage ?? (typeof window === 'undefined' ? undefined : window.localStorage)
if (storage !== undefined) {
Object.defineProperty(globalThis, 'localStorage', {
value: storage,
configurable: true,
writable: true,
})
}
+10 -11
View File
@@ -90,14 +90,14 @@ describe('SideCardSection declarative inventory', () => {
expect(html).toContain('>explorer<')
expect(html).toContain('data-icon="subagent"')
expect(html).toContain('>Subagents<')
// Default prefs: only the interceptOpenPath switch is checked (openByDefault
// now defaults off), and both tabs + the image viewer cards pressed
// Default prefs: no general switch is on (openByDefault and agentOpenTools
// both default off), and both tabs + the image viewer cards pressed
// (3 aria-pressed cards).
// The nested auto-open toggle is NOT an inline card (it lives in the popup).
expect(pressedCount(html, 'true')).toBe(3)
expect(pressedCount(html, 'false')).toBe(0)
// The general toggles are custom switches (real checkboxes, one checked).
expect(html.match(/checked=""/g)?.length).toBe(1)
// The general toggles are custom switches (real checkboxes, none checked).
expect(html.match(/checked=""/g)?.length ?? 0).toBe(0)
expect(html).not.toContain('Auto-open Subagents')
})
@@ -153,9 +153,9 @@ describe('SideCardSection declarative inventory', () => {
expect(html).toContain('>Subagents<')
expect(html).toContain('>Image<')
expect(pressedCount(html, 'false')).toBe(2)
// The explorer card stays pressed; the one default-on general switch stays checked.
// The explorer card stays pressed; no general switch is on by default.
expect(pressedCount(html, 'true')).toBe(1)
expect(html.match(/checked=""/g)?.length).toBe(1)
expect(html.match(/checked=""/g)?.length ?? 0).toBe(0)
})
it('hides the gear of a disabled feature (its related settings are dormant)', () => {
@@ -178,11 +178,10 @@ describe('SideCardSection declarative inventory', () => {
expect(html).toContain('Pick the title-bar compatibility scheme: auto-detect (default, conservative) / DSH official web / known desktop shells / custom (shift distance + custom CSS)')
expect(html).not.toContain('<select')
expect(html).toContain('>Auto-detect<')
// Three general-row switches remain (openByDefault + interceptOpenPath
// + agentOpenTools), only interceptOpenPath checked by default — the
// scheme row is a dropdown, not a switch.
expect(html.match(/type="checkbox"/g)?.length).toBe(3)
expect(html.match(/checked=""/g)?.length).toBe(1)
// Two general-row switches remain (openByDefault + agentOpenTools), neither
// on by default — the scheme row is a dropdown, not a switch.
expect(html.match(/type="checkbox"/g)?.length).toBe(2)
expect(html.match(/checked=""/g)?.length ?? 0).toBe(0)
// Auto (default) needs no further settings → no gear.
expect(html).not.toContain('Position compatibility mode Feature settings')
+33 -12
View File
@@ -7,6 +7,7 @@
*/
import { describe, expect, it } from 'vitest'
import type { SidebarHistoryEntry, SidebarSessionEvent, SidebarSessionSummary } from '../src/context-types.ts'
import type { AssistantLiveChunk } from '../src/assistant-live.ts'
import {
boundaryDelivered,
buildOpenTurnSnapshot,
@@ -35,6 +36,24 @@ function ev(type: string, seq: number, data: Record<string, unknown> = {}): Side
return event
}
/** One live delta as the 0.1.5 stream buffer produces it: `index` is the
* dense frame position, the chunk carries its own block index. */
function live(
turn: number,
step: number,
text: string,
over: { kind?: 'text' | 'reasoning'; index?: number; block?: number } = {},
): AssistantLiveChunk {
return {
attemptId: 'a1',
turn,
step,
index: over.index ?? 0,
time: 0,
chunk: { type: over.kind === 'reasoning' ? 'reasoning-delta' : 'text-delta', index: over.block ?? 0, text },
}
}
/** A completed user→assistant turn block (turn T, step 1, optional tools). */
function completedTurn(seq: number, turn: number, over: {
text?: string
@@ -136,7 +155,6 @@ describe('buildSidechatInheritance', () => {
ev('user/message', 6, { content: [{ type: 'text', text: 'next?' }], source: { kind: 'user' } }),
ev('turn/start', 7, { turn: 2 }),
ev('step/start', 8, { turn: 2, step: 1 }),
ev('assistant/chunk', 9, { turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: 'partial answer' } }),
]
const { seed, snapshot } = buildSidechatInheritance(events)
expectContiguous(seed)
@@ -145,10 +163,12 @@ describe('buildSidechatInheritance', () => {
const turnEnd = seed.at(-1)!
expect(stepEnd).toMatchObject({ type: 'step/end', data: { turn: 2, step: 1 } })
expect(turnEnd).toMatchObject({ type: 'turn/end', data: { turn: 2, reason: { kind: 'interrupted' } } })
// The pending user message and the partial chunk survive as REAL events.
// The pending user message survives as a REAL event. The in-flight
// step's deltas are process-local on 0.1.5, so they are NOT seed events —
// the snapshot fallback is the only path that carries them.
expect(seed.some(event => event.type === 'user/message'
&& (event.data.content as Array<{ text: string }>)[0]?.text === 'next?')).toBe(true)
expect(seed.some(event => event.type === 'assistant/chunk')).toBe(true)
expect(seed.every(event => event.type !== 'assistant/live-chunk')).toBe(true)
expect(snapshot).toBeNull()
})
@@ -166,7 +186,7 @@ describe('buildSidechatInheritance', () => {
content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'hit' }] }],
},
}),
ev('assistant/chunk', 5, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'found ' } }),
ev('assistant/message', 5, { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'found it' }] }, stream: [] }),
]
const { seed, snapshot } = buildSidechatInheritance(events)
expectContiguous(seed)
@@ -180,11 +200,12 @@ describe('buildSidechatInheritance', () => {
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
ev('turn/start', 1, { turn: 1 }),
ev('step/start', 2, { turn: 1, step: 1 }),
ev('assistant/chunk', 3, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'running ' } }),
ev('tool/call', 4, { turn: 1, step: 1, callId: 'c1', name: 'bash', arguments: '{"cmd":"sleep 9"}' }),
]
expect(hasDanglingToolCall(events, 1)).toBe(true)
const { seed, snapshot } = buildSidechatInheritance(events)
// The in-flight text is process-local on 0.1.5: it reaches the snapshot
// through the live buffer, not through the log.
const { seed, snapshot } = buildSidechatInheritance(events, [live(1, 1, 'running ')])
expectContiguous(seed)
// The seed stops BEFORE the open turn; the pending user message stays.
expect(seed.at(-1)?.type).toBe('user/message')
@@ -200,10 +221,9 @@ describe('buildSidechatInheritance', () => {
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
ev('turn/start', 1, { turn: 1 }),
ev('step/start', 2, { turn: 1, step: 1 }),
ev('assistant/message', 3, { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'first' }] } }),
ev('assistant/message', 3, { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'first' }] }, stream: [] }),
ev('step/end', 4, { turn: 1, step: 1 }),
ev('step/start', 5, { turn: 1, step: 2 }),
ev('assistant/chunk', 6, { turn: 1, step: 2, chunk: { type: 'text-delta', index: 0, text: 'second' } }),
]
const { seed } = buildSidechatInheritance(events)
expectContiguous(seed)
@@ -222,9 +242,6 @@ describe('buildOpenTurnSnapshot', () => {
const events = [
ev('turn/start', 0, { turn: 1 }),
ev('step/start', 1, { turn: 1, step: 1 }),
ev('assistant/chunk', 2, { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'think' } }),
ev('assistant/chunk', 3, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '```js\ncode' } }),
ev('assistant/chunk', 4, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: '\n```' } }),
ev('tool/call', 5, { turn: 1, step: 1, callId: 'c1', name: 'read', arguments: '{"path":"a.txt"}' }),
ev('tool/result', 6, {
turn: 1,
@@ -236,7 +253,11 @@ describe('buildOpenTurnSnapshot', () => {
}),
ev('tool/call', 7, { turn: 1, step: 1, callId: 'c2', name: 'bash', arguments: '{"cmd":"long"}' }),
]
const snapshot = buildOpenTurnSnapshot(events)
const snapshot = buildOpenTurnSnapshot(events, [
live(1, 1, 'think', { kind: 'reasoning', index: 0, block: 0 }),
live(1, 1, '```js\ncode', { index: 1, block: 1 }),
live(1, 1, '\n```', { index: 2, block: 1 }),
])
expect(snapshot).not.toBeNull()
expect(snapshot).toContain('```js\ncode\n```')
expect(snapshot).toContain('Reasoning so far')
+31 -16
View File
@@ -48,18 +48,29 @@ 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 (_id?: string): Promise<{ meta: Record<string, unknown>; events: unknown[] }> => ({ meta: { agentPreset: 'preset-a' }, events: [] }))
/** The 0.1.5 persistence face: open(id, 'read') -> handle.read() -> close(). */
const open = vi.fn(async (_id?: string): Promise<{
header: { agentPreset?: string; cwd?: string }
inheritedEventCount: number
read: () => Promise<{ events: unknown[] }>
close: () => Promise<void>
}> => ({
header: { agentPreset: 'preset-a' },
inheritedEventCount: 0,
read: async () => ({ events: [] }),
close: async () => {},
}))
return {
agents: { get, create, resume },
agentPresets: { resolve, mount },
sessionTitle: { rename },
sessionPersistence: { inspect },
sessionPersistence: { open },
create,
resume,
get,
rename,
mount,
inspect,
open,
}
}
@@ -170,7 +181,6 @@ describe('sidechat.start', () => {
ev('user/message', 0, { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }),
ev('turn/start', 1, { turn: 1 }),
ev('step/start', 2, { turn: 1, step: 1 }),
ev('assistant/chunk', 3, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'streaming' } }),
],
})
const child = agent('child')
@@ -181,7 +191,7 @@ describe('sidechat.start', () => {
const options = services.create.mock.calls[0]![0] as { seed: Array<{ type: string; data: Record<string, unknown> }> }
expect(options.seed.map(event => event.type)).toEqual([
'user/message', 'turn/start', 'step/start', 'assistant/chunk', 'step/end', 'turn/end',
'user/message', 'turn/start', 'step/start', 'step/end', 'turn/end',
'subagent/descriptor',
])
expect(options.seed.at(-2)?.data).toEqual({ turn: 1, reason: { kind: 'interrupted' } })
@@ -306,7 +316,7 @@ describe('sidechat.prompt', () => {
const resumeOptions = services.resume.mock.calls[0]![0] as { resumeSessionId: string; setup: unknown }
expect(resumeOptions.resumeSessionId).toBe('child')
expect(typeof resumeOptions.setup).toBe('function')
expect(services.inspect).toHaveBeenCalledWith('child')
expect(services.open).toHaveBeenCalledWith('child', 'read')
expect(child.followup).toHaveBeenCalledTimes(1)
})
@@ -368,14 +378,14 @@ describe('sidechat.info', () => {
const api = buildSidechatApi(ctxWith(services))
const info = await api['sidechat.info']({ childId: 'child' })
expect(info).toEqual({ live: false, preset: 'preset-a' })
expect(services.inspect).toHaveBeenCalledWith('child')
expect(services.open).toHaveBeenCalledWith('child', 'read')
})
it('degrades to a bare cold info when the session is gone', async () => {
const services = happyServices(undefined, agent('child'))
services.agents.get = vi.fn((_id: unknown) => undefined)
services.inspect = vi.fn(async () => { throw new Error('unknown session') })
services.sessionPersistence = { inspect: services.inspect }
services.open = vi.fn(async () => { throw new Error('unknown session') })
services.sessionPersistence = { open: services.open }
const api = buildSidechatApi(ctxWith(services))
await expect(api['sidechat.info']({ childId: 'ghost' })).resolves.toEqual({ live: false })
})
@@ -391,8 +401,8 @@ function threadLog(): Array<ReturnType<typeof ev>> {
ev('subagent/descriptor', 3, { mode: 'continuable' }),
ev('user/message', 4, { content: [{ type: 'text', text: 'Side conversation boundary.' }], source: { kind: 'plugin', plugin: 'dsh-better-sidebar' } }),
ev('user/message', 5, { content: [{ type: 'text', text: 'the side question' }], source: { kind: 'user' } }),
ev('assistant/chunk', 6, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'an' } }),
ev('assistant/chunk', 7, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'swer' } }),
ev('assistant/attempt', 6, { turn: 1, step: 1, stream: [] }),
ev('assistant/attempt', 7, { turn: 1, step: 1, stream: [] }),
]
}
@@ -404,7 +414,7 @@ describe('sidechat.events', () => {
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()
expect(services.open).not.toHaveBeenCalled()
})
it('narrows to the afterSeq delta on polls (and never resurrects the seed)', async () => {
@@ -422,11 +432,16 @@ describe('sidechat.events', () => {
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() }))
services.open.mockImplementation(async () => ({
header: { agentPreset: 'preset-a' },
inheritedEventCount: 0,
read: async () => ({ events: threadLog() }),
close: async () => {},
}))
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')
expect(services.open).toHaveBeenCalledWith('child', 'read')
})
it('returns a marker-less legacy log whole', async () => {
@@ -443,7 +458,7 @@ describe('sidechat.events', () => {
})
it('caps a pathological response at its tail (8000 events)', async () => {
const events = Array.from({ length: 8_003 }, (_, index) => ev('assistant/chunk', index, { turn: 1, step: 1 }))
const events = Array.from({ length: 8_003 }, (_, index) => ev('assistant/attempt', index, { turn: 1, step: 1, stream: [] }))
const child = agent('child', { events })
const services = happyServices(undefined, child)
const api = buildSidechatApi(ctxWith(services))
@@ -463,7 +478,7 @@ describe('sidechat.events', () => {
.rejects.toMatchObject({ code: 'bad-request' })
services.agents.get = vi.fn((_id: unknown) => undefined)
services.inspect.mockImplementation(async () => { throw new Error('no such session') })
services.open.mockImplementation(async () => { throw new Error('no such session') })
await expect(api['sidechat.events']({ childId: 'ghost' }))
.rejects.toMatchObject({ code: 'not-found', status: 404 })
})
+30 -14
View File
@@ -10,9 +10,9 @@
* not a mock, so the class of bug cannot silently return.
*/
import { describe, expect, it } from 'vitest'
import { Session, SessionLogOffset } from '@deepseek-ai/dsh-session'
import { Session, SessionLogOffset, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
import type { SessionId } from '@deepseek-ai/dsh-session'
import { Inbox } from '@deepseek-ai/dsh-agent'
import type { InboxState } from '@deepseek-ai/dsh-agent'
import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent'
import type { SidebarSessionEvent } from '../src/context-types.ts'
import { buildSidechatInheritance } from '../src/sidechat-core.ts'
@@ -42,19 +42,20 @@ function assistantMessage(text: string): Record<string, unknown> {
}
/** A parent log with a completed turn, a pending question, and an open
* in-progress turn (the exact shape a mid-stream thread creation sees). */
* in-progress turn (the exact shape a mid-stream thread creation sees).
* DSH 0.1.5 logs nothing for the in-flight step: its deltas are
* process-local `agent/assistant-stream` frames, not session events. */
function parentLog(): SidebarSessionEvent[] {
return [
ev('user/message', 0, userMessage('first question')),
ev('turn/start', 1, { turn: 1 }),
ev('step/start', 2, { turn: 1, step: 1 }),
ev('assistant/message', 3, { turn: 1, step: 1, message: assistantMessage('first answer') }),
ev('assistant/message', 3, { turn: 1, step: 1, message: assistantMessage('first answer'), stream: [] }),
ev('step/end', 4, { turn: 1, step: 1 }),
ev('turn/end', 5, { turn: 1, reason: { kind: 'completed' } }),
ev('user/message', 6, userMessage('pending question')),
ev('turn/start', 7, { turn: 2 }),
ev('step/start', 8, { turn: 2, step: 1 }),
ev('assistant/chunk', 9, { turn: 2, step: 1, chunk: { type: 'text-delta', index: 0, text: 'in-progress' } }),
]
}
@@ -68,7 +69,7 @@ describe('sidechat seed against the real dsh-session validator', () => {
const types = child.snapshotEvents().map(event => event.type)
expect(types).toEqual([
'user/message', 'turn/start', 'step/start', 'assistant/message', 'step/end', 'turn/end',
'user/message', 'turn/start', 'step/start', 'assistant/chunk', 'step/end', 'turn/end',
'user/message', 'turn/start', 'step/start', 'step/end', 'turn/end',
'session/end-seed',
])
// The synthetic close is honest: the frozen turn ends interrupted.
@@ -113,18 +114,24 @@ describe('sidechat seed against the real dsh-session validator', () => {
})
})
describe('sidechat seed fork markers vs the real dsh-agent Inbox replay', () => {
describe('sidechat seed fork markers vs the reconstructed inbox', () => {
/**
* Regression: the thread-create call originally passed `seed` WITHOUT the
* fork-marker pair (`meta.isSeeded: true` + `inheritedEventCount`). A seed
* without them is REPLAY history, not an inherited prefix, so the child's
* ownEvents() includes the whole seed and its Inbox constructor replays the
* ownEvents() includes the whole seed and its inbox fold replays the
* parent's `agent/inbox/spliced` events — inheriting whatever input sat
* UNCLAIMED in the parent at the click moment (a queued follow-up, or a
* tool-result context spliced into next-step between step boundaries of a
* long-running turn). The first side prompt then claimed and SENT that stale
* message before the boundary + question. These tests run the REAL
* dsh-session Session and dsh-agent Inbox.
* message before the boundary + question.
*
* The fold below is the standard inbox projection's rule
* (`inboxProjectionDefinition` in `@deepseek-ai/dsh-agent-loop/inbox`,
* which 0.1.5 does not re-export from the package root): pending input is
* reconstructed from the session's OWN splice events, so the
* `inheritedEventCount` the marker pair supplies is exactly what keeps the
* inherited prefix out of it.
*/
const pendingMessage = (id: string, kind: string, text: string) => ({
id,
@@ -133,15 +140,24 @@ describe('sidechat seed fork markers vs the real dsh-agent Inbox replay', () =>
source: { kind },
})
const inboxOver = (session: Session): { nextTurn: number; nextStep: number } => {
const inbox = new Inbox(session, { inserted() {}, discarded() {}, claimed() {} })
return { nextTurn: inbox.nextTurn.length, nextStep: inbox.nextStep.length }
let state: InboxState = { 'next-turn': [], 'next-step': [] }
for (const event of session.ownEvents()) {
if (event.type !== 'agent/inbox/spliced') continue
const splice = event.data
const list = state[splice.target]
state = {
...state,
[splice.target]: list.toSpliced(splice.start, splice.removedCount ?? 0, ...splice.inserted),
}
}
return { nextTurn: state['next-turn'].length, nextStep: state['next-step'].length }
}
/** The shape the routes pass since the fix: isSeeded + inheritedEventCount. */
const forkMarkedSession = (id: string, seed: readonly SidebarSessionEvent[]): Session =>
Session.create(
id as SessionId,
seed as never,
{ version: 0, id: id as SessionId, createdAt: Date.now(), isSeeded: true },
{ version: SESSION_FORMAT_VERSION, id: id as SessionId, createdAt: Date.now(), isSeeded: true },
SessionLogOffset(seed.length),
)
/** Real loop order: inbox insert → turn/start → claim deletion → step/start
@@ -152,7 +168,7 @@ describe('sidechat seed fork markers vs the real dsh-agent Inbox replay', () =>
ev('agent/inbox/spliced', 2, { target: 'next-turn', start: 0, removedCount: 1, inserted: [] }),
ev('step/start', 3, { turn: 1, step: 1 }),
ev('user/message', 4, pendingMessage('m-q', 'user', 'q')),
ev('assistant/message', 5, { turn: 1, step: 1, message: assistantMessage('a') }),
ev('assistant/message', 5, { turn: 1, step: 1, message: assistantMessage('a'), stream: [] }),
ev('step/end', 6, { turn: 1, step: 1 }),
ev('turn/end', 7, { turn: 1, reason: { kind: 'completed' } }),
]
+5 -5
View File
@@ -80,15 +80,15 @@ describe('transcriptRows', () => {
])
})
it('accumulates chunk deltas per block and supersedes them on settle', () => {
it('accumulates live deltas per block and supersedes them on settle', () => {
const entries = [
entry(ev('session/end-seed', 0)),
entry(ev('user/message', 1, { content: textBlocks('q'), source: { kind: 'user' } })),
entry(ev('turn/start', 2, { turn: 1 })),
entry(ev('step/start', 3, { turn: 1, step: 1 })),
entry(ev('assistant/chunk', 4, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'Hel' } })),
entry(ev('assistant/chunk', 5, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'lo' } })),
entry(ev('assistant/chunk', 6, { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 1, text: 'think' } })),
entry(ev('assistant/live-chunk', 4, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'Hel' } })),
entry(ev('assistant/live-chunk', 5, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'lo' } })),
entry(ev('assistant/live-chunk', 6, { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 1, text: 'think' } })),
]
const rows = transcriptRows(entries)
const assistant = rows.find(row => row.kind === 'assistant') as Extract<SidechatTranscriptRow, { kind: 'assistant' }>
@@ -104,7 +104,7 @@ describe('transcriptRows', () => {
entry(ev('user/message', 1, { content: textBlocks('q'), source: { kind: 'user' } })),
entry(ev('turn/start', 2, { turn: 1 })),
entry(ev('step/start', 3, { turn: 1, step: 1 })),
entry(ev('assistant/chunk', 4, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'par' } })),
entry(ev('assistant/live-chunk', 4, { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'par' } })),
entry(ev('assistant/message', 5, { turn: 1, step: 1, message: { content: textBlocks('final answer') } })),
]
const rows = transcriptRows(entries)
+19 -6
View File
@@ -40,6 +40,8 @@ interface FakeContext {
sessions: { get: (id: string) => { header: { cwd?: string } } | undefined }
tools: { register: (tool: unknown) => () => void }
effect: (fn: () => void | (() => void), label?: string) => void
/** The session/agent event feeds: nothing emits in these tests. */
on: (event: string, listener: (payload: never) => void) => () => void
/** The settings service never appears in the smoke context: the inject
* callback must never run (mirror of cordis' service-less inject). */
inject: (deps: readonly string[], callback: (sctx: never) => void) => () => void
@@ -100,6 +102,7 @@ describe('host plugin smoke', () => {
// No settings service in the smoke context: the registration callback
// never runs (cordis' service-less inject behaves the same).
inject: () => () => {},
on: () => () => {},
// No jobs/agents services: the jobs routes degrade to a 503.
get: () => undefined,
}
@@ -136,6 +139,7 @@ describe('host plugin smoke', () => {
if (typeof cleanup === 'function') effects.push(cleanup)
},
inject: () => () => {},
on: () => () => {},
get: () => undefined,
}
try {
@@ -492,7 +496,7 @@ describe('git destructive operations (scratch repository)', () => {
describe('session cwd resolution over the API route', () => {
interface CtxOverrides {
sessions?: { get: (id: string) => { header: { cwd?: string } } | undefined }
sessionPersistence?: { inspect: (id: string) => Promise<{ meta: { cwd?: string } }> }
sessionPersistence?: { open: (id: string, access: 'read' | 'write') => Promise<{ header: { cwd?: string }; read: () => Promise<{ events: never[] }>; close: () => Promise<void> }> }
}
const mountAll = (overrides: CtxOverrides = {}): SidebarWebRoute[] => {
@@ -509,6 +513,8 @@ describe('session cwd resolution over the API route', () => {
effect: (fn: () => void | (() => void)) => { fn() },
// No settings service: the namespace registration never runs.
inject: () => () => {},
// The session/agent event feeds: nothing emits in these tests.
on: () => () => {},
// No jobs/agents services in the smoke context: the routes degrade.
get: (key: string) => key === 'sessionPersistence' ? overrides.sessionPersistence : undefined,
}
@@ -575,8 +581,10 @@ describe('session cwd resolution over the API route', () => {
const coldCwd = resolvePath('/cold-project-cwd')
const route = mount({
sessionPersistence: {
inspect: async (id) => ({
meta: id === 's-cold' ? { cwd: coldCwd } : {},
open: async (id) => ({
header: id === 's-cold' ? { cwd: coldCwd } : {},
read: async () => ({ events: [] }),
close: async () => {},
}),
},
})
@@ -592,7 +600,7 @@ describe('session cwd resolution over the API route', () => {
// potentially recreate the original "outside workspace" misclassification.
const route = mount({
sessionPersistence: {
inspect: async () => ({ meta: { cwd: 'relative/path' } }),
open: async () => ({ header: { cwd: 'relative/path' }, read: async () => ({ events: [] }), close: async () => {} }),
},
})
const result = await invoke(route, 'session.cwd', { sessionId: 's-bad' })
@@ -603,7 +611,7 @@ describe('session cwd resolution over the API route', () => {
it('falls back to the process cwd when persistence has no cwd for the session', async () => {
const route = mount({
sessionPersistence: {
inspect: async () => ({ meta: {} }),
open: async () => ({ header: {}, read: async () => ({ events: [] }), close: async () => {} }),
},
})
const result = await invoke(route, 'session.cwd', { sessionId: 's-blank' })
@@ -877,6 +885,8 @@ describe('side card settings routes', () => {
if (deps.includes('settings') && settings !== undefined) callback({ settings })
return () => {}
},
// The session/agent event feeds: nothing emits in these tests.
on: () => () => {},
// No jobs/agents services: the jobs routes degrade to a 503.
get: () => undefined,
}
@@ -990,7 +1000,6 @@ describe('side card settings routes', () => {
bottomPanelAutoTerminal: true,
terminalFontFamily: '',
terminalFontSize: 13,
interceptOpenPath: true,
editorExplorer: false,
workspaceFence: true,
terminalShell: '',
@@ -1170,6 +1179,8 @@ describe('agent terminal tool gating', () => {
if (deps.includes('settings')) callback({ settings })
return () => {}
},
// The session/agent event feeds: nothing emits in these tests.
on: () => () => {},
// No jobs/agents services: the jobs routes degrade to a 503.
get: () => undefined,
}
@@ -1227,6 +1238,8 @@ describe('agent sidebar-open tool gating', () => {
if (deps.includes('settings')) callback({ settings })
return () => {}
},
// The session/agent event feeds: nothing emits in these tests.
on: () => () => {},
get: () => undefined,
}
apply(ctx as never)
+2 -2
View File
@@ -46,11 +46,11 @@ describe('subagent activity summary parser', () => {
])).toEqual({ text: 'ok' })
})
it('lastActivity ignores lifecycle events, chunks, and text-less messages', () => {
it('lastActivity ignores lifecycle events, stream rows, and text-less messages', () => {
const live = lastActivity([
entry('turn/end', { turn: 1, reason: 'success' }),
entry('step/start', { turn: 1, step: 1 }),
entry('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text', delta: 'x' } }),
entry('assistant/live-chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'x' } }),
entry('assistant/message', { turn: 1, step: 1, message: { content: [{ type: 'tool_use', name: 'bash' }] } }),
])
expect(live).toEqual({})
+2
View File
@@ -77,6 +77,8 @@ function mount(initialTrustedHosts: readonly string[] = []): {
if (typeof cleanup === 'function') effects.push(cleanup)
},
inject: () => () => {},
// The session/agent event feeds: nothing emits in these tests.
on: () => () => {},
get: () => undefined,
}
apply(ctx as never)