feat: adapt to DSH 0.1.7-alpha.1 and yield the read-only previews

DSH 0.1.7-alpha.1 is 1299 commits past 0.1.6-alpha.2 and rewrites four
contracts this plugin was built on. The peer floor moves to
^0.1.7-alpha.1 (semver's prerelease rule means ^0.1.6-alpha.2 can never
match a 0.1.7 prerelease); 0.1.6-alpha.2 and earlier stay on v0.19.1.
Released as 0.21.0-alpha.1 (dist-tag alpha).

Hard breaks:
- dsh-settings was rewritten: register(ns, schema) is gone, a form is
  addressed by the plugin row's Loader entry id and read from the
  module's exported Config. The preferences are merged into Config and
  marked volatile — which requires DSH's own schemastery, because only
  that build wraps a volatile field in the cosmokit reference the
  loader's volatile commit walks. With the public package a settings
  write reports success and revision 1 while the effective value never
  changes.
- ui-primitives renamed its whole icon family (IconXxx14/16 ->
  IconXxxRegular/Medium): 26 imported symbols, 17 files. The failing
  mode is render-time, not load-time. Five call sites that relied on the
  old 14px default now pass size={14} explicitly.
- Session format v4 rejects source.kind === 'plugin'; the side-chat
  boundary injection now writes plugin:dsh-better-sidebar, the same kind
  DSH's own v3->v4 migration produces, so historical threads survive.
  Tool results moved to role 'tool' with top-level content/isError; all
  four parsers accept both shapes.
- The client session snapshot dropped subagentsByParent/jobsBySession
  and the on-demand subagent-catalog observation API. The Tasks page
  reads projectionsBySession and a new jobs.list route.

Yields and convergence:
- The read-only previews go to DSH's documentpreview (Office->PDF, a
  spreadsheet worker, zoom, per-directory refresh); editor.canOpen
  refuses those 26 extensions. Markdown, HTML (with its sandbox
  switches) and the editable code buffer stay.
- The host browser kind is desktop-profile-only now, so the web e2e
  lanes expect no browser entry and assert its absence.
- Link takeover claims only urlTarget links and passes the rest to the
  host, fixing a regression from the 0.1.6 round: the fallback pointed
  at a 'browser' type the plugin no longer registers, so http links in
  its own markdown did nothing.

Also fixed, found along the way:
- The file tree refreshes live (new directory watch over the plugin's
  own socket), since the plugin owns the `files` kind.
- ctx.sessions.list has no `current` field at either tag; the three
  readers were dead. They now use ctx.sidebarRight.mounted.
- package.json.files was missing lib/client-locale.js.
- Two genuinely dead call sites: sessions.openSubagent/open/
  refreshSubagents do not exist on the current baseline, so the
  topology click, the jump-back and the retry were all no-ops.
- The test harness window shim lacked addEventListener, which the 0.1.7
  primitives call at module-evaluation time; and the e2e token exchange
  needs Playwright's request stack, not Node's fetch.
This commit is contained in:
Menghuan1918
2026-09-22 17:09:08 +08:00
parent de99fd4cfb
commit 6586e3e2d3
96 changed files with 4567 additions and 2080 deletions
+7
View File
@@ -55,6 +55,13 @@ if (g.window === undefined) {
innerWidth: 1024,
innerHeight: 768,
getComputedStyle: () => ({ getPropertyValue: () => '' }),
// DSH 0.1.7's ui-primitives subscribes at MODULE EVALUATION time (a shared
// scroll/resize placement effect), so a window stub without these throws
// before any spec body runs.
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => true,
matchMedia: () => ({ matches: false, addEventListener: () => {}, removeEventListener: () => {} }),
// desktop-env.ts reads the shell's URL stamps from location.search
// (Sidebar renders with parseDesktopEnv even in specs).
location: { search: '' },
+69 -24
View File
@@ -1,9 +1,12 @@
/**
* Built-in registration tests: the plugin registers 5 tabs and 6 file
* Built-in registration tests: the plugin registers 5 tabs and 3 file
* viewers through the same service external plugins use (dogfooding);
* the catch-all `code` viewer, the NUL-sniffing `binary-download` viewer,
* and the html sandbox settings pin the registry's behavior. (Office
* previews are NOT built in — they moved to the recommended office plugin,
* the catch-all `code` viewer and the html sandbox settings pin the
* registry's behavior. The read-only previews (image / pdf /
* binary-download) were yielded to the host's own document preview
* (DSH 0.1.7 `ui-sidebar-documentpreview`) and legacy Office
* doc/xls/ppt now fall through to the host too. (Office previews were
* already NOT built in — they moved to the recommended office plugin,
* see src/client/plugins-viewers.ts.) The git tab is the unified changes
* tab (git lens + session lens, PR #471's file-trace merged in).
*/
@@ -15,6 +18,7 @@ import './browser-globals.ts'
import type { Context } from '../src/context-types.ts'
import { createBetterSidebarService } from '../src/client/service.ts'
import { planFsReadOutcome } from '../src/client/editor-load.ts'
import { createSidebarStore } from '../src/client/state.ts'
import { registerBuiltins } from '../src/client/builtins/index.ts'
import { parkSidechatReopen } from '../src/client/SideChatView.tsx'
@@ -180,16 +184,38 @@ describe('built-in tab registrations', () => {
})
describe('built-in file viewer registrations', () => {
it('registers the 6 built-in file viewers (office previews live in the recommended office plugin)', () => {
it('registers the 3 built-in file viewers (the read-only previews yielded to the host)', () => {
const { service } = setup()
expect(service.getFileViewers().map(v => v.id).sort()).toEqual(
['binary-download', 'code', 'html', 'image', 'markdown', 'pdf'],
['code', 'html', 'markdown'],
)
// Office previews are not built in: docx/xlsx/pptx files fall through to
// the download-only binary viewer (or a registered office plugin).
expect(service.getFileViewers().map(v => v.id)).not.toContain('docx')
expect(service.getFileViewers().map(v => v.id)).not.toContain('xlsx')
expect(service.getFileViewers().map(v => v.id)).not.toContain('pptx')
// The yielded ids are gone: the host's own document preview
// (ui-sidebar-documentpreview) owns images, pdf, spreadsheets, office
// and plain text; the plugin keeps only markdown / html / the editable
// code catch-all.
for (const id of ['image', 'pdf', 'binary-download']) {
expect(service.getFileViewers().map(v => v.id), `${id} must not be built in`).not.toContain(id)
}
// Office previews are not built in either: they live in the recommended
// office plugin (which registers the ids through this service).
for (const id of ['docx', 'xlsx', 'pptx']) {
expect(service.getFileViewers().map(v => v.id)).not.toContain(id)
}
})
it('the yielded viewer ids stay registrable by external plugins (public contract)', () => {
const { service } = setup()
// Dropping the built-in descriptors must not freeze the ids: the
// registry API keeps accepting them from third-party plugins.
const dispose = service.registerFileViewer({
id: 'image',
exts: ['png'],
fetchStrategy: 'mediaUrl',
component: () => null,
})
expect(service.matchFileViewer('photo.png')?.id).toBe('image')
dispose()
expect(service.matchFileViewer('photo.png')?.id).toBe('code')
})
it('code is the catch-all at the lowest priority', () => {
@@ -226,24 +252,43 @@ describe('built-in file viewer registrations', () => {
expect(toggles[1]?.desc).toBeDefined()
})
it('binary-download claims legacy office by extension (office previews are not built in)', () => {
it('no built-in viewer claims the yielded extensions (the host document preview does)', () => {
const { service } = setup()
expect(service.matchFileViewer('old.doc')?.id).toBe('binary-download')
expect(service.matchFileViewer('old.xls')?.id).toBe('binary-download')
expect(service.matchFileViewer('old.ppt')?.id).toBe('binary-download')
// Modern office files (zip containers, NUL-free) fall through to the
// catch-all code viewer without an office plugin registered.
expect(service.matchFileViewer('book.docx', new Uint8Array([0x50, 0x4b, 0x03, 0x04]))?.id).toBe('code')
// The spreadsheet / pdf / image / office set yielded to the host: the
// only plugin-side match left is the catch-all code viewer, so the
// `editor` type can hand these addresses over (see native/index.ts's
// canOpen). Legacy doc/xls/ppt were previously claimed by the removed
// `binary-download` viewer and are now the host's too.
for (const path of [
'book.xlsx', 'legacy.xls', 'macro.xlsb', 'sheet.xlt', 'template.xltx', 'macro.xltm',
'flat.ods', 'flat.ots', 'flat.fods', 'data.csv', 'data.tsv',
'paper.pdf', 'photo.png', 'photo.jpg', 'anim.gif', 'photo.webp', 'art.svg',
'tile.bmp', 'favicon.ico', 'next.avif',
'report.docx', 'report.doc', 'notes.dot', 'notes.dotx',
'deck.pptx', 'deck.ppt',
]) {
expect(service.matchFileViewer(path)?.id, path).toBe('code')
}
})
it('binary-download NUL detect claims unknown-extension binaries over code', () => {
it('an unknown-extension binary still downloads (the catch-all + host fallback)', () => {
const { service } = setup()
// First match (no head) falls to the catch-all code viewer...
// Without the removed `binary-download` descriptor no built-in viewer
// carries a `detect` probe, so the catch-all blind-claims everything...
expect(service.matchFileViewer('blob.zzz')?.id).toBe('code')
// ...but the head re-match (NUL probe) routes it to binary-download.
expect(service.matchFileViewer('blob.zzz', new Uint8Array([0x01, 0x00, 0x02]))?.id).toBe('binary-download')
// A NUL-free blob stays with code.
expect(service.matchFileViewer('blob.zzz', new Uint8Array([0x61, 0x62]))?.id).toBe('code')
expect(service.getFileViewers().some(v => v.detect !== undefined)).toBe(false)
// ...and the head re-match (the same call the editor host makes on a
// binary fsRead result) finds no sniffer either: the fsRead catch-all
// cannot render binary, so planFsReadOutcome hands the host its
// `binary` outcome and the editor renders the download pane.
expect(service.matchFileViewer('blob.zzz', new Uint8Array([0x01, 0x00, 0x02]))?.id).toBe('code')
const code = service.getFileViewers().find(v => v.id === 'code')
expect(planFsReadOutcome(code!, {
binary: true,
content: '',
truncated: false,
head: Buffer.from([0x01, 0x00, 0x02]).toString('base64'),
}, (head) => service.matchFileViewer('blob.zzz', head), () => '/media')).toEqual({ kind: 'binary' })
})
it('every built-in viewer carries the declarative settings surface (title + icon)', () => {
+44 -1
View File
@@ -4,6 +4,7 @@
* (their full behavior suites live in the standalone dsh-file-trace plugin).
*/
import { describe, expect, it } from 'vitest'
import { createToolResultMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
import { extractFileOps, groupByFile, knownContentBefore, parseReadContent, parseReadLines } from '../src/client/changes/ops.ts'
import { diffLines, buildDiffSegments, coalesceInline, diffInline } from '../src/client/diff/rows.ts'
import { langOfPath, scanLine } from '../src/client/diff/highlight.ts'
@@ -19,10 +20,26 @@ function call(seq: number, name: string, callId: string, args: unknown, time = s
return ev('tool/call', seq, time, { name, callId, arguments: JSON.stringify(args) })
}
/** A tool/result event carrying one tool-result block with inner text. */
/** A tool/result event carrying one first-class tool-role message (the 0.1.7
* shape: result blocks and `isError` at the message's own top level). */
function result(seq: number, callId: string, text: string, isError = false, time = seq): SidebarSessionEvent {
return ev('tool/result', seq, time, {
message: {
role: 'tool',
source: { kind: 'tool', callId },
toolCallId: callId,
isError,
content: [{ type: 'text', text }],
},
})
}
/** The retired 0.1.6 shape: a role-'user' message wrapping the result in one
* `tool-result` content block. Historical logs still carry it. */
function legacyResult(seq: number, callId: string, text: string, isError = false, time = seq): SidebarSessionEvent {
return ev('tool/result', seq, time, {
message: {
role: 'user',
source: { kind: 'tool', callId },
content: [{ type: 'tool-result', isError, content: [{ type: 'text', text }] }],
},
@@ -96,6 +113,32 @@ describe('extractFileOps', () => {
expect(ops.map(op => op.callId)).toEqual(['r'])
})
it('reads the tool/result message dsh-llm actually produces (producer round-trip)', () => {
const message = createToolResultMessage({
callId: ToolCallId('r'),
content: [{ type: 'text', text: 'produced file body' }],
isError: false,
})
const ops = extractFileOps([
call(1, 'read', 'r', { file_path: 'a.ts' }),
ev('tool/result', 2, 2, { message: message as unknown as Record<string, unknown> }),
])
expect(ops[0]!.running).toBe(false)
expect(ops[0]!.read).toBe('produced file body')
})
it('reads a LEGACY 0.1.6-era tool-result wrapper (historical logs keep that shape)', () => {
const ops = extractFileOps([
// No content in the model's arguments, so the settled result supplies it.
call(1, 'write', 'w', { file_path: 'a' }),
legacyResult(2, 'w', 'legacy write detail'),
call(3, 'read', 'r', { file_path: 'b' }),
legacyResult(4, 'r', 'cannot read "b": not found', true),
])
expect(ops.find(op => op.kind === 'write')?.content).toBe('legacy write detail')
expect(ops.find(op => op.kind === 'read')?.errorText).toBe('cannot read "b": not found')
})
it('groups by file newest-first and recovers prior write content', () => {
const ops = extractFileOps([
call(1, 'write', 'w1', { file_path: 'a.ts', content: 'first' }),
+40 -8
View File
@@ -60,6 +60,41 @@ export async function exchangeLaunchCookie(
/** The `name=value` pair of the auth cookie, once per process. */
let cookieHeader: string | undefined
/**
* Exchange the launch token through Playwright's own request stack.
*
* {@link exchangeLaunchCookie} takes any `fetch`, but the Node global one
* cannot stand in for a browser here: the host's static fallback answers a
* plain Node GET of the token URL with an empty `405` and no `Set-Cookie`
* (reproduced on both `0.1.5-rc.2` and `0.1.7-alpha.1`, so it is a harness
* quirk rather than a host regression), which left every later `/api` call
* unauthenticated and the lane's session seeding dead. Playwright's request
* context speaks enough of a browser shape to complete the exchange, and
* `maxRedirects: 0` keeps the `303` from being followed — following it drops
* the `Set-Cookie` this exists to capture.
* @param launchUrl - the token URL as printed by the host.
* @returns the `name=value` cookie pair.
*/
export async function exchangeLaunchCookieViaPlaywright(launchUrl: string): Promise<string> {
const context = await request.newContext()
try {
await context.get(launchUrl, { maxRedirects: 0 })
const [cookie] = (await context.storageState()).cookies
if (cookie === undefined) {
throw new Error('token exchange produced no cookie — cannot authenticate /api seeding')
}
return `${cookie.name}=${cookie.value}`
} finally {
await context.dispose()
}
}
/** The exchanged cookie pair, memoised for the process. */
async function authCookie(): Promise<string> {
cookieHeader ??= await exchangeLaunchCookieViaPlaywright(RAW_URL)
return cookieHeader
}
/** page.goto through the host auth. A plain navigation goes to PAGE_URL (the
* token URL performs the browser's exchange), but a STAMPED navigation
* cannot: the exchange answers `303 → /` and silently DROPS every sibling
@@ -69,11 +104,11 @@ let cookieHeader: string | undefined
* context, so the seeding is per-call and idempotent. */
export async function gotoPage(page: Page, extra: Record<string, string> = {}): Promise<void> {
if (Object.keys(extra).length > 0) {
if (cookieHeader === undefined) cookieHeader = await exchangeLaunchCookie(RAW_URL)
const eq = cookieHeader.indexOf('=')
const header = await authCookie()
const eq = header.indexOf('=')
await page.context().addCookies([{
name: cookieHeader.slice(0, eq),
value: cookieHeader.slice(eq + 1),
name: header.slice(0, eq),
value: header.slice(eq + 1),
url: ORIGIN,
}])
await page.goto(pageUrlWith(ORIGIN, extra), { waitUntil: 'domcontentloaded' })
@@ -84,12 +119,9 @@ export async function gotoPage(page: Page, extra: Record<string, string> = {}):
/** Authenticated request context for host RPC seeding (cookie attached). */
export async function createHostApi(): Promise<APIRequestContext> {
if (cookieHeader === undefined) {
cookieHeader = await exchangeLaunchCookie(RAW_URL)
}
return request.newContext({
baseURL: ORIGIN,
extraHTTPHeaders: { cookie: cookieHeader },
extraHTTPHeaders: { cookie: await authCookie() },
})
}
+21 -6
View File
@@ -58,16 +58,22 @@ const SEEDED_README_FILE = 'readme-style.md'
const CRASH_STRIP_PATTERNS = [/^dsh-better-sidebar:/, /^\[dsh-better-sidebar\]/]
/**
* The tab types the plugin itself contributes, plus the host-owned kinds the
* The tab types the plugin itself contributes, plus the host-owned kind the
* guide is asserted to still offer.
*
* `terminal` and `browser` are DSH 0.1.6's own right-Sidebar types: this
* plugin deliberately ships neither (it used to own both), so the guide must
* show exactly ONE entry for each when the host mounts those packages — a
* second entry would mean the plugin is shadowing the host again.
* `terminal` is DSH's own right-Sidebar type: this plugin deliberately ships
* neither terminal nor browser (it used to own both), so the guide must show
* exactly ONE `terminal` entry when the host mounts that package — a second
* entry would mean the plugin is shadowing the host again.
*
* `browser` is deliberately NOT in this list and is asserted ABSENT below: DSH
* 0.1.7 disables `@deepseek-ai/dsh-client-ui-sidebar-browser` outside the
* desktop profile ("Web profiles opt in; Desktop retains sandboxed HTTP(S)
* Browser tabs"), and every lane here drives the web profile. Listing it would
* demand a guide entry the host itself has stopped offering.
*/
const PLUGIN_TABS = ['files', 'git', 'subagent', 'sidechat'] as const
const HOST_OWNED_TABS = ['terminal', 'browser'] as const
const HOST_OWNED_TABS = ['terminal'] as const
const NATIVE_TABS: readonly string[] = [...PLUGIN_TABS, ...HOST_OWNED_TABS]
let api: APIRequestContext
@@ -298,6 +304,15 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
).toHaveCount(1)
}
// The browser kind the plugin handed to the host is NOT offered on a web
// profile: DSH 0.1.7 mounts its browser package only for the desktop
// profile. Pinned rather than merely omitted, so a future lane that starts
// seeing a browser entry learns the host changed its mind.
await expect(
page.locator('[data-sidebar-right-guide-entry="browser"]'),
'the web profile must not offer a browser guide entry at DSH 0.1.7',
).toHaveCount(0)
// Sweep every type through the guide. Each open mounts a real viewer (the
// editor and mermaid chunks arrive lazily); a failure anywhere surfaces as a
// pageerror or a crash strip, both of which the next assertion sees. A pane
+10 -5
View File
@@ -34,12 +34,17 @@ import { PAGE_URL, createHostApi, hostRpc, sendFirstMessage } from './host'
const WORKSPACE_PATH = process.env.DSH_E2E_PERF_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-perf-workspace')
/**
* Every tab type the composed guide must offer: the plugin's own four plus
* DSH 0.1.6's host-owned terminal and browser. This plugin deliberately ships
* neither of those, so exactly one entry each proves it is not shadowing the
* host. The ids are the guides' `data-sidebar-right-guide-entry` keys.
* Every tab type the composed guide must offer: the plugin's own four plus the
* host's `terminal`. This plugin deliberately ships neither terminal nor
* browser, so exactly one `terminal` entry proves it is not shadowing the host.
*
* `browser` is deliberately absent: DSH 0.1.7 mounts
* `@deepseek-ai/dsh-client-ui-sidebar-browser` only for the desktop profile and
* this lane drives the web profile, so demanding a `browser` entry would demand
* something the host itself stopped offering. mount.e2e.ts asserts the absence
* explicitly. The ids are the guides' `data-sidebar-right-guide-entry` keys.
*/
const NATIVE_TABS = ['files', 'git', 'subagent', 'sidechat', 'terminal', 'browser'] as const
const NATIVE_TABS = ['files', 'git', 'subagent', 'sidechat', 'terminal'] as const
let api: APIRequestContext
+127
View File
@@ -0,0 +1,127 @@
/**
* Directory-watch spec: the file tree's live refresh rests on this module, so
* the tests use real directories and the real `fs.watch` rather than a stub —
* the debounce, the handle accounting and the error path are the behaviour
* being pinned.
*
* Timing note: `fs.watch` delivery is neither prompt nor one-to-one with the
* writes that caused it (macOS FSEvents batches on its own schedule, and a
* loaded machine stretches it further). The assertions below therefore pin
* the PROPERTIES the tree depends on — a change is reported, a burst is
* folded, an unwatched directory stays silent — instead of an exact event
* count, and every wait is given a budget far above the module's 150 ms
* debounce.
*/
import { describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createDirectoryWatchers, type DirectoryWatchEvent } from '../src/fs-watch.ts'
/** Generous delivery budget: the module debounces at 150 ms, the OS adds its own latency. */
const DELIVERY_TIMEOUT_MS = 15_000
/** Long enough for a second (tail) delivery to have surfaced, so "folded" means something. */
const SETTLE_MS = 1_000
/** One scratch tree with a directory to watch. */
const withScratch = (run: (dir: string) => Promise<void> | void): Promise<void> => {
const root = mkdtempSync(join(tmpdir(), 'dsh-sidebar-watch-'))
const dir = join(root, 'watched')
mkdirSync(dir)
return Promise.resolve(run(dir)).finally(() => { rmSync(root, { recursive: true, force: true }) })
}
/** Sleep for a fixed window. */
const settle = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))
describe('directory watchers', () => {
it('folds a burst of changes into a single notice', async () => {
await withScratch(async (dir) => {
const events: DirectoryWatchEvent[] = []
const watchers = createDirectoryWatchers(event => events.push(event), () => {})
try {
expect(watchers.add(dir)).toBe(true)
// One save is several filesystem events; the tree re-lists the whole
// directory either way, so they must collapse. Twenty writes make the
// fold unambiguous even if the OS delivers one late tail batch.
for (let index = 0; index < 20; index++) writeFileSync(join(dir, `f${index}.txt`), 'x')
await vi.waitFor(() => { expect(events.length).toBeGreaterThan(0) }, { timeout: DELIVERY_TIMEOUT_MS })
await settle(SETTLE_MS)
expect(events.length).toBeLessThanOrEqual(2)
for (const event of events) expect(event).toEqual({ dir })
} finally {
watchers.close()
}
})
})
it('stops reporting once a directory is unwatched or closed', async () => {
await withScratch(async (dir) => {
const events: DirectoryWatchEvent[] = []
const watchers = createDirectoryWatchers(event => events.push(event), () => {})
watchers.add(dir)
watchers.remove(dir)
// Removing twice is a no-op, never a throw.
watchers.remove(dir)
writeFileSync(join(dir, 'a.txt'), 'x')
// The waiting budget matches the positive case: a silent assertion is
// only meaningful if an event had ample time to arrive.
await settle(SETTLE_MS)
expect(events).toEqual([])
// Closing the whole set is idempotent too.
watchers.close()
watchers.close()
})
})
it('treats a repeat add as already-watched', async () => {
await withScratch(async (dir) => {
const events: DirectoryWatchEvent[] = []
const watchers = createDirectoryWatchers(event => events.push(event), () => {})
try {
expect(watchers.add(dir)).toBe(true)
// A second add must not install a second watcher — that would double
// every notice for the same directory.
expect(watchers.add(dir)).toBe(true)
writeFileSync(join(dir, 'a.txt'), 'x')
await vi.waitFor(() => { expect(events.length).toBeGreaterThan(0) }, { timeout: DELIVERY_TIMEOUT_MS })
await settle(SETTLE_MS)
expect(events.length).toBeLessThanOrEqual(2)
} finally {
watchers.close()
}
})
})
it('caps the handles one connection may hold', async () => {
await withScratch(async (dir) => {
const root = join(dir, '..')
const watchers = createDirectoryWatchers(() => {}, () => {})
try {
// The cap is 64: the first 64 directories are watched, the rest are
// refused rather than throwing, and a refusal evicts nothing.
let accepted = 0
for (let index = 0; index < 70; index++) {
const child = join(root, `child-${index}`)
mkdirSync(child)
if (watchers.add(child)) accepted += 1
}
expect(accepted).toBe(64)
} finally {
watchers.close()
}
})
})
it('reports an unwatchable directory instead of throwing', async () => {
const failures: string[] = []
const watchers = createDirectoryWatchers(() => {}, (dir) => { failures.push(dir) })
try {
const missing = join(tmpdir(), 'dsh-sidebar-watch-does-not-exist-42')
expect(watchers.add(missing)).toBe(false)
expect(failures).toEqual([missing])
} finally {
watchers.close()
}
})
})
+75 -21
View File
@@ -7,15 +7,16 @@
* by the owning session, with a 503 when the registry is absent.
*/
import { describe, expect, it, vi } from 'vitest'
import { createToolResultMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
import { buildJobsApi } from '../src/jobs-routes.ts'
import { SidebarError } from '../src/wire.ts'
import type { Context, SidebarSessionEvent } from '../src/context-types.ts'
/** A context whose `get` serves only the jobs/agents faces, with a session store. */
function ctxWith(sessions: unknown, jobs: unknown, agents: unknown): Context {
/** A context whose `get` serves only the jobs face, with a session store. */
function ctxWith(sessions: unknown, jobs: unknown): Context {
return {
sessions,
get: (key: string) => (key === 'jobs' ? jobs : key === 'agents' ? agents : undefined),
get: (key: string) => (key === 'jobs' ? jobs : undefined),
} as unknown as Context
}
@@ -25,7 +26,7 @@ function ctxWithFeed(sessions: unknown): {
emit: (session: unknown, event: SidebarSessionEvent) => void
} {
let listener: ((session: unknown, event: SidebarSessionEvent) => void) | undefined
const base = ctxWith(sessions, undefined, undefined) as unknown as {
const base = ctxWith(sessions, undefined) as unknown as {
on: (event: string, fn: (session: unknown, event: SidebarSessionEvent) => void) => () => void
effect: (fn: () => void | (() => void)) => void
}
@@ -41,15 +42,13 @@ function ctxWithFeed(sessions: unknown): {
}
}
/** A stub live agent (the fence compares `id` only). */
const agent = (id: string) => ({ id, session: { header: { cwd: '/p' } } })
/** One job_output tool/call event. */
function jobOutputCall(seq: number, callId: string, jobId: string): SidebarSessionEvent {
return { type: 'tool/call', seq, time: seq, data: { name: 'job_output', callId, arguments: JSON.stringify({ job_id: jobId }) } }
}
/** One tool/result event carrying the finalized text the model received. */
/** One tool/result event carrying the finalized text the model received —
* the 0.1.7 first-class tool-role shape (content + isError on the message). */
function jobOutputResult(seq: number, callId: string, text: string, over: { isError?: boolean } = {}): SidebarSessionEvent {
return {
type: 'tool/result',
@@ -57,6 +56,26 @@ function jobOutputResult(seq: number, callId: string, text: string, over: { isEr
time: seq,
data: {
message: {
role: 'tool',
source: { kind: 'tool', callId },
toolCallId: callId,
isError: over.isError === true,
content: [{ type: 'text', text }],
},
},
}
}
/** The retired 0.1.6 shape: role-'user' message wrapping the result in one
* `tool-result` block. Historical logs still carry it. */
function legacyJobOutputResult(seq: number, callId: string, text: string, over: { isError?: boolean } = {}): SidebarSessionEvent {
return {
type: 'tool/result',
seq,
time: seq,
data: {
message: {
role: 'user',
source: { kind: 'tool', callId },
content: [{
type: 'tool-result',
@@ -85,7 +104,7 @@ describe('jobs.output route (event replay)', () => {
jobOutputCall(4, 'c3', 'bash-2'),
jobOutputResult(5, 'c3', 'other job output'),
]
const api = buildJobsApi(ctxWith({ get: () => session(events) }, undefined, undefined), 512 * 1024)
const api = buildJobsApi(ctxWith({ get: () => session(events) }, undefined), 512 * 1024)
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({
text: 'line1\nline2\n[status: running]\nline3\n[status: completed, exit code: 0]',
truncated: false,
@@ -102,7 +121,7 @@ describe('jobs.output route (event replay)', () => {
jobOutputCall(4, 'c3', 'bash-1'),
jobOutputResult(5, 'c3', 'boom', { isError: true }),
]
const api = buildJobsApi(ctxWith({ get: () => session(events) }, undefined, undefined), 512 * 1024)
const api = buildJobsApi(ctxWith({ get: () => session(events) }, undefined), 512 * 1024)
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({
text: 'real output\n[status: running]',
truncated: false,
@@ -110,10 +129,45 @@ describe('jobs.output route (event replay)', () => {
})
})
it('reads the tool/result message dsh-llm actually produces (producer round-trip)', () => {
const message = createToolResultMessage({
callId: ToolCallId('c1'),
content: [{ type: 'text', text: 'produced output\n[status: running]' }],
isError: false,
})
const events: SidebarSessionEvent[] = [
jobOutputCall(0, 'c1', 'bash-1'),
{ type: 'tool/result', seq: 1, time: 1, data: { message } },
]
const api = buildJobsApi(ctxWith({ get: () => session(events) }, undefined), 512 * 1024)
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({
text: 'produced output\n[status: running]',
truncated: false,
read: true,
})
})
it('reads a LEGACY 0.1.6-era tool-result wrapper (historical logs keep that shape)', () => {
// The first legacy read is an error (its wrapper flag must still be
// honored), the second supplies the text.
const events = [
jobOutputCall(0, 'c1', 'bash-1'),
legacyJobOutputResult(1, 'c1', 'legacy boom', { isError: true }),
jobOutputCall(2, 'c2', 'bash-1'),
legacyJobOutputResult(3, 'c2', 'legacy line\n[status: running]'),
]
const api = buildJobsApi(ctxWith({ get: () => session(events) }, undefined), 512 * 1024)
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({
text: 'legacy line\n[status: running]',
truncated: false,
read: true,
})
})
it('reports read:false until the model reads the job (no registry call at all)', () => {
const events = [jobOutputCall(0, 'c1', 'bash-2')]
const jobs = { kill: vi.fn() }
const api = buildJobsApi(ctxWith({ get: () => session(events) }, jobs, undefined), 100)
const api = buildJobsApi(ctxWith({ get: () => session(events) }, jobs), 100)
expect(api.output({ sessionId: 's1', id: 'bash-1' })).toEqual({ text: '', truncated: false, read: false })
// The replay never touches the registry — the model's cursor is safe by construction.
expect(jobs.kill).not.toHaveBeenCalled()
@@ -170,7 +224,7 @@ describe('jobs.output route (event replay)', () => {
it('caps oversized replays with the truncated flag', () => {
const events = [jobOutputCall(0, 'c1', 'bash-1'), jobOutputResult(1, 'c1', 'x'.repeat(10_000))]
const api = buildJobsApi(ctxWith({ get: () => session(events) }, undefined, undefined), 100)
const api = buildJobsApi(ctxWith({ get: () => session(events) }, undefined), 100)
const value = api.output({ sessionId: 's1', id: 'bash-1' })
expect(value.text).toBe('x'.repeat(100))
expect(value.truncated).toBe(true)
@@ -178,39 +232,39 @@ describe('jobs.output route (event replay)', () => {
})
it('rejects a missing sessionId or id as bad-request', () => {
const api = buildJobsApi(ctxWith({ get: () => undefined }, undefined, undefined), 100)
const api = buildJobsApi(ctxWith({ get: () => undefined }, undefined), 100)
expect(() => api.output({ id: 'bash-1' })).toThrowError(expect.objectContaining<Partial<SidebarError>>({ code: 'bad-request' }))
expect(() => api.output({ sessionId: 's1' })).toThrowError(expect.objectContaining<Partial<SidebarError>>({ code: 'bad-request' }))
})
})
describe('jobs.kill route', () => {
it('kills with the forwarded reason and the live caller', () => {
it('kills with the forwarded reason and the owning session id', () => {
const jobs = { kill: vi.fn(() => 'requested' as const) }
const agents = { get: vi.fn((id: string) => agent(id)) }
const api = buildJobsApi(ctxWith({ get: () => undefined }, jobs, agents), 100)
const api = buildJobsApi(ctxWith({ get: () => undefined }, jobs), 100)
expect(api.kill({ sessionId: 's1', id: 'bash-1', reason: 'user pressed stop' }))
.toEqual({ ok: true, outcome: 'requested' })
expect(jobs.kill).toHaveBeenCalledWith('bash-1', agent('s1'), 'user pressed stop')
// 0.1.7's registry fences by SessionId; the 0.1.6 Agent object is gone.
expect(jobs.kill).toHaveBeenCalledWith('bash-1', 's1', 'user pressed stop')
})
it('defaults the reason when none is supplied', () => {
const jobs = { kill: vi.fn(() => 'already-finished' as const) }
const api = buildJobsApi(ctxWith({ get: () => undefined }, jobs, undefined), 100)
const api = buildJobsApi(ctxWith({ get: () => undefined }, jobs), 100)
expect(api.kill({ sessionId: 's1', id: 'bash-1' })).toEqual({ ok: true, outcome: 'already-finished' })
expect(jobs.kill).toHaveBeenCalledWith('bash-1', undefined, 'user requested via sidebar')
expect(jobs.kill).toHaveBeenCalledWith('bash-1', 's1', 'user requested via sidebar')
})
it('maps registry refusals to a 404 job-error', () => {
const jobs = { kill: vi.fn(() => { throw new Error('unknown job bash-9') }) }
const api = buildJobsApi(ctxWith({ get: () => undefined }, jobs, undefined), 100)
const api = buildJobsApi(ctxWith({ get: () => undefined }, jobs), 100)
expect(() => api.kill({ sessionId: 's1', id: 'bash-9' })).toThrowError(
expect.objectContaining<Partial<SidebarError>>({ code: 'job-error', status: 404 }),
)
})
it('degrades to a 503 when the jobs registry is absent (output keeps working)', () => {
const api = buildJobsApi(ctxWith({ get: () => session([]) }, undefined, undefined), 100)
const api = buildJobsApi(ctxWith({ get: () => session([]) }, undefined), 100)
expect(() => api.kill({ sessionId: 's1', id: 'bash-1' })).toThrowError(
expect.objectContaining<Partial<SidebarError>>({ code: 'job-error', status: 503 }),
)
+203 -19
View File
@@ -1,12 +1,21 @@
// @vitest-environment jsdom
/**
* External-link interception policy tests: the pure decision (which http(s)
* external links are taken over into the sidebar browser), the click shape
* (plain left clicks only — modified clicks always bypass), and the
* registered click capture (the caller's `takeoverEnabled(url)` gate).
* External-link interception tests: the pure decision (which http(s) external
* links a click may hand over), the click shape (plain left clicks only —
* modified clicks always bypass), the takeover gate (only a URL an enabled
* tab type CLAIMS through `urlTarget` is taken over at all), the open step
* (the claimed type, or a real-browser `window.open` fallback when that type
* is gone), and the registered click capture that puts them together.
*/
import { describe, expect, it } from 'vitest'
import { isPlainLeftClick, registerLinkInterception, shouldInterceptLink } from '../src/client/link-intercept.ts'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
isPlainLeftClick,
isTargetAvailable,
openInterceptedLink,
registerLinkInterception,
shouldInterceptLink,
shouldTakeOverLink,
} from '../src/client/link-intercept.ts'
const SELF = 'http://127.0.0.1:3080'
@@ -34,9 +43,8 @@ describe('shouldInterceptLink', () => {
})
it("takes over LAN hosts (the browser's own blocklist, not the link policy)", () => {
// The link takeover funnels into the sidebar browser, which then applies
// its own loopback/self address-bar policy on render — the interception
// itself must not silently drop links the browser will surface as blocked.
// The link takeover only ever hands a link to a tab type that claims it;
// whatever that target does with a loopback/LAN address is its own policy.
expect(shouldInterceptLink('http://192.168.1.1/', SELF)).toBe('http://192.168.1.1/')
})
})
@@ -58,19 +66,147 @@ describe('isPlainLeftClick', () => {
})
})
describe('shouldTakeOverLink', () => {
const url = new URL('http://example.com/page')
it('takes over only a URL some tab type claims', () => {
expect(shouldTakeOverLink(url, { suspended: false, resolveTarget: () => 'my-plugin:page' })).toBe(true)
expect(shouldTakeOverLink(url, { suspended: false, resolveTarget: () => undefined })).toBe(false)
})
it('never consults a claim while the sidebar is suspended', () => {
let claims = 0
expect(shouldTakeOverLink(url, {
suspended: true,
resolveTarget: () => { claims++; return 'my-plugin:page' },
})).toBe(false)
// The suspension short-circuits: no claim is looked up at all.
expect(claims).toBe(0)
})
})
describe('isTargetAvailable', () => {
it('accepts a registered type whose switch is absent or on', () => {
expect(isTargetAvailable('my-plugin:page', [{ id: 'my-plugin:page' }], {})).toBe(true)
expect(isTargetAvailable('my-plugin:page', [{ id: 'my-plugin:page' }], { 'my-plugin:page': true })).toBe(true)
})
it('rejects an unregistered type and a switched-off one', () => {
expect(isTargetAvailable('my-plugin:page', [], {})).toBe(false)
expect(isTargetAvailable('my-plugin:page', [{ id: 'other:page' }], {})).toBe(false)
expect(isTargetAvailable('my-plugin:page', [{ id: 'my-plugin:page' }], { 'my-plugin:page': false })).toBe(false)
})
})
describe('openInterceptedLink', () => {
let openSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
})
afterEach(() => {
openSpy.mockRestore()
})
it('opens the claimed type through the sidebar service, with the host as title', () => {
const opened: Array<{ type: string; url: string; title?: string }> = []
const path = openInterceptedLink('https://docs.example.com/a?b=1', {
resolveTarget: () => 'my-plugin:page',
isAvailable: () => true,
sidebar: { openTab: seed => { opened.push(seed) } },
})
expect(path).toBe('sidebar')
expect(opened).toEqual([{ type: 'my-plugin:page', url: 'https://docs.example.com/a?b=1', title: 'docs.example.com' }])
expect(openSpy).not.toHaveBeenCalled()
})
it('falls back to a real browser tab when the claimed type is gone', () => {
const opened: unknown[] = []
const path = openInterceptedLink('https://docs.example.com/a', {
resolveTarget: () => 'my-plugin:page',
isAvailable: () => false,
sidebar: { openTab: seed => { opened.push(seed) } },
})
expect(path).toBe('window')
expect(opened).toEqual([])
expect(openSpy).toHaveBeenCalledWith('https://docs.example.com/a', '_blank', 'noopener,noreferrer')
})
it('falls back to a real browser tab when the plugin service is detached', () => {
const path = openInterceptedLink('https://docs.example.com/a', {
resolveTarget: () => 'my-plugin:page',
isAvailable: () => true,
sidebar: undefined,
})
expect(path).toBe('window')
expect(openSpy).toHaveBeenCalledTimes(1)
})
it('falls back to a real browser tab when nothing claims the URL', () => {
const opened: unknown[] = []
const path = openInterceptedLink('https://unclaimed.example.com/a', {
resolveTarget: () => undefined,
isAvailable: () => true,
sidebar: { openTab: seed => { opened.push(seed) } },
})
expect(path).toBe('window')
expect(opened).toEqual([])
expect(openSpy).toHaveBeenCalledWith('https://unclaimed.example.com/a', '_blank', 'noopener,noreferrer')
})
})
describe('registerLinkInterception', () => {
/** Dispatch one click on an anchor with the given absolute href. */
const clickAnchor = (href: string, init: MouseEventInit = {}): void => {
let openSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
})
afterEach(() => {
openSpy.mockRestore()
})
/** Dispatch one click on an anchor with the given absolute href; the
* returned event carries `defaultPrevented` for the caller to assert. */
const clickAnchor = (href: string, init: MouseEventInit = {}): MouseEvent => {
const anchor = document.createElement('a')
anchor.href = href
document.body.appendChild(anchor)
anchor.dispatchEvent(new MouseEvent('click', {
const event = new MouseEvent('click', {
bubbles: true,
cancelable: true,
button: 0,
...init,
}))
})
anchor.dispatchEvent(event)
anchor.remove()
return event
}
/** The index.tsx wiring (shouldTakeOverLink → openInterceptedLink) over a
* stub tab registry / tab service, so the capture registration, the gate
* and the open step are all exercised as shipped. */
const mount = (stub: {
tabs: readonly { id: string }[]
tabsEnabled?: Record<string, boolean>
claims?: (url: URL) => string | undefined
suspended?: boolean
detached?: boolean
}): { opened: Array<{ type: string; url: string; title?: string }>; dispose: () => void } => {
const opened: Array<{ type: string; url: string; title?: string }> = []
const tabsEnabled = stub.tabsEnabled ?? {}
const resolveTarget = stub.claims ?? (() => undefined)
const dispose = registerLinkInterception({
takeoverEnabled: url => shouldTakeOverLink(url, { suspended: stub.suspended ?? false, resolveTarget }),
openInSidebar: (url) => {
openInterceptedLink(url, {
resolveTarget,
isAvailable: type => isTargetAvailable(type, stub.tabs, tabsEnabled),
sidebar: stub.detached === true ? undefined : { openTab: seed => { opened.push(seed) } },
})
},
selfOrigin: SELF,
})
return { opened, dispose }
}
it('passes the parsed URL to takeoverEnabled and only takes over when it returns true', () => {
@@ -84,7 +220,7 @@ describe('registerLinkInterception', () => {
openInSidebar: (url) => { opened.push(url) },
selfOrigin: SELF,
})
// https → the gate refuses (the sidebar-takeover defaults: https off).
// https → the gate refuses (nothing claims it in this stub).
clickAnchor('https://example.com/page')
// http → the gate passes, the click is taken over with the absolute href.
clickAnchor('http://example.com/page')
@@ -93,6 +229,52 @@ describe('registerLinkInterception', () => {
dispose()
})
it('leaves an UNCLAIMED http link to the host: no preventDefault, no open, no window', () => {
const { opened, dispose } = mount({
tabs: [{ id: 'my-plugin:page' }],
claims: url => (url.hostname === 'claimed.example.com' ? 'my-plugin:page' : undefined),
})
const event = clickAnchor('http://unclaimed.example.com/readme')
expect(event.defaultPrevented).toBe(false)
expect(opened).toEqual([])
expect(openSpy).not.toHaveBeenCalled()
dispose()
})
it('takes over a CLAIMED link whose type is switched off, falling back to window.open', () => {
const { opened, dispose } = mount({
tabs: [{ id: 'my-plugin:page' }],
tabsEnabled: { 'my-plugin:page': false },
claims: () => 'my-plugin:page',
})
const event = clickAnchor('http://claimed.example.com/page')
// Taken over (so the anchor's own default cannot double-open)…
expect(event.defaultPrevented).toBe(true)
// …but the sidebar cannot serve it any more: a real browser tab does.
expect(opened).toEqual([])
expect(openSpy).toHaveBeenCalledTimes(1)
expect(openSpy).toHaveBeenCalledWith('http://claimed.example.com/page', '_blank', 'noopener,noreferrer')
dispose()
})
it('takes over a CLAIMED link whose type was unregistered, falling back to window.open', () => {
const { opened, dispose } = mount({ tabs: [], claims: () => 'my-plugin:page' })
const event = clickAnchor('http://claimed.example.com/page')
expect(event.defaultPrevented).toBe(true)
expect(opened).toEqual([])
expect(openSpy).toHaveBeenCalledWith('http://claimed.example.com/page', '_blank', 'noopener,noreferrer')
dispose()
})
it('opens a CLAIMED link with a live target through the sidebar service', () => {
const { opened, dispose } = mount({ tabs: [{ id: 'my-plugin:page' }], claims: () => 'my-plugin:page' })
const event = clickAnchor('http://claimed.example.com/page?x=1')
expect(event.defaultPrevented).toBe(true)
expect(opened).toEqual([{ type: 'my-plugin:page', url: 'http://claimed.example.com/page?x=1', title: 'claimed.example.com' }])
expect(openSpy).not.toHaveBeenCalled()
dispose()
})
it('never consults the gate for same-origin or non-http(s) links', () => {
let gateCalls = 0
const dispose = registerLinkInterception({
@@ -100,8 +282,8 @@ describe('registerLinkInterception', () => {
openInSidebar: () => { throw new Error('must not open') },
selfOrigin: SELF,
})
clickAnchor(`${SELF}/settings`)
clickAnchor('mailto:a@b.c')
expect(clickAnchor(`${SELF}/settings`).defaultPrevented).toBe(false)
expect(clickAnchor('mailto:a@b.c').defaultPrevented).toBe(false)
expect(gateCalls).toBe(0)
dispose()
})
@@ -113,9 +295,11 @@ describe('registerLinkInterception', () => {
openInSidebar: () => { throw new Error('must not open') },
selfOrigin: SELF,
})
clickAnchor('http://example.com/', { ctrlKey: true })
clickAnchor('http://example.com/', { metaKey: true })
clickAnchor('http://example.com/', { button: 1 })
expect(clickAnchor('http://example.com/', { ctrlKey: true }).defaultPrevented).toBe(false)
expect(clickAnchor('http://example.com/', { metaKey: true }).defaultPrevented).toBe(false)
expect(clickAnchor('http://example.com/', { button: 1 }).defaultPrevented).toBe(false)
expect(clickAnchor('http://example.com/', { shiftKey: true }).defaultPrevented).toBe(false)
expect(clickAnchor('http://example.com/', { altKey: true }).defaultPrevented).toBe(false)
expect(gateCalls).toBe(0)
dispose()
})
-25
View File
@@ -44,9 +44,6 @@ describe('side card preferences', () => {
titleBarStripPx: 40,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
browserInterceptLinks: true,
browserInterceptHttp: true,
browserInterceptHttps: false,
tabsEnabled: {},
viewersEnabled: {},
pluginSettings: {},
@@ -68,9 +65,6 @@ describe('side card preferences', () => {
titleBarStripPx: 40,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
browserInterceptLinks: true,
browserInterceptHttp: true,
browserInterceptHttps: false,
tabsEnabled: {},
viewersEnabled: {},
pluginSettings: {},
@@ -92,9 +86,6 @@ describe('side card preferences', () => {
titleBarStripPx: 40,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
browserInterceptLinks: true,
browserInterceptHttp: true,
browserInterceptHttps: false,
tabsEnabled: {},
viewersEnabled: {},
pluginSettings: {},
@@ -185,22 +176,6 @@ describe('side card preferences', () => {
expect((await loadPrefs(wire({ titleBarStripPx: 64 }))).titleBarStripPx).toBe(64)
})
it('defaults the link-takeover protocol flags: http on, https off, master on', async () => {
// Absent or malformed → the per-protocol defaults.
expect((await loadPrefs(wire({}))).browserInterceptLinks).toBe(true)
expect((await loadPrefs(wire({}))).browserInterceptHttp).toBe(true)
expect((await loadPrefs(wire({}))).browserInterceptHttps).toBe(false)
expect((await loadPrefs(wire({ browserInterceptHttp: 'yes' }))).browserInterceptHttp).toBe(true)
expect((await loadPrefs(wire({ browserInterceptHttps: 0 }))).browserInterceptHttps).toBe(false)
// Explicit booleans survive verbatim.
expect((await loadPrefs(wire({ browserInterceptHttp: false }))).browserInterceptHttp).toBe(false)
expect((await loadPrefs(wire({ browserInterceptHttps: true }))).browserInterceptHttps).toBe(true)
// The master is independent of the protocol flags (an explicit master
// false stays "never take over" regardless of the flags).
expect((await loadPrefs(wire({ browserInterceptLinks: false, browserInterceptHttp: true, browserInterceptHttps: true }))))
.toMatchObject({ browserInterceptLinks: false, browserInterceptHttp: true, browserInterceptHttps: true })
})
it('validates the per-tab / per-viewer enable maps (absent keys mean enabled)', async () => {
// A non-object map falls back to {} (everything enabled).
expect((await loadPrefs(wire({ tabsEnabled: 'nope' }))).tabsEnabled).toEqual({})
+82
View File
@@ -0,0 +1,82 @@
/**
* Icon-export guard: DSH 0.1.7 renamed the whole `ui-primitives` icon family
* from size-suffixed names (`IconCloseFill14`) to weight-suffixed ones
* (`IconCloseFillRegular` / `…Medium`), deleting the old names outright.
*
* Missing exports do not fail at load time here — the client bundle is CJS
* with `ui-primitives` external, so a missing named export reads as
* `undefined` and React throws "Element type is invalid" at RENDER time. That
* is a failure mode no other spec in this repository observes, so this one
* pins the contract directly: every icon symbol the plugin imports from the
* package must actually be exported by the installed version.
*
* `pnpm typecheck` catches the same thing from the type side; this stays as
* the runtime-side witness, and as a guard for the symbols that reach the
* bundle through a re-export rather than a direct import.
*/
import { describe, expect, it } from 'vitest'
import { readFileSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
import * as primitives from '@deepseek-ai/dsh-client-ui-primitives'
/** The specifier whose named exports the plugin depends on. */
const PACKAGE = '@deepseek-ai/dsh-client-ui-primitives'
/**
* Every value symbol the repository imports from the primitives package.
* Type-only imports are ignored: they are erased before the bundle is built
* and therefore cannot become a missing runtime export.
* @returns the imported symbol names, deduplicated.
*/
function importedSymbols(): string[] {
const files = execFileSync('rg', ['-l', PACKAGE, 'src', 'tests'], { encoding: 'utf8' })
.trim().split('\n').filter(Boolean)
const names = new Set<string>()
for (const file of files) {
const source = readFileSync(file, 'utf8')
for (const match of source.matchAll(
new RegExp(`import\\s+(type\\s+)?\\{([^}]*)\\}\\s+from\\s+['"]${PACKAGE}['"]`, 'g'),
)) {
// `import type { … }` imports nothing at runtime.
if (match[1] !== undefined) continue
for (const raw of match[2]!.split(',')) {
const name = raw.trim().replace(/^type\s+/, '')
if (name === '' || name.startsWith('type ')) continue
// A per-specifier `type` marker has no runtime counterpart either.
if (/^type\s/.test(raw.trim())) continue
names.add(name)
}
}
}
return [...names].sort()
}
describe('ui-primitives exports', () => {
const imported = importedSymbols()
const exported = new Set(Object.keys(primitives as Record<string, unknown>))
it('finds the plugin\'s import surface at all', () => {
// A guard on the guard: a regex that silently stopped matching would make
// the assertion below vacuously true.
expect(imported.length).toBeGreaterThan(20)
expect(imported).toContain('FileTypeIcon')
expect(imported).toContain('MarkdownText')
})
it('still exports every icon symbol the plugin imports', () => {
const missing = imported.filter(name => name.startsWith('Icon') && !exported.has(name))
expect(missing, `these symbols are imported but not exported by ${PACKAGE}: ${missing.join(', ')}`).toEqual([])
})
it('still exports every other value symbol the plugin imports', () => {
const missing = imported.filter(name => !name.startsWith('Icon') && !exported.has(name))
expect(missing, `these symbols are imported but not exported by ${PACKAGE}: ${missing.join(', ')}`).toEqual([])
})
it('ships no icon whose name still carries the retired size suffix', () => {
// The rename was wholesale: a `…14` / `…16` name reappearing would mean
// upstream re-introduced the old scheme and the migration above is stale.
const sized = [...exported].filter(name => /^Icon[A-Za-z]+(10|12|14|16|20|24)$/.test(name))
expect(sized).toEqual([])
})
})
+135 -44
View File
@@ -12,6 +12,13 @@
* the tab and puts the column back to collapsed (src/client/sidebar/
* use-host-feeds.ts `activateTasksPage`). The topology jump-back is an explicit
* user gesture and always takes the host's expansion.
*
* "The current conversation" is the native surface's MOUNTED seat
* (`ctx.sidebarRight.mounted`): the session-list snapshot never carried a
* current-session field, so the park gate used to read a phantom one and was
* permanently false. Background jobs are likewise no longer mirrored into that
* snapshot — the trigger polls the plugin's `jobs.list` route, so a job is
* delivered by mutating the stubbed registry and advancing the poll.
*/
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -30,7 +37,7 @@ import {
type SidebarSurface,
type TabComponentProps,
} from '../src/client/service.ts'
import type { Context, SidebarSessionList } from '../src/context-types.ts'
import type { Context, SidebarJobView, SidebarSessionList } from '../src/context-types.ts'
class FakeWebSocket {
onmessage: ((event: { data: unknown }) => void) | null = null
@@ -89,18 +96,41 @@ function makeNativeSurfaceSpy(): NativeSurfaceSpy {
}
}
/** `ctx.sidebarRight`, replaced by a stand-in column: `isExpanded` reports the
* current state, `toggleExpanded` flips it and counts the calls. */
interface NativeColumnSpy {
face: { isExpanded: () => boolean; toggleExpanded: () => void }
toggles: number
/** A subscribable seat observable (mirror of `ISidebarRight.mounted`). */
function makeMountedStore(initial: string | undefined) {
let snapshot = initial
const listeners = new Set<() => void>()
return {
getSnapshot: () => snapshot,
subscribe: (listener: () => void) => {
listeners.add(listener)
return () => { listeners.delete(listener) }
},
set(next: string | undefined): void {
if (next === snapshot) return
snapshot = next
for (const listener of [...listeners]) listener()
},
}
}
function makeNativeColumnSpy(expanded: boolean): NativeColumnSpy {
type MountedStore = ReturnType<typeof makeMountedStore>
/** `ctx.sidebarRight`, replaced by a stand-in column: `isExpanded` reports the
* current state, `toggleExpanded` flips it and counts the calls, and `mounted`
* is the seat observation the shell binds its per-session state to. */
interface NativeColumnSpy {
face: { isExpanded: () => boolean; toggleExpanded: () => void; mounted: MountedStore }
toggles: number
mounted: MountedStore
}
function makeNativeColumnSpy(expanded: boolean, mounted: string | undefined): NativeColumnSpy {
const spy = {
toggles: 0,
expanded,
face: {} as { isExpanded: () => boolean; toggleExpanded: () => void },
mounted: makeMountedStore(mounted),
face: {} as { isExpanded: () => boolean; toggleExpanded: () => void; mounted: MountedStore },
}
spy.face = {
isExpanded: () => spy.expanded,
@@ -108,6 +138,7 @@ function makeNativeColumnSpy(expanded: boolean): NativeColumnSpy {
spy.toggles += 1
spy.expanded = !spy.expanded
},
mounted: spy.mounted,
}
return spy
}
@@ -131,11 +162,15 @@ interface MountedSidebar {
surface: NativeSurfaceSpy
column: NativeColumnSpy
sessionId: string
/** The stubbed `jobs.list` registry, keyed by OWNER session (mutable). */
jobs: Record<string, SidebarJobView[]>
unmount: () => void
}
let sessionSeq = 0
const mounted: MountedSidebar[] = []
/** Owner sessions the stubbed `jobs.list` route was read for. */
const jobListReads: string[] = []
function setViewport(width: number): void {
Object.defineProperty(window, 'innerWidth', { configurable: true, value: width })
@@ -150,13 +185,26 @@ function mountSidebar(
vi.stubGlobal('WebSocket', FakeWebSocket)
const sessionId = `auto-activation-${++sessionSeq}`
const initial: SidebarSessionList = {
current: sessionId,
byId: {
[sessionId]: { id: sessionId, cwd: '/tmp', displayTitle: 'Root' },
},
jobsBySession: { [sessionId]: [] },
}
const feed = makeSessionFeed(initial)
const jobs: Record<string, SidebarJobView[]> = {}
// The jobs feed is polled through the plugin's own route (0.1.7 removed the
// snapshot's jobs mirror): the registry below is what a test mutates.
vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => {
const method = String(url).split('/').pop()
if (method !== 'jobs.list') throw new Error(`unexpected fetch ${String(url)}`)
const body = JSON.parse(String(init?.body)) as { sessionId?: string }
const owner = body.sessionId ?? ''
jobListReads.push(owner)
return {
ok: true,
status: 200,
json: async () => ({ ok: true, value: { jobs: jobs[owner] ?? [] } }),
} as unknown as Response
})
const store = createSidebarStore()
store.setPrefs({ ...store.getPrefs(), autoOpenSubagent: true, autoOpenJobs: true })
store.setSession(sessionId)
@@ -165,7 +213,7 @@ function mountSidebar(
const surface = makeNativeSurfaceSpy()
service.setSurface(surface.surface)
service.registerTab({ id: 'subagent', title: 'Subagent', component: JumpHarness })
const column = makeNativeColumnSpy(options.columnExpanded ?? false)
const column = makeNativeColumnSpy(options.columnExpanded ?? false, sessionId)
const localeSnapshot = { active: 'en' }
const ctx = {
locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot },
@@ -186,6 +234,7 @@ function mountSidebar(
surface,
column,
sessionId,
jobs,
unmount: () => {
act(() => { root.unmount() })
container.remove()
@@ -198,9 +247,9 @@ function mountSidebar(
type ActivitySource = 'subagent' | 'job'
/** Deliver one piece of background activity through the host's own feeds. */
function publishActivity(sidebar: MountedSidebar, source: ActivitySource): void {
async function publishActivity(sidebar: MountedSidebar, source: ActivitySource): Promise<void> {
if (source === 'job') {
publishJob(sidebar)
await publishJob(sidebar)
return
}
publishSubagent(sidebar)
@@ -209,7 +258,6 @@ function publishActivity(sidebar: MountedSidebar, source: ActivitySource): void
function publishSubagent(sidebar: MountedSidebar): void {
const before = sidebar.feed.getSnapshot()
const sessionId = before.current!
act(() => {
sidebar.feed.set({
...before,
@@ -219,7 +267,7 @@ function publishSubagent(sidebar: MountedSidebar): void {
id: 'child',
displayTitle: 'Worker',
origin: 'subagent',
parentId: sessionId,
parentId: sidebar.sessionId,
running: true,
},
},
@@ -231,45 +279,54 @@ function flushSubagentDebounce(): void {
act(() => { vi.advanceTimersByTime(500) })
}
function publishJob(sidebar: MountedSidebar): void {
const before = sidebar.feed.getSnapshot()
const sessionId = before.current!
act(() => {
sidebar.feed.set({
...before,
jobsBySession: {
...before.jobsBySession,
[sessionId]: [{
id: 'bash-1',
kind: 'bash',
label: 'sleep 30',
status: 'running',
startedAt: 1_000,
}],
},
})
})
/**
* Deliver one new job through the polled jobs route: let the mount-time
* baseline read land, register the job, then advance one poll interval.
*/
async function publishJob(sidebar: MountedSidebar): Promise<void> {
await flushFeeds()
sidebar.jobs[sessionIdOf(sidebar)] = [{
id: 'bash-1',
kind: 'bash',
label: 'sleep 30',
status: 'running',
startedAt: 1_000,
}]
await act(async () => { await vi.advanceTimersByTimeAsync(2_000) })
}
/** The conversation the sidebar is bound to (its own store's session). */
function sessionIdOf(sidebar: MountedSidebar): string {
return sidebar.store.getSnapshot().sessionId ?? sidebar.sessionId
}
/** Let pending microtasks settle (the polled reads resolve outside timers). */
async function flushFeeds(): Promise<void> {
for (let tick = 0; tick < 4; tick++) {
await act(async () => { await Promise.resolve() })
}
}
/** Switch the conversation to the child session the Tasks page jumped to. */
function switchToChild(sidebar: MountedSidebar): void {
const before = sidebar.feed.getSnapshot()
const parent = before.current!
act(() => {
sidebar.feed.set({
...before,
current: 'child',
byId: {
...before.byId,
child: {
id: 'child',
displayTitle: 'Worker',
origin: 'subagent',
parentId: parent,
parentId: sidebar.sessionId,
running: true,
},
},
})
// The mounted seat is what moves the conversations: the shell binds its
// per-session state to it (the session list has no current-session field).
sidebar.column.mounted.set('child')
})
}
@@ -312,28 +369,62 @@ describe('Sidebar background-activity auto-activation (#162)', () => {
{ source: 'job', width: 390 },
{ source: 'subagent', width: 1024 },
{ source: 'job', width: 1024 },
] as const)('$source activation at $width px activates the Tasks page in the native Sidebar', ({ source, width }) => {
] as const)('$source activation at $width px activates the Tasks page in the native Sidebar', async ({ source, width }) => {
const sidebar = mountSidebar(width)
publishActivity(sidebar, source)
await publishActivity(sidebar, source)
expectNativeTasksOpen(sidebar, sidebar.sessionId)
expectWorkbenchUntouched(sidebar)
// Parking is the narrow-viewport half of the same promise.
expect(sidebar.column.toggles).toBe(width < 768 ? 1 : 0)
})
it.each(['subagent', 'job'] as const)('%s activation leaves a narrow fullscreen column to the park', (source) => {
it.each(['subagent', 'job'] as const)('%s activation leaves a narrow fullscreen column to the park', async (source) => {
const sidebar = mountSidebar(390)
publishActivity(sidebar, source)
await publishActivity(sidebar, source)
expect(sidebar.column.toggles).toBe(1)
})
it('a narrow column the user already expanded is not closed under them', () => {
it('a narrow column the user already expanded is not closed under them', async () => {
const sidebar = mountSidebar(390, false, { columnExpanded: true })
publishActivity(sidebar, 'subagent')
await publishActivity(sidebar, 'subagent')
expectNativeTasksOpen(sidebar, sidebar.sessionId)
expect(sidebar.column.toggles).toBe(0)
})
it('a page that loads with jobs already running never triggers; the next NEW job does', async () => {
const sidebar = mountSidebar(1024)
// The conversation is already running work when the sidebar mounts: the
// first successful read only ARMS the baseline, it is never "new work".
sidebar.jobs[sidebar.sessionId] = [{
id: 'bash-0',
kind: 'bash',
label: 'already running',
status: 'running',
startedAt: 500,
}]
await flushFeeds()
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
expect(sidebar.surface.opens).toEqual([])
// A second, genuinely new job id is what surfaces the Tasks page.
sidebar.jobs[sidebar.sessionId] = [
...sidebar.jobs[sidebar.sessionId] ?? [],
{ id: 'bash-1', kind: 'bash', label: 'sleep 30', status: 'running', startedAt: 1_000 },
]
await act(async () => { await vi.advanceTimersByTimeAsync(2_000) })
expectNativeTasksOpen(sidebar, sidebar.sessionId)
})
it('a session switch re-arms the job baseline instead of firing on the new session\'s jobs', async () => {
const sidebar = mountSidebar(1024)
sidebar.jobs['child'] = [{ id: 'bash-9', kind: 'bash', label: 'child work', status: 'running', startedAt: 10 }]
switchToChild(sidebar)
await flushFeeds()
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
// The child's pre-existing job belongs to the NEW baseline, not to "new
// work in the session I am looking at" (the baseline resets on session).
expect(sidebar.surface.opens).toEqual([])
})
it('reads the viewport when the debounced activation fires, not when it arms', () => {
const sidebar = mountSidebar(1024)
publishSubagent(sidebar)
@@ -343,9 +434,9 @@ describe('Sidebar background-activity auto-activation (#162)', () => {
expect(sidebar.column.toggles).toBe(1)
})
it('leaves an already-open bottom workbench open and untouched', () => {
it('leaves an already-open bottom workbench open and untouched', async () => {
const sidebar = mountSidebar(1024, true)
publishActivity(sidebar, 'subagent')
await publishActivity(sidebar, 'subagent')
expectNativeTasksOpen(sidebar, sidebar.sessionId)
expectWorkbenchUntouched(sidebar, true)
})
+10 -2
View File
@@ -67,15 +67,23 @@ function mountSidebar(): MountedSidebar {
// services return stable objects) — a fresh object per call loops forever.
const localeSnapshot = { active: 'en' }
const sessionsSnapshot = {
current: sessionId,
// cwd present → api.sessionCwd is never called in these tests.
byId: { [sessionId]: { cwd: '/tmp' } },
}
// The conversation on screen is the native surface's mounted seat
// (`ctx.sidebarRight.mounted`); the session-list snapshot never had a
// current-session field, so the shell binds its per-session state to THIS.
const mounted = {
getSnapshot: () => sessionId,
subscribe: () => () => {},
}
const ctx = {
locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot },
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } },
betterSidebar: service,
get: (name: string) => name === 'betterSidebar' ? service : undefined,
get: (name: string) => name === 'betterSidebar'
? service
: name === 'sidebarRight' ? { mounted } : undefined,
}
const root: Root = createRoot(container)
act(() => { root.render(createElement(Sidebar, { ctx: ctx as never, store })) })
+67 -18
View File
@@ -6,6 +6,7 @@
* the preset resolution.
*/
import { describe, expect, it } from 'vitest'
import { createToolResultMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
import type { SidebarHistoryEntry, SidebarSessionEvent, SidebarSessionSummary } from '../src/context-types.ts'
import type { AssistantLiveChunk } from '../src/assistant-live.ts'
import {
@@ -20,11 +21,33 @@ import {
threadHasCompletedTurn,
threadTrailingPending,
SIDE_BOUNDARY_PROMPT,
SIDE_INJECTION_PLUGIN,
SIDE_INJECTION_SOURCE_KIND,
SIDE_LABEL_PREFIX,
type SeedEvent,
} from '../src/sidechat-core.ts'
/** One 0.1.6-era tool/result message: a user-role message whose single
* `tool-result` content block nests the result blocks and carries `isError`. */
function legacyToolResultMessage(callId: string, text: string, isError = false): Record<string, unknown> {
return {
role: 'user',
source: { kind: 'tool', callId },
content: [{ type: 'tool-result', toolCallId: callId, isError, content: [{ type: 'text', text }] }],
}
}
/** One 0.1.7 tool/result message: a first-class tool-role message whose
* `content` holds the result blocks and whose `isError` lives on the message. */
function toolRoleResultMessage(callId: string, text: string, isError = false): Record<string, unknown> {
return {
role: 'tool',
source: { kind: 'tool', callId },
toolCallId: callId,
content: [{ type: 'text', text }],
isError,
}
}
/** One log event fixture (structural, seq === index like the live contract).
* Surface-eligible events carry their required `surfaceOp: 'append'`
* marker, exactly like the live pipeline appends them. */
@@ -70,10 +93,7 @@ function completedTurn(seq: number, turn: number, over: {
events.push(ev('tool/result', next++, {
turn,
step: 1,
message: {
source: { kind: 'tool', callId: tool.callId },
content: [{ type: 'tool-result', toolCallId: tool.callId, isError: false, content: [{ type: 'text', text: tool.result ?? 'ok' }] }],
},
message: toolRoleResultMessage(tool.callId, tool.result ?? 'ok'),
}))
}
if (over.text !== undefined) {
@@ -125,10 +145,7 @@ describe('buildSidechatInheritance', () => {
ev('tool/result', 4, {
turn: 1,
step: 1,
message: {
source: { kind: 'tool', callId: 'c1' },
content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'ok' }] }],
},
message: toolRoleResultMessage('c1', 'ok'),
}),
ev('assistant/message', 5, { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'a' }] } }),
ev('step/end', 6, { turn: 1, step: 1 }),
@@ -181,10 +198,7 @@ describe('buildSidechatInheritance', () => {
ev('tool/result', 4, {
turn: 1,
step: 1,
message: {
source: { kind: 'tool', callId: 'c1' },
content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'hit' }] }],
},
message: toolRoleResultMessage('c1', 'hit'),
}),
ev('assistant/message', 5, { turn: 1, step: 1, message: { content: [{ type: 'text', text: 'found it' }] }, stream: [] }),
]
@@ -243,13 +257,12 @@ describe('buildOpenTurnSnapshot', () => {
ev('turn/start', 0, { turn: 1 }),
ev('step/start', 1, { turn: 1, step: 1 }),
ev('tool/call', 5, { turn: 1, step: 1, callId: 'c1', name: 'read', arguments: '{"path":"a.txt"}' }),
// The shape 0.1.7's createToolResultMessage writes: a first-class
// tool-role message with the result blocks at the top level.
ev('tool/result', 6, {
turn: 1,
step: 1,
message: {
source: { kind: 'tool', callId: 'c1' },
content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'file body' }] }],
},
message: toolRoleResultMessage('c1', 'file body'),
}),
ev('tool/call', 7, { turn: 1, step: 1, callId: 'c2', name: 'bash', arguments: '{"cmd":"long"}' }),
]
@@ -265,6 +278,42 @@ describe('buildOpenTurnSnapshot', () => {
expect(snapshot).toContain('Result: file body')
expect(snapshot).toContain('`bash` (executing)')
})
it('reads the tool/result message dsh-llm actually produces (producer round-trip)', () => {
// createToolResultMessage is the producer of every live tool/result row:
// the fixture shapes above must match what it emits, not a guess.
const message = createToolResultMessage({
callId: ToolCallId('c1'),
content: [{ type: 'text', text: 'produced body' }],
isError: false,
})
const events = [
ev('turn/start', 0, { turn: 1 }),
ev('step/start', 1, { turn: 1, step: 1 }),
ev('tool/call', 2, { turn: 1, step: 1, callId: 'c1', name: 'read', arguments: '{"path":"a"}' }),
ev('tool/result', 3, { turn: 1, step: 1, message: message as unknown as Record<string, unknown> }),
]
const snapshot = buildOpenTurnSnapshot(events)
expect(snapshot).not.toBeNull()
expect(snapshot).toContain('Result: produced body')
})
it('reads a LEGACY 0.1.6-wrapped tool result (historical logs keep that shape)', () => {
const events = [
ev('turn/start', 0, { turn: 1 }),
ev('step/start', 1, { turn: 1, step: 1 }),
ev('tool/call', 5, { turn: 1, step: 1, callId: 'c1', name: 'read', arguments: '{"path":"old.txt"}' }),
ev('tool/result', 6, {
turn: 1,
step: 1,
message: legacyToolResultMessage('c1', 'legacy body'),
}),
ev('tool/call', 7, { turn: 1, step: 1, callId: 'c2', name: 'bash', arguments: '{"cmd":"long"}' }),
]
const snapshot = buildOpenTurnSnapshot(events)
expect(snapshot).not.toBeNull()
expect(snapshot).toContain('Result: legacy body')
})
})
describe('sideLabel', () => {
@@ -360,7 +409,7 @@ describe('isContextInjectionMessage', () => {
it('recognizes plugin-stamped sources structurally', () => {
expect(isContextInjectionMessage({
content: [{ type: 'text', text: 'runtime context' }],
source: { kind: 'plugin', plugin: SIDE_INJECTION_PLUGIN },
source: { kind: SIDE_INJECTION_SOURCE_KIND },
})).toBe(true)
expect(isContextInjectionMessage({
content: [{ type: 'text', text: 'q' }],
+8 -5
View File
@@ -11,7 +11,7 @@ import { describe, expect, it, vi } from 'vitest'
import { SUBAGENT_DESCRIPTOR_VERSION } from '@deepseek-ai/dsh-subagent'
import { buildSidechatApi } from '../src/sidechat-routes.ts'
import { SidebarError } from '../src/wire.ts'
import { SIDE_BOUNDARY_PROMPT, SIDE_INJECTION_PLUGIN, SIDE_NEW_THREAD_TITLE, sideLabel } from '../src/sidechat-core.ts'
import { SIDE_BOUNDARY_PROMPT, SIDE_INJECTION_SOURCE_KIND, SIDE_NEW_THREAD_TITLE, sideLabel } from '../src/sidechat-core.ts'
import type { Context } from '../src/context-types.ts'
/** A fake live agent (inject/followup/cancel spied). */
@@ -163,9 +163,12 @@ describe('sidechat.start', () => {
})
// First contact is SPLIT: the boundary rides agent.inject (plugin-stamped
// context, no wake), the question is the follow-up that wakes the driver.
// The stamp is the plugin's producer-owned kind: session format v4
// refuses the retired bare `kind: 'plugin'` at log read-back.
expect(child.inject).toHaveBeenCalledTimes(1)
const injection = child.inject.mock.calls[0]![0] as { content: Array<{ type: string; text: string }>; source: { kind: string; plugin: string } }
expect(injection.source).toEqual({ kind: 'plugin', plugin: SIDE_INJECTION_PLUGIN })
const injection = child.inject.mock.calls[0]![0] as { content: Array<{ type: string; text: string }>; source: { kind: string } }
expect(injection.source).toEqual({ kind: SIDE_INJECTION_SOURCE_KIND })
expect(injection.source).toEqual({ kind: 'plugin:dsh-better-sidebar' })
expect(injection.content[0]!.text.startsWith(SIDE_BOUNDARY_PROMPT)).toBe(true)
expect(injection.content[0]!.text).not.toContain('explain the event flow')
expect(child.followup).toHaveBeenCalledTimes(1)
@@ -259,7 +262,7 @@ describe('sidechat.start', () => {
await api['sidechat.prompt']({ childId, text: 'explain the event flow' })
expect(child.inject).toHaveBeenCalledTimes(1)
const injection = child.inject.mock.calls[0]![0] as { content: Array<{ text: string }>; source: { kind: string } }
expect(injection.source.kind).toBe('plugin')
expect(injection.source.kind).toBe(SIDE_INJECTION_SOURCE_KIND)
expect(injection.content[0]!.text.startsWith(SIDE_BOUNDARY_PROMPT)).toBe(true)
expect(injection.content[0]!.text).toContain('`bash` (executing)')
expect(child.followup).toHaveBeenCalledTimes(1)
@@ -399,7 +402,7 @@ function threadLog(): Array<ReturnType<typeof ev>> {
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', 4, { content: [{ type: 'text', text: 'Side conversation boundary.' }], source: { kind: SIDE_INJECTION_SOURCE_KIND } }),
ev('user/message', 5, { content: [{ type: 'text', text: 'the side question' }], source: { kind: 'user' } }),
ev('assistant/attempt', 6, { turn: 1, step: 1, stream: [] }),
ev('assistant/attempt', 7, { turn: 1, step: 1, stream: [] }),
+62 -10
View File
@@ -19,7 +19,8 @@ import { buildSidechatInheritance } from '../src/sidechat-core.ts'
/** One live-style event with the surface marker message events carry, and
* the REAL message shapes the validator demands (id/role/source/content;
* tool/result messages carry role 'user' + one tool-result block). */
* a tool/result carries the first-class role-'tool' message 0.1.7 writes —
* v4 refuses the retired role-'user' + `tool-result` wrapper). */
function ev(type: string, seq: number, data: Record<string, unknown>): SidebarSessionEvent {
const event: SidebarSessionEvent = { type, seq, time: seq * 1000, data }
if (type === 'user/message' || type === 'assistant/message' || type === 'tool/result') {
@@ -41,6 +42,31 @@ function assistantMessage(text: string): Record<string, unknown> {
}
}
/** The 0.1.7 tool/result message: first-class tool role, result blocks and
* `isError` at the message's own top level (dsh-llm's createToolResultMessage). */
function toolResultMessage(callId: string, text: string, isError = false): Record<string, unknown> {
return {
id: `m-${callId}`,
role: 'tool',
source: { kind: 'tool', callId },
toolCallId: callId,
content: [{ type: 'text', text }],
isError,
}
}
/** The retired 0.1.6 shape: a role-'user' message wrapping the result in one
* `tool-result` content block. Historical logs still carry it, but the v4
* seed validator refuses it — the plugin's own readers must still read it. */
function legacyToolResultMessage(callId: string, text: string, isError = false): Record<string, unknown> {
return {
id: `m-${callId}`,
role: 'user',
source: { kind: 'tool', callId },
content: [{ type: 'tool-result', toolCallId: callId, isError, content: [{ type: 'text', text }] }],
}
}
/** 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).
* DSH 0.1.5 logs nothing for the in-flight step: its deltas are
@@ -90,6 +116,40 @@ describe('sidechat seed against the real dsh-session validator', () => {
expect(child.snapshotEvents().map(event => event.type).at(-1)).toBe('session/end-seed')
})
it('refuses the retired 0.1.6 tool-result wrapper (why every fixture uses the tool-role message)', () => {
const legacy = [
ev('user/message', 0, userMessage('q')),
ev('turn/start', 1, { turn: 1 }),
ev('step/start', 2, { turn: 1, step: 1 }),
ev('tool/call', 3, { turn: 1, step: 1, callId: 'c1', name: 'bash', arguments: '{}' }),
ev('tool/result', 4, { turn: 1, step: 1, message: legacyToolResultMessage('c1', 'ok') }),
ev('turn/end', 5, { turn: 1, reason: { kind: 'completed' } }),
]
// Session format v4 made tool results first-class role-'tool' messages;
// a seed still carrying the wrapper cannot be adopted at all.
expect(() => Session.create('session-validator-legacy' as SessionId, legacy as never))
.toThrow(/message must have role "tool"/)
})
it('still READS a legacy 0.1.6-shaped tool result when building the snapshot (compat)', () => {
// Historical logs keep the wrapper shape forever, so the plugin's own
// readers must keep reading it even though the validator refuses it.
const log = [
ev('user/message', 0, userMessage('q')),
ev('turn/start', 1, { turn: 1 }),
ev('step/start', 2, { turn: 1, step: 1 }),
ev('tool/call', 3, { turn: 1, step: 1, callId: 'c1', name: 'bash', arguments: '{"cmd":"ls"}' }),
ev('tool/result', 4, { turn: 1, step: 1, message: legacyToolResultMessage('c1', 'legacy ok') }),
// A second call with no result yet: the open turn cannot be closed
// honestly, so the inheritance falls back to the snapshot — where the
// legacy-shaped result above must still surface.
ev('tool/call', 5, { turn: 1, step: 1, callId: 'c2', name: 'bash', arguments: '{"cmd":"sleep"}' }),
]
const { snapshot } = buildSidechatInheritance(log)
expect(snapshot).not.toBeNull()
expect(snapshot).toContain('Result: legacy ok')
})
it('accepts the durable subagent descriptor the routes append to the seed', () => {
// Regression guard for the catalog-corrupt fix: a cold thread WITHOUT a
// descriptor renders as a 'corrupt' diagnostic in the host subagents.list.
@@ -181,15 +241,7 @@ describe('sidechat seed fork markers vs the reconstructed inbox', () => {
ev('step/start', 11, { turn: 2, step: 1 }),
ev('user/message', 12, pendingMessage('m-q2', 'user', 'q2')),
ev('tool/call', 13, { turn: 2, step: 1, callId: 'c1', name: 'bash', arguments: '{}' }),
ev('tool/result', 14, {
turn: 2, step: 1,
message: {
id: 'm-r',
role: 'user',
content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'ok' }] }],
source: { kind: 'tool', callId: 'c1' },
},
}),
ev('tool/result', 14, { turn: 2, step: 1, message: toolResultMessage('c1', 'ok') }),
ev('agent/inbox/spliced', 15, {
target: 'next-step',
start: 0,
+66 -15
View File
@@ -6,8 +6,9 @@
* pairing, and orphan failed results.
*/
import { describe, expect, it } from 'vitest'
import { createToolResultMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
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 { SIDE_BOUNDARY_PREFIX, SIDE_BOUNDARY_PROMPT, SIDE_INJECTION_SOURCE_KIND } from '../src/sidechat-core.ts'
import {
formatDurationMs,
formatTokens,
@@ -35,12 +36,32 @@ function textBlocks(...texts: string[]): unknown[] {
return texts.map(text => ({ type: 'text', text }))
}
/** One 0.1.6-era tool/result message (user role, nested `tool-result` wrapper). */
function legacyResultMessage(callId: string, text: string, isError = false): Record<string, unknown> {
return {
role: 'user',
source: { kind: 'tool', callId },
content: [{ type: 'tool-result', toolCallId: callId, isError, content: textBlocks(text) }],
}
}
/** One 0.1.7 tool/result message (tool role, blocks + `isError` on the message). */
function toolRoleResultMessage(callId: string, text: string, isError = false): Record<string, unknown> {
return {
role: 'tool',
source: { kind: 'tool', callId },
toolCallId: callId,
content: textBlocks(text),
isError,
}
}
describe('transcriptRows', () => {
it('cuts the inherited seed at the last end-seed and renders the boundary as an injection row', () => {
const entries = [
entry(ev('user/message', 0, { content: textBlocks('inherited'), source: { kind: 'user' } })),
entry(ev('session/end-seed', 1)),
entry(ev('user/message', 2, { content: textBlocks(`${SIDE_BOUNDARY_PREFIX}\n\nmode`), source: { kind: 'plugin', plugin: SIDE_INJECTION_PLUGIN } })),
entry(ev('user/message', 2, { content: textBlocks(`${SIDE_BOUNDARY_PREFIX}\n\nmode`), source: { kind: SIDE_INJECTION_SOURCE_KIND } })),
entry(ev('user/message', 3, { content: textBlocks('the side question'), source: { kind: 'user' } })),
]
const rows = transcriptRows(entries)
@@ -70,7 +91,7 @@ describe('transcriptRows', () => {
it('renders any plugin-sourced context message as an injection row, boundary prefix or not', () => {
const entries = [
entry(ev('session/end-seed', 0)),
entry(ev('user/message', 1, { content: textBlocks('runtime context'), source: { kind: 'plugin', plugin: 'other-plugin' } })),
entry(ev('user/message', 1, { content: textBlocks('runtime context'), source: { kind: 'plugin:other-plugin' } })),
entry(ev('user/message', 2, { content: textBlocks('q'), source: { kind: 'user' } })),
]
const rows = transcriptRows(entries)
@@ -119,13 +140,12 @@ describe('transcriptRows', () => {
entry(ev('turn/start', 1, { turn: 1 })),
entry(ev('step/start', 2, { turn: 1, step: 1 })),
entry(ev('tool/call', 3, { turn: 1, step: 1, callId: 'c1', name: 'read', arguments: '{"path":"a"}' })),
// The shape 0.1.7 writes: tool role, result blocks and `isError` at the
// message's own top level (no `tool-result` wrapper).
entry(ev('tool/result', 4, {
turn: 1,
step: 1,
message: {
source: { kind: 'tool', callId: 'c1' },
content: [{ type: 'tool-result', toolCallId: 'c1', isError: true, content: [{ type: 'text', text: 'denied' }] }],
},
message: toolRoleResultMessage('c1', 'denied', true),
error: { name: 'EACCES', code: 'EACCES' },
})),
]
@@ -142,6 +162,42 @@ describe('transcriptRows', () => {
})
})
it('reads the tool/result message dsh-llm actually produces (producer round-trip)', () => {
const message = createToolResultMessage({
callId: ToolCallId('c1'),
// A literal block array: the producer takes real ContentBlocks, not the
// loose `unknown[]` the log fixtures above are written with.
content: [{ type: 'text', text: 'produced output' }],
isError: false,
})
const entries = [
entry(ev('session/end-seed', 0)),
entry(ev('tool/call', 1, { callId: 'c1', name: 'bash', arguments: '{"command":"ls"}' })),
entry(ev('tool/result', 2, { message: message as unknown as Record<string, unknown> })),
]
const rows = transcriptRows(entries)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ kind: 'tool', name: 'bash', resultText: 'produced output', executing: false })
})
it('reads a LEGACY 0.1.6-era tool result wrapper (historical logs keep that shape)', () => {
const entries = [
entry(ev('session/end-seed', 0)),
entry(ev('turn/start', 1, { turn: 1 })),
entry(ev('step/start', 2, { turn: 1, step: 1 })),
entry(ev('tool/call', 3, { turn: 1, step: 1, callId: 'c1', name: 'read', arguments: '{"path":"a"}' })),
entry(ev('tool/result', 4, {
turn: 1,
step: 1,
message: legacyResultMessage('c1', 'legacy denied', true),
error: { name: 'EACCES', code: 'EACCES' },
})),
]
const rows = transcriptRows(entries)
expect(rows).toHaveLength(1)
expect(rows[0]).toMatchObject({ kind: 'tool', name: 'read', resultText: 'legacy denied', failed: true })
})
it('keeps a call executing until its result lands and surfaces orphan failures', () => {
const entries = [
entry(ev('session/end-seed', 0)),
@@ -158,10 +214,7 @@ describe('transcriptRows', () => {
entry(ev('tool/result', 1, {
turn: 1,
step: 1,
message: {
source: { kind: 'tool', callId: 'gone' },
content: [{ type: 'tool-result', toolCallId: 'gone', isError: true, content: [{ type: 'text', text: 'boom' }] }],
},
message: toolRoleResultMessage('gone', 'boom', true),
error: { name: 'X', code: 'X' },
})),
]
@@ -216,10 +269,8 @@ describe('tool cards', () => {
entry(ev('tool/result', seq, {
turn: 1,
step: 1,
message: {
source: { kind: 'tool', callId },
content: [{ type: 'tool-result', toolCallId: callId, content: [{ type: 'text', text }] }],
},
// The 0.1.7 first-class tool-role message shape.
message: toolRoleResultMessage(callId, text),
...extra,
}))
+12 -3
View File
@@ -142,13 +142,22 @@ function viewProps(ctx: Context) {
/** The sessions-list snapshot with one idle side thread bound to 'root'. */
function threadStore(): ReturnType<typeof makeStore> {
return makeStore({
current: 'root',
byId: {
root: { id: 'root', displayTitle: '主会话', running: false },
t1: { id: 't1', displayTitle: 'Side: 线程一', origin: 'subagent', parentId: 'root', running: false },
},
subagentsByParent: {},
jobsBySession: {},
// The side thread's parent catalog (DSH 0.1.7 projection shape); the view
// below reads the summary row, not the catalog, but the fixture is the
// real snapshot shape so a future reader cannot copy a phantom field.
projectionsBySession: {
root: {
values: {
subagentCatalog: [{ id: 't1', createdAt: 1_000, mode: 'continuable', label: 'Side: 线程一' }],
},
state: 'ready',
error: null,
},
},
})
}
+233 -150
View File
@@ -10,6 +10,7 @@ import { tmpdir } from 'node:os'
import { join, resolve as resolvePath } from 'node:path'
import { SettingsConflictError, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
import { apply, mediaTypeForPath } from '../src/index.ts'
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
import { encodeHtmlUrl } from '../src/html-route.ts'
import * as git from '../src/git.ts'
import { listDirectory } from '../src/fs-tree.ts'
@@ -86,7 +87,7 @@ describe('host plugin smoke', () => {
'/sidebar/file',
'/sidebar/html',
])
expect(upgrades.map(route => route.path)).toEqual(['/sidebar/ws/agent-opens'])
expect(upgrades.map(route => route.path)).toEqual(['/sidebar/ws/agent-opens', '/sidebar/ws/fs-watch'])
// Teardown runs without throwing.
for (const cleanup of effects) cleanup()
})
@@ -625,95 +626,116 @@ describe('session cwd resolution over the API route', () => {
}
})
})
/**
* Invoke one `/sidebar/api/<method>` route against a fake Socket-ish pair.
* @param route - the mounted prefix route.
* @param method - the API method name.
* @param payload - the JSON request body.
* @returns the parsed envelope.
*/
const invoke = async (route: SidebarWebRoute, method: string, payload: unknown): Promise<{
ok: boolean
value?: unknown
error?: { code?: string; message: string }
}> => {
const body = Buffer.from(JSON.stringify(payload))
const req = {
method: 'POST',
url: `/sidebar/api/${method}`,
headers: { host: '127.0.0.1:3080' },
[Symbol.asyncIterator]: async function* () { yield body },
} as never
const out: { status: number; body: string } = { status: 200, body: '' }
const res = {
writeHead: (status: number) => { out.status = status },
end: (chunk: unknown) => { out.body += String(chunk ?? '') },
} as never
await route.handler(req, res)
return JSON.parse(out.body) as { ok: boolean; value?: unknown; error?: { code?: string; message: string } }
}
/** The Loader entry id this plugin's row is mounted under in these tests. */
const ENTRY_ID = 'better-sidebar'
/** The fiber that row owns; the plugin matches it by identity. */
const PLUGIN_FIBER = { name: 'dsh-better-sidebar' }
/**
* A minimal settings FORMS seam: `describe`/`update`/`configure` over one
* entry, with the revision guard. DSH 0.1.7 replaced the registrable namespace
* with exactly this shape, so the plugin no longer owns the schema — it
* reports what `describe` gives it and writes through `update`.
* @param pre - user-layer values staged per entry id before the plugin mounts.
*/
const createFakeSettings = (pre?: Record<string, Record<string, unknown>>) => {
// The plugin's own row always exists in a real profile — it IS the row that
// mounted the plugin — so its form is addressable before anything is written.
const rows = new Map<string, { value: Record<string, unknown>; revision: number }>([
[ENTRY_ID, { value: {}, revision: 0 }],
])
for (const [ns, value] of Object.entries(pre ?? {})) rows.set(ns, { value, revision: 0 })
return {
describe(options?: { redactSecrets?: boolean }) {
return [...rows.entries()].map(([ns, row]) => ({
ns,
value: { ...SIDEBAR_PREFS_DEFAULTS, ...row.value },
revision: row.revision,
...(options?.redactSecrets === true ? {} : { user: row.value }),
}))
},
async update(ns: string, patch: Record<string, unknown>, expectedRevision?: number) {
const row = rows.get(ns)
if (row === undefined) throw new Error(`No configurable plugin entry "${ns}"`)
if (expectedRevision !== undefined && expectedRevision !== row.revision) {
throw new SettingsConflictError(ns as SettingsNamespace, expectedRevision, row.revision)
}
row.value = { ...row.value, ...patch }
row.revision += 1
},
configure() {
return () => {}
},
}
}
/**
* Mount the host plugin against a fake context carrying a settings seam.
* @param settings - the settings forms fake; omit to simulate a deployment with none.
* @param home - a harness home holding a retired `settings.yaml`, when the test wants the legacy import to run.
* @returns the mounted `/sidebar/api` route.
*/
const mountWithSettings = (settings?: unknown, home?: string): SidebarWebRoute => {
const routes: SidebarWebRoute[] = []
const ctx = {
webRuntime: { trustedHosts: [] },
webServer: {
register: (route: SidebarWebRoute) => { routes.push(route); return () => {} },
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
},
sessions: { get: () => undefined },
tools: { register: () => () => {} },
effect: (fn: () => void | (() => void)) => { fn() },
inject: (deps: string[], callback: (sctx: { settings: unknown }) => void) => {
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,
fiber: PLUGIN_FIBER,
// The plugin discovers its own settings entry id from the loader, so a
// fake without these entries has NO settings face at all.
loader: {
entries: () => [{ options: { id: ENTRY_ID, name: 'dsh-better-sidebar' }, fiber: PLUGIN_FIBER }],
},
logger: { info: () => {}, warn: () => {}, debug: () => {}, error: () => {} },
...(home === undefined ? {} : { profileContext: { home } }),
}
apply(ctx as never)
return routes.find(route => route.path === '/sidebar/api')!
}
describe('side card settings routes', () => {
/** A minimal settings seam: register/describe/update with the revision guard. */
const createFakeSettings = (pre?: Record<string, Record<string, unknown>>) => {
const namespaces = new Map<string, {
schema: unknown
value: Record<string, unknown> | undefined
revision: number
}>()
for (const [ns, value] of Object.entries(pre ?? {})) {
namespaces.set(ns, { schema: (input: unknown) => input, value, revision: 0 })
}
const resolve = (entry: { schema: unknown; value: Record<string, unknown> | undefined }): unknown => {
const schema = entry.schema as (input: unknown) => unknown
return entry.value === undefined ? schema(undefined) : schema(entry.value)
}
return {
register(ns: string, schema: unknown) {
// Preserve a pre-seeded value: tests stage prefs through the `pre`
// map before the plugin mounts and registers the same namespace.
const existing = namespaces.get(ns)
namespaces.set(ns, { schema, value: existing?.value ?? undefined, revision: 0 })
return { get: () => ({}), watch: () => () => {}, update: async () => {}, replace: async () => {} }
},
describe() {
return [...namespaces.entries()].map(([ns, entry]) => ({
ns,
value: resolve(entry),
applies: 'live' as const,
revision: entry.revision,
}))
},
async update(ns: string, patch: Record<string, unknown>, expectedRevision?: number) {
const entry = namespaces.get(ns)
if (entry === undefined) throw new Error(`settings namespace "${ns}" is not registered`)
if (expectedRevision !== undefined && expectedRevision !== entry.revision) {
throw new SettingsConflictError(ns as SettingsNamespace, expectedRevision, entry.revision)
}
entry.value = { ...entry.value, ...patch }
entry.revision += 1
},
}
}
const mountWithSettings = (settings?: unknown): SidebarWebRoute => {
const routes: SidebarWebRoute[] = []
const ctx = {
webRuntime: { trustedHosts: [] },
webServer: {
register: (route: SidebarWebRoute) => { routes.push(route); return () => {} },
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
},
sessions: { get: () => undefined },
tools: { register: () => () => {} },
effect: (fn: () => void | (() => void)) => { fn() },
inject: (deps: string[], callback: (sctx: { settings: unknown }) => void) => {
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,
}
apply(ctx as never)
return routes.find(route => route.path === '/sidebar/api')!
}
const invoke = async (route: SidebarWebRoute, method: string, payload: unknown): Promise<{
ok: boolean
value?: unknown
error?: { code?: string; message: string }
}> => {
const body = Buffer.from(JSON.stringify(payload))
const req = {
method: 'POST',
url: `/sidebar/api/${method}`,
headers: { host: '127.0.0.1:3080' },
[Symbol.asyncIterator]: async function* () { yield body },
} as never
const out: { status: number; body: string } = { status: 200, body: '' }
const res = {
writeHead: (status: number) => { out.status = status },
end: (chunk: unknown) => { out.body += String(chunk ?? '') },
} as never
await route.handler(req, res)
return JSON.parse(out.body) as { ok: boolean; value?: unknown; error?: { code?: string; message: string } }
}
it('serves the schema defaults when the settings service is absent', async () => {
const route = mountWithSettings(undefined)
const result = await invoke(route, 'settings.get', {})
@@ -721,7 +743,7 @@ describe('side card settings routes', () => {
expect(result.value).toEqual({ value: undefined, revision: undefined, externalDisable: false })
})
it('reports externalDisable false when the aionui namespace is absent', async () => {
it('reports externalDisable false when the aionui entry is absent', async () => {
const route = mountWithSettings(createFakeSettings())
const result = await invoke(route, 'settings.get', {})
expect(result.ok).toBe(true)
@@ -735,44 +757,90 @@ describe('side card settings routes', () => {
expect((result.value as { externalDisable?: boolean }).externalDisable).toBe(true)
})
it('reads the resolved prefs and writes a patch through the seam', async () => {
it('passes the entry form through and writes a patch back into it', async () => {
const route = mountWithSettings(createFakeSettings())
const read = await invoke(route, 'settings.get', {})
expect(read.ok).toBe(true)
expect(read.value).toEqual({
value: {
autoOpenSubagent: true,
autoOpenJobs: true,
agentOpenTools: false,
editorExplorer: false,
workspaceFence: true,
// The route serves the SCHEMA-resolved document, and these three
// title-bar fields are declared without a schema default on purpose
// (the client's parsePrefs supplies `auto` / '' instead, which is
// what lets a document predating them migrate rather than flip).
titleBarCompat: false,
titleBarStripPx: 40,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
browserInterceptLinks: true,
browserInterceptHttp: true,
browserInterceptHttps: false,
// The enable-switch maps default to {} (everything on).
tabsEnabled: {},
viewersEnabled: {},
// The plugin-owned settings map defaults to {} too.
pluginSettings: {},
},
revision: 0,
externalDisable: false,
const view = read.value as { value: Record<string, unknown>; revision: number }
expect(view.revision).toBe(0)
// The form belongs to the ENTRY, so what the route reports is exactly the
// field set this plugin's own preference contract declares.
expect(Object.keys(view.value).sort()).toEqual(Object.keys(SIDEBAR_PREFS_DEFAULTS).sort())
expect(view.value).toMatchObject({
autoOpenSubagent: true,
autoOpenJobs: true,
agentOpenTools: false,
editorExplorer: false,
workspaceFence: true,
// These title-bar fields are declared without a schema default on
// purpose, so a document predating them migrates rather than flips.
titleBarCompat: false,
titleBarStripPx: 40,
htmlViewerNoSandbox: false,
htmlViewerDefaultUnsafe: false,
// The enable-switch maps default to {} (everything on).
tabsEnabled: {},
viewersEnabled: {},
// The plugin-owned settings map defaults to {} too.
pluginSettings: {},
})
const written = await invoke(route, 'settings.update', { patch: { agentOpenTools: true } })
expect(written.ok).toBe(true)
const view = written.value as { value: { agentOpenTools: boolean; titleBarStripPx: number }; revision: number }
expect(view.value.agentOpenTools).toBe(true)
expect(view.value.titleBarStripPx).toBe(40)
expect(view.revision).toBe(1)
const after = written.value as { value: { agentOpenTools: boolean; titleBarStripPx: number }; revision: number }
expect(after.value.agentOpenTools).toBe(true)
expect(after.value.titleBarStripPx).toBe(40)
expect(after.revision).toBe(1)
})
it('imports a pre-0.1.7 settings.yaml section once, keeping only declared fields', async () => {
const home = mkdtempSync(join(tmpdir(), 'dsh-sidebar-legacy-prefs-'))
// DSH renames the retired document to `.imported` before importing any
// section, and it re-imports by SAME id — this section is keyed by the
// package name, which never matches the row id, so it is left behind. The
// unknown fields are the ones this release no longer declares; forwarding
// them would reject the whole patch and lose the preferences.
writeFileSync(join(home, 'settings.yaml.imported'), [
'dsh-better-sidebar:',
' agentOpenTools: true',
' titleBarStripPx: 22',
' terminalFontSize: 13',
' browserInterceptHttp: false',
'other-plugin:',
' irrelevant: true',
'',
].join('\n'))
try {
const settings = createFakeSettings()
mountWithSettings(settings, home)
// The import is fire-and-forget so a file read can never block loading;
// let its read and microtasks settle before asserting.
await new Promise(resolve => setTimeout(resolve, 100))
const row = settings.describe().find(candidate => candidate.ns === ENTRY_ID)
expect(row?.value).toMatchObject({ agentOpenTools: true, titleBarStripPx: 22 })
expect(row?.value).not.toHaveProperty('terminalFontSize')
expect(row?.value).not.toHaveProperty('browserInterceptHttp')
expect(row?.revision).toBe(1)
} finally {
rmSync(home, { recursive: true, force: true })
}
})
it('leaves a row that already has user values alone', async () => {
const home = mkdtempSync(join(tmpdir(), 'dsh-sidebar-legacy-skip-'))
writeFileSync(join(home, 'settings.yaml'), 'dsh-better-sidebar:\n agentOpenTools: true\n')
try {
const settings = createFakeSettings({ [ENTRY_ID]: { editorExplorer: true } })
mountWithSettings(settings, home)
await new Promise(resolve => setTimeout(resolve, 100))
const row = settings.describe().find(candidate => candidate.ns === ENTRY_ID)
// The row's own user layer wins: the import must never overwrite a value
// set after the upgrade.
expect(row?.value).toMatchObject({ editorExplorer: true, agentOpenTools: false })
expect(row?.revision).toBe(0)
} finally {
rmSync(home, { recursive: true, force: true })
}
})
it('disarms the workspace fence for the fs routes when the pref is off', async () => {
@@ -825,28 +893,29 @@ describe('side card settings routes', () => {
})
describe('agent sidebar-open tool gating', () => {
it('injects the one open tool only when the side-card setting is enabled (default off)', () => {
/**
* DSH 0.1.7 emits its settings change on the settings service's own context,
* which is not an ancestor of this plugin's fiber, so the gate cannot watch
* an event. It is re-evaluated whenever the client re-reads the form instead
* — which is exactly what the fenced `settings.get` route below does, and
* what the Side card page does on every `settings/document-updated` push.
*/
const gatingCtx = (enabled: () => boolean, routes: SidebarWebRoute[]) => {
let registered = 0
let disposed = 0
const live = (): number => registered - disposed
const watcherRef: { current: (() => void) | null } = { current: null }
let enabled = false
const settings = {
register() {
return {
get: () => ({ agentOpenTools: enabled, tabsEnabled: {} }),
watch: (callback: () => void) => { watcherRef.current = callback; return () => {} },
update: async () => {},
replace: async () => {},
}
},
describe: () => [],
describe: () => [{
ns: ENTRY_ID,
value: { ...SIDEBAR_PREFS_DEFAULTS, agentOpenTools: enabled() },
revision: 0,
}],
async update() {},
configure: () => () => {},
}
const ctx = {
webRuntime: { trustedHosts: [] },
webServer: {
register: (route: SidebarWebRoute) => { void route; return () => {} },
register: (route: SidebarWebRoute) => { routes.push(route); return () => {} },
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
},
sessions: { get: () => undefined },
@@ -856,27 +925,41 @@ 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,
fiber: PLUGIN_FIBER,
loader: {
entries: () => [{ options: { id: ENTRY_ID, name: 'dsh-better-sidebar' }, fiber: PLUGIN_FIBER }],
},
logger: { info: () => {}, warn: () => {}, debug: () => {}, error: () => {} },
}
apply(ctx as never)
// Default off: no open tool is registered even though the settings service is mounted.
expect(live()).toBe(0)
// Flipping the setting on registers the single sidebar_open tool.
return { ctx, live: () => registered - disposed, registrations: () => registered, disposals: () => disposed }
}
it('injects the one open tool only when the side-card setting is enabled (default off)', async () => {
let enabled = false
const routes: SidebarWebRoute[] = []
const harness = gatingCtx(() => enabled, routes)
apply(harness.ctx as never)
const route = routes.find(candidate => candidate.path === '/sidebar/api')!
// Default off: no open tool is registered even though settings is mounted.
expect(harness.live()).toBe(0)
// Switching the setting on and re-reading the form registers the one tool.
enabled = true
watcherRef.current?.()
expect(live()).toBe(1)
expect(disposed).toBe(0)
// Flipping it back off unregisters it (and drains the undelivered queue).
await invoke(route, 'settings.get', {})
expect(harness.live()).toBe(1)
expect(harness.disposals()).toBe(0)
// Switching it back off unregisters it (and drains the undelivered queue).
enabled = false
watcherRef.current?.()
expect(live()).toBe(0)
expect(disposed).toBe(1)
// And a redundant toggle registers it fresh (no double-registration).
await invoke(route, 'settings.get', {})
expect(harness.live()).toBe(0)
expect(harness.disposals()).toBe(1)
// A redundant re-read registers it fresh (no double-registration).
enabled = true
watcherRef.current?.()
expect(live()).toBe(1)
expect(registered).toBe(2)
await invoke(route, 'settings.get', {})
expect(harness.live()).toBe(1)
await invoke(route, 'settings.get', {})
expect(harness.live()).toBe(1)
expect(harness.registrations()).toBe(2)
})
})
+52 -23
View File
@@ -1,9 +1,10 @@
import { describe, expect, it } from 'vitest'
import {
collectBranchIds, countSubagentDescendants, detectNewDirectSubagent,
countSubagentDescendants, detectNewDirectSubagent,
directSubagentCount, rootAncestor,
} from '../src/client/subagent-detect.ts'
import type { SidebarSessionList, SidebarSubagentCatalog } from '../src/context-types.ts'
import { isKnownLeaf, subagentCatalogs } from '../src/client/subagent-catalog.ts'
import type { SidebarSessionList, SidebarSubagentCatalogEntry } from '../src/context-types.ts'
describe('subagent detection over the sessions list feed', () => {
/** A list snapshot carrying the given direct subagent children of `parent`. */
@@ -22,7 +23,7 @@ describe('subagent detection over the sessions list feed', () => {
running: running.includes(id),
}
}
return { current: parent, byId }
return { byId }
}
it('counts only the direct subagent children of the given session', () => {
@@ -87,9 +88,8 @@ describe('subagent detection over the sessions list feed', () => {
expect(directSubagentCount(byId, 'p1')).toBe(1)
expect(countSubagentDescendants(byId, 'p1')).toEqual({ count: 1, runningCount: 0 })
// A side thread appearing under an empty session never trips 0 → N.
const before: SidebarSessionList = { current: 'p2', byId: { p2: { id: 'p2', displayTitle: 'P2' } } }
const before: SidebarSessionList = { byId: { p2: { id: 'p2', displayTitle: 'P2' } } }
const after: SidebarSessionList = {
current: 'p2',
byId: {
p2: { id: 'p2', displayTitle: 'P2' },
s2: { id: 's2', displayTitle: 'Side: New thread', origin: 'subagent', parentId: 'p2' },
@@ -106,9 +106,8 @@ describe('subagent detection over the sessions list feed', () => {
// AUTO_OPEN_DEBOUNCE_MS and re-evaluates the ORIGINAL baseline against
// the live snapshot; once the title frame has landed the same baseline
// yields no trigger. These two assertions pin exactly that dependency.
const baseline: SidebarSessionList = { current: 'p2', byId: { p2: { id: 'p2', displayTitle: 'P2' } } }
const baseline: SidebarSessionList = { byId: { p2: { id: 'p2', displayTitle: 'P2' } } }
const firstFrame: SidebarSessionList = {
current: 'p2',
byId: {
p2: { id: 'p2', displayTitle: 'P2' },
s2: { id: 's2', displayTitle: 'DSH-better-sidebar', origin: 'subagent', parentId: 'p2' },
@@ -116,7 +115,6 @@ describe('subagent detection over the sessions list feed', () => {
}
expect(detectNewDirectSubagent(baseline, firstFrame, 'p2')).toBe(true) // the race
const settled: SidebarSessionList = {
current: 'p2',
byId: {
p2: { id: 'p2', displayTitle: 'P2' },
s2: { id: 's2', displayTitle: 'Side: New thread', origin: 'subagent', parentId: 'p2' },
@@ -142,21 +140,52 @@ describe('subagent detection over the sessions list feed', () => {
expect(rootAncestor(byId, undefined)).toBeUndefined()
})
it('collects every catalog branch of the topology, cycle-safe', () => {
const child = (id: string, hasChildren: boolean): SidebarSubagentCatalog['entries'][number] => ({
kind: 'child', id, activity: 'inactive', hasChildren, mode: 'one-shot',
it('derives branch disclosure from the child\'s own projection catalog', () => {
// DSH 0.1.7's catalog row is `{id, createdAt, mode, label?}` — the 0.1.6
// `hasChildren` boolean is gone, so the topology infers it from the
// child's OWN `subagentCatalog` projection: a child whose catalog loaded
// EMPTY is a known leaf, and everything else keeps its level open.
const row = (id: string, over: Partial<SidebarSubagentCatalogEntry> = {}): SidebarSubagentCatalogEntry => ({
id, createdAt: 1_000, mode: 'one-shot', ...over,
})
const catalogs: Record<string, SidebarSubagentCatalog> = {
root: { entries: [child('a', true), child('b', false)], parentAvailable: true, state: 'ready', error: null },
a: { entries: [child('c', false)], parentAvailable: true, state: 'ready', error: null },
}
expect(collectBranchIds(catalogs, 'root')).toEqual(['a'])
expect(collectBranchIds(catalogs, undefined)).toEqual([])
// A cycle terminates (each branch id collected at most once, no hang).
const cyclic: Record<string, SidebarSubagentCatalog> = {
root: { entries: [child('a', true)], parentAvailable: true, state: 'ready', error: null },
a: { entries: [child('root', true)], parentAvailable: true, state: 'ready', error: null },
}
expect(collectBranchIds(cyclic, 'root')).toEqual(['a', 'root'])
const catalogs = subagentCatalogs({
root: {
values: { subagentCatalog: [row('branch'), row('leaf')] },
state: 'ready',
error: null,
},
// The branch has a child of its own; the leaf's catalog loaded empty.
branch: {
values: { subagentCatalog: [row('grand', { mode: 'continuable', label: 'Grand' })] },
state: 'ready',
error: null,
},
leaf: { values: { subagentCatalog: [] }, state: 'ready', error: null },
})
expect(catalogs.root?.entries.map(entry => entry.id)).toEqual(['branch', 'leaf'])
expect(catalogs.root?.state).toBe('ready')
expect(isKnownLeaf(catalogs, 'leaf')).toBe(true)
expect(isKnownLeaf(catalogs, 'branch')).toBe(false)
// A catalog that has not landed (absent from the snapshot, idle without a
// value, still loading, or failed) is NOT a known leaf: the level stays
// reserved instead of flapping shut.
expect(isKnownLeaf(catalogs, 'not-in-snapshot')).toBe(false)
const pending = subagentCatalogs({
idle: { values: {}, state: 'idle', error: null },
loading: { values: {}, state: 'loading', error: null },
failed: { values: {}, state: 'error', error: { code: 'boom', message: 'boom' } },
cached: { values: { subagentCatalog: [row('a')] }, state: 'idle', error: null },
})
expect(pending.idle).toMatchObject({ state: 'loading', entries: [] })
expect(isKnownLeaf(pending, 'idle')).toBe(false)
expect(isKnownLeaf(pending, 'loading')).toBe(false)
// A failed catalog keeps the level open (there may be children) and
// carries the failure the retry row renders.
expect(pending.failed).toMatchObject({ state: 'error', error: { message: 'boom' } })
expect(isKnownLeaf(pending, 'failed')).toBe(false)
// An idle row WITH a value is the persisted checkpoint's catalog.
expect(pending.cached).toMatchObject({ state: 'ready', entries: [expect.objectContaining({ id: 'a' })] })
// A host with no projection store at all leaves every catalog empty.
expect(subagentCatalogs(undefined)).toEqual({})
})
})
+275 -95
View File
@@ -1,17 +1,26 @@
/**
* Subagent page tests for the background-job section: rows render from the
* `jobsBySession` mirror, clicking a row peeks its output through
* `jobs.output` with the OWNER session scope, the kill button needs a
* two-click confirm, settled rows offer no kill, and the output panel never
* polls while the page is hidden.
* Subagent page tests: the projection-backed topology (branch expansion, the
* `mode: 'unknown'` row, navigation and catalog retry) and the background-job
* section (rows read through `jobs.list` per tree session, the output dock
* with the OWNER session scope, the two-click kill confirm, and no polling
* while the page is hidden).
*/
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createElement } from 'react'
import { act } from 'react-dom/test-utils'
import { renderRoot } from './test-utils.ts'
import { renderRoot, setupReactAct } from './test-utils.ts'
setupReactAct()
import { SubagentView } from '../src/client/SubagentView.tsx'
import type { Context, SidebarSessionList } from '../src/context-types.ts'
import type {
Context,
SidebarJobView,
SidebarSessionList,
SidebarSubagentCatalogEntry,
SidebarSubagentAddress,
} from '../src/context-types.ts'
/** A subscribable sessions-list snapshot (mirror of the runtime list feed). */
function makeStore(initial: SidebarSessionList) {
@@ -32,48 +41,58 @@ function makeStore(initial: SidebarSessionList) {
type Store = ReturnType<typeof makeStore>
/** The client context face SubagentView touches (history stub; everything else inert). */
function makeCtx(store: Store): Context {
/** The navigation spy the page must reach (DSH 0.1.6+ `ctx.uiWorkspace`). */
interface NavigationSpy {
opened: Array<SidebarSubagentAddress | string>
}
/** The client context face SubagentView touches (everything else inert). */
function makeCtx(store: Store, navigation?: NavigationSpy): Context {
return {
sessions: {
list: store,
setSubagentCatalogOpen: () => {},
openSubagent: () => {},
open: () => {},
refreshSubagents: async () => {},
},
connection: {
api: {
subagents: {
history: async () => ({ result: { ok: true, value: { events: [], hasMore: false } } }),
},
},
open: (id: string) => { navigation?.opened.push(id) },
},
get: (name: string) => (name === 'uiWorkspace' && navigation !== undefined
? { openSession: (target: SidebarSubagentAddress | string) => { navigation.opened.push(target) } }
: undefined),
} as unknown as Context
}
const outputCalls: Array<{ sessionId: string; id: string }> = []
const killCalls: Array<{ sessionId: string; id: string }> = []
/** Job lists the stubbed `jobs.list` route answers with, keyed by OWNER session. */
let jobsByOwner: Record<string, SidebarJobView[]> = {}
/** The owner sessions the page actually read (the fence's unit of access). */
let jobListCalls: string[] = []
function jsonResponse(value: unknown): Response {
return { ok: true, status: 200, json: async () => value } as unknown as Response
}
/** One `subagentCatalog` projection row (DSH 0.1.7 shape). */
function entry(
id: string,
mode: SidebarSubagentCatalogEntry['mode'],
label?: string,
): SidebarSubagentCatalogEntry {
return { id, createdAt: 1_000, mode, ...(label === undefined ? {} : { label }) }
}
/** One session's loaded projection snapshot. */
function ready(entries: SidebarSubagentCatalogEntry[]) {
return { values: { subagentCatalog: entries }, state: 'ready' as const, error: null }
}
function baseSnapshot(): SidebarSessionList {
return {
current: 'root',
byId: {
root: { id: 'root', displayTitle: '主会话', running: true },
child: { id: 'child', displayTitle: '子代理', origin: 'subagent', parentId: 'root', running: true },
},
subagentsByParent: {},
jobsBySession: {
root: [
{ id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 },
],
child: [
{ id: 'bash-2', kind: 'bash', label: 'echo hi', status: 'completed', startedAt: 2_000, finishedAt: 3_000 },
],
projectionsBySession: {
root: ready([entry('child', 'continuable')]),
child: ready([]),
},
}
}
@@ -81,12 +100,22 @@ function baseSnapshot(): SidebarSessionList {
beforeEach(() => {
outputCalls.length = 0
killCalls.length = 0
jobListCalls = []
jobsByOwner = {
root: [{ id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 }],
child: [{ id: 'bash-2', kind: 'bash', label: 'echo hi', status: 'completed', startedAt: 2_000, finishedAt: 3_000 }],
}
vi.stubGlobal('fetch', async (url: string | URL | Request, init?: RequestInit) => {
const method = String(url).split('/').pop()
const body = JSON.parse(String(init?.body)) as { sessionId?: string; id?: string; rootSessionId?: string }
if (method === 'subagents.live') {
return jsonResponse({ ok: true, value: { live: {} } })
}
if (method === 'jobs.list') {
const owner = body.sessionId ?? ''
jobListCalls.push(owner)
return jsonResponse({ ok: true, value: { jobs: jobsByOwner[owner] ?? [] } })
}
if (method === 'jobs.output') {
outputCalls.push({ sessionId: body.sessionId ?? '', id: body.id ?? '' })
// bash-9 stands for a job the model never read (read:false).
@@ -110,15 +139,139 @@ beforeEach(() => {
afterEach(() => {
vi.unstubAllGlobals()
vi.useRealTimers()
for (const el of document.querySelectorAll('body > div')) el.remove()
})
describe('SubagentView background jobs', () => {
it('renders the tree jobs with status, durations, and owner labels', () => {
/**
* Flush the mount-time job reads: the poll fires immediately, and the request
* → envelope → json → Promise.all chain settles over several microtask ticks.
*/
async function flushJobs(): Promise<void> {
for (let tick = 0; tick < 4; tick++) {
await act(async () => { await Promise.resolve() })
}
}
/** Render the page and wait for its first job read to land. */
async function renderPage(store: Store, active = true, navigation?: NavigationSpy) {
const rendered = renderRoot(
createElement(SubagentView, { sessionId: 'root', active, ctx: makeCtx(store, navigation) }),
)
await flushJobs()
return rendered
}
/** One topology row by its aria-label prefix. */
function row(container: HTMLElement, label: string): HTMLElement {
const found = container.querySelector<HTMLElement>(`[role="treeitem"][aria-label^="${label}"]`)
if (found === null) throw new Error(`no topology row labelled ${label}`)
return found
}
describe('SubagentView topology (projectionsBySession)', () => {
it('renders the tree and expands a branch inferred from its own catalog', async () => {
const store = makeStore({
byId: {
root: { id: 'root', displayTitle: '主会话' },
child: { id: 'child', displayTitle: 'Worker', origin: 'subagent', parentId: 'root' },
grand: { id: 'grand', displayTitle: 'Grandchild', origin: 'subagent', parentId: 'child' },
},
projectionsBySession: {
root: ready([entry('child', 'continuable', 'Worker')]),
// The child carries a child of its own: `hasChildren` is not in the
// 0.1.7 row, it is the child's own catalog that says so.
child: ready([entry('grand', 'one-shot', 'Grandchild')]),
grand: ready([]),
},
})
const { container, unmount } = await renderPage(store)
const branch = row(container, 'Worker')
expect(branch.getAttribute('aria-level')).toBe('1')
expect(branch.getAttribute('aria-expanded')).toBe('true')
// The grandchild is rendered one level down and is a KNOWN LEAF (its own
// catalog loaded empty), so it carries no disclosure.
const leaf = row(container, 'Grandchild')
expect(leaf.getAttribute('aria-level')).toBe('2')
expect(leaf.hasAttribute('aria-expanded')).toBe(false)
// Mode + activity come from the row and the live channel respectively.
expect(branch.getAttribute('aria-label')).toContain('可续接')
expect(leaf.getAttribute('aria-label')).toContain('一次性')
expect(branch.getAttribute('aria-label')).toContain('空闲')
unmount()
})
it('keeps a mode-less (unknown) row unlabelled instead of claiming a mode', async () => {
const store = makeStore({
byId: { root: { id: 'root', displayTitle: '主会话' } },
projectionsBySession: {
// 0.1.7's third mode: a child the host kept without a readable
// descriptor. It claims neither mode, so the card states neither.
root: ready([entry('opaque', 'unknown', 'Opaque child')]),
opaque: ready([]),
},
})
const { container, unmount } = await renderPage(store)
const unknown = row(container, 'Opaque child')
const label = unknown.getAttribute('aria-label') ?? ''
expect(label).toContain('Opaque child')
expect(label).toContain('空闲')
expect(label).not.toContain('一次性')
expect(label).not.toContain('可续接')
unmount()
})
it('opens a child through the workspace face (the sessions face is gone since 0.1.6)', async () => {
const navigation: NavigationSpy = { opened: [] }
const store = makeStore(baseSnapshot())
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
const { container, unmount } = await renderPage(store, true, navigation)
await act(async () => { row(container, '子代理').click() })
// The runtime removed ISessions.openSubagent after 0.1.5-rc.2: the
// workspace face owns main-view selection and is what must be called.
expect(navigation.opened).toEqual([
{ parentSessionId: 'root', childSessionId: 'child', mode: 'continuable' },
])
unmount()
})
it('retries a failed catalog through the 0.1.7 refresh name', async () => {
const refreshed: string[] = []
const store = makeStore({
byId: { root: { id: 'root', displayTitle: '主会话' } },
projectionsBySession: {
root: {
values: {},
state: 'error',
error: { code: 'boom', message: '目录读取失败' },
},
},
})
const rendered = renderRoot(createElement(SubagentView, {
sessionId: 'root',
active: true,
ctx: {
sessions: {
list: store,
refreshProjections: async (sessionId: string) => { refreshed.push(sessionId) },
},
get: () => undefined,
} as unknown as Context,
}))
await flushJobs()
expect(rendered.container.textContent).toContain('目录读取失败')
const retry = [...rendered.container.querySelectorAll('button')]
.find(button => button.textContent?.includes('重试'))
expect(retry).toBeDefined()
await act(async () => { retry?.click() })
expect(refreshed).toEqual(['root'])
rendered.unmount()
})
})
describe('SubagentView background jobs', () => {
it('renders the tree jobs with status, durations, and owner labels', async () => {
const store = makeStore(baseSnapshot())
const { container, unmount } = await renderPage(store)
const text = container.textContent ?? ''
expect(text).toContain('后台任务')
expect(text).toContain('2 个后台任务 · 1 运行中')
@@ -127,52 +280,59 @@ describe('SubagentView background jobs', () => {
expect(text).toContain('echo hi')
expect(text).toContain('主会话')
expect(text).toContain('子代理')
// The fence admits a job to its owner only, so the tree costs one read per
// tree session (and never a read of a session outside the tree).
expect([...jobListCalls].sort()).toEqual(['child', 'root'])
// Only the running row offers a kill button.
expect(container.querySelectorAll('button[aria-label="终止"]')).toHaveLength(1)
unmount()
})
it('renders nothing job-related when the mirror is empty', () => {
const store = makeStore({ current: 'root', byId: { root: { id: 'root', displayTitle: '主会话' } }, subagentsByParent: {}, jobsBySession: {} })
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
it('renders nothing job-related when the tree has no jobs', async () => {
jobsByOwner = {}
const store = makeStore({ byId: { root: { id: 'root', displayTitle: '主会话' } } })
const { container, unmount } = await renderPage(store)
expect(container.textContent).not.toContain('后台任务')
unmount()
})
it('survives the mirror emptying while mounted (hook-order regression #300)', async () => {
const store = makeStore(baseSnapshot())
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
expect(container.textContent).toContain('sleep 300')
// The mirror empties (all jobs settled and dropped): the section must
// vanish WITHOUT reordering hooks — a hook below the empty-state return
// would crash React with "Rendered fewer hooks than expected" (the
// minified #300 the sidebar boundary surfaces with a retry button).
await act(async () => { store.set({ ...baseSnapshot(), jobsBySession: {} }) })
expect(container.textContent).not.toContain('后台任务')
// And returning jobs must work too, with the same hook order.
await act(async () => { store.set(baseSnapshot()) })
expect(container.textContent).toContain('sleep 300')
unmount()
it('survives the job list emptying while mounted (hook-order regression #300)', async () => {
vi.useFakeTimers()
try {
const store = makeStore(baseSnapshot())
const { container, unmount } = await renderPage(store)
expect(container.textContent).toContain('sleep 300')
// Every job settles and drops: the section must vanish WITHOUT reordering
// hooks — a hook below the empty-state return would crash React with
// "Rendered fewer hooks than expected" (the minified #300 the sidebar
// boundary surfaces with a retry button).
jobsByOwner = {}
await act(async () => { await vi.advanceTimersByTimeAsync(3_000) })
expect(container.textContent).not.toContain('后台任务')
// And returning jobs must work too, with the same hook order.
jobsByOwner = {
root: [{ id: 'bash-1', kind: 'bash', label: 'sleep 300', status: 'running', startedAt: 1_000 }],
}
await act(async () => { await vi.advanceTimersByTimeAsync(3_000) })
expect(container.textContent).toContain('sleep 300')
unmount()
} finally {
vi.useRealTimers()
}
})
it('shows the selected job output in the bottom dock, closeable', async () => {
const store = makeStore(baseSnapshot())
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
const row = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
await act(async () => { row.click() })
const { container, unmount } = await renderPage(store)
const first = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
await act(async () => { first.click() })
// The peek request carries the OWNER session (the fence compares it).
expect(outputCalls).toEqual([{ sessionId: 'root', id: 'bash-1' }])
expect(container.textContent).toContain('output-of-bash-1')
// Exactly one dock region exists (never one per row).
expect(container.querySelectorAll('[role="region"]')).toHaveLength(1)
// The selected row is marked, and the close button dismisses the dock.
expect(row.getAttribute('aria-pressed')).toBe('true')
expect(first.getAttribute('aria-pressed')).toBe('true')
const close = container.querySelector('button[aria-label="关闭"]') as HTMLButtonElement
await act(async () => { close.click() })
expect(container.textContent).not.toContain('output-of-bash-1')
@@ -182,9 +342,7 @@ describe('SubagentView background jobs', () => {
it('switches the single dock between selected rows', async () => {
const store = makeStore(baseSnapshot())
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
const { container, unmount } = await renderPage(store)
const first = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
await act(async () => { first.click() })
expect(container.textContent).toContain('output-of-bash-1')
@@ -202,19 +360,16 @@ describe('SubagentView background jobs', () => {
})
it('explains when the model has not read the job yet', async () => {
const snapshot = baseSnapshot()
snapshot.jobsBySession = {
jobsByOwner = {
root: [
...(snapshot.jobsBySession?.root ?? []),
...jobsByOwner.root ?? [],
{ id: 'bash-9', kind: 'bash', label: 'unread cmd', status: 'running', startedAt: 9_000 },
],
}
const store = makeStore(snapshot)
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
const row = container.querySelector('button[aria-label*="unread cmd"]') as HTMLButtonElement
await act(async () => { row.click() })
const store = makeStore(baseSnapshot())
const { container, unmount } = await renderPage(store)
const unread = container.querySelector('button[aria-label*="unread cmd"]') as HTMLButtonElement
await act(async () => { unread.click() })
// read:false → the pane explains the output awaits the model's job_output
// (never the model's cursor, so there is nothing to steal yet).
expect(container.textContent).toContain('等待模型读取该任务的输出')
@@ -230,20 +385,14 @@ describe('SubagentView background jobs', () => {
startedAt: 1_000 + index,
...(index % 2 === 0 ? {} : { finishedAt: 2_000 + index, detail: 'exit code: 1' }),
}))
const store = makeStore({
current: 'root',
byId: { root: { id: 'root', displayTitle: '主会话' } },
subagentsByParent: {},
jobsBySession: { root: many },
})
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
jobsByOwner = { root: many }
const store = makeStore({ byId: { root: { id: 'root', displayTitle: '主会话' } } })
const { container, unmount } = await renderPage(store)
expect(container.textContent).toContain('60 个后台任务 · 30 运行中')
expect(container.querySelectorAll('button[aria-label*="bulk cmd"]')).toHaveLength(60)
// Clicking a row anywhere in the long list still feeds the single dock.
const row = container.querySelector('button[aria-label*="bulk cmd 59"]') as HTMLButtonElement
await act(async () => { row.click() })
const bulk = container.querySelector('button[aria-label*="bulk cmd 59"]') as HTMLButtonElement
await act(async () => { bulk.click() })
expect(outputCalls).toEqual([{ sessionId: 'root', id: 'bash-69' }])
expect(container.textContent).toContain('output-of-bash-69')
unmount()
@@ -251,9 +400,7 @@ describe('SubagentView background jobs', () => {
it('kills a live job only after the two-click confirm', async () => {
const store = makeStore(baseSnapshot())
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: true, ctx: makeCtx(store) }),
)
const { container, unmount } = await renderPage(store)
const kill = container.querySelector('button[aria-label="终止"]') as HTMLButtonElement
await act(async () => { kill.click() })
// First click only arms the confirm — no request leaves the page.
@@ -265,21 +412,54 @@ describe('SubagentView background jobs', () => {
unmount()
})
it('does not poll the output while the page is hidden', async () => {
it('does not read jobs or poll output while the page is hidden', async () => {
vi.useFakeTimers()
try {
const store = makeStore(baseSnapshot())
const { container, unmount } = renderRoot(
createElement(SubagentView, { sessionId: 'root', active: false, ctx: makeCtx(store) }),
)
const row = container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
await act(async () => { row.click() })
const { container, unmount } = await renderPage(store, false)
// A hidden page issues no job read at all (it also has no rows to show).
expect(jobListCalls).toEqual([])
expect(container.textContent).not.toContain('后台任务')
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
// One peek for the initial load; the 2s poll never ran while inactive.
expect(outputCalls).toHaveLength(1)
expect(jobListCalls).toEqual([])
expect(outputCalls).toEqual([])
unmount()
} finally {
vi.useRealTimers()
}
})
it('stops polling the output and the job lists while the page is hidden', async () => {
vi.useFakeTimers()
try {
const store = makeStore(baseSnapshot())
// Render visible to load the rows (a hidden page has none), then hide the
// page and hold the counters still.
const rendered = renderRoot(createElement(SubagentView, {
sessionId: 'root',
active: true,
ctx: makeCtx(store),
}))
await flushJobs()
const first = rendered.container.querySelector('button[aria-label*="sleep 300"]') as HTMLButtonElement
await act(async () => { first.click() })
expect(outputCalls).toHaveLength(1)
rendered.rerender(createElement(SubagentView, {
sessionId: 'root',
active: false,
ctx: makeCtx(store),
}))
// The visibility flip itself re-pulls the dock once (the pane's own
// effect deps include `active`); what must stop is everything after it.
await flushJobs()
const peeks = outputCalls.length
const reads = jobListCalls.length
await act(async () => { await vi.advanceTimersByTimeAsync(10_000) })
expect(outputCalls).toHaveLength(peeks)
expect(jobListCalls).toHaveLength(reads)
rendered.unmount()
} finally {
vi.useRealTimers()
}
})
})
+27 -29
View File
@@ -13,7 +13,7 @@ import {
jobStatusLabel,
treeSessionIds,
} from '../src/client/subagent-jobs.ts'
import type { SidebarSessionList, SidebarSessionSummary, SidebarJobStatus, SidebarJobView } from '../src/context-types.ts'
import type { SidebarSessionSummary, SidebarJobStatus, SidebarJobView } from '../src/context-types.ts'
/** The translator stub: renders duration templates like the real locale copy. */
const templates: Record<string, string> = {
@@ -67,17 +67,19 @@ describe('collectTreeJobs', () => {
root: summary('root'),
child: summary('child', { origin: 'subagent', parentId: 'root' }),
}
const jobsBySession = {
// The map is the client's per-owner read of `jobs.list` (the registry's
// fence admits a job to its owner session only), keyed by that owner.
const jobsByOwner = {
root: [job('bash-1')],
child: [job('bash-2', { status: 'completed', finishedAt: 2_000 })],
stranger: [job('bash-9')],
}
const rows = collectTreeJobs(byId, jobsBySession, 'root')
const rows = collectTreeJobs(byId, jobsByOwner, 'root')
expect(rows.map(row => [row.ownerSessionId, row.ownerTitle, row.job.id]))
.toEqual([['root', 'title-root', 'bash-1'], ['child', 'title-child', 'bash-2']])
})
it('returns an empty list for an absent mirror or empty sets', () => {
it('returns an empty list for an absent read or empty sets', () => {
const byId = { root: summary('root') }
expect(collectTreeJobs(byId, undefined, 'root')).toEqual([])
expect(collectTreeJobs(byId, {}, 'root')).toEqual([])
@@ -134,32 +136,28 @@ describe('status presentation helpers', () => {
})
describe('detectNewJob', () => {
const list = (jobsBySession: Record<string, SidebarJobView[]>): SidebarSessionList => ({
current: 'root',
byId: { root: { id: 'root', displayTitle: 'root' } },
subagentsByParent: {},
jobsBySession: jobsBySession,
})
it('fires on EVERY new job id for the session (not just the first)', () => {
expect(detectNewJob(list({}), list({ root: [job('bash-1')] }), 'root')).toBe(true)
/**
* The baseline rule the auto-open trigger adds on top of this helper: the
* FIRST list a page reads only arms the baseline (the poller keeps
* `prev === undefined` until a read succeeds), so a conversation that is
* already running jobs when the page loads never pops the Tasks page. The
* helper itself is pure over two lists.
*/
it('fires on EVERY job id the previous list lacked, and on nothing else', () => {
// A new id appears (the first job, then another one alongside it).
expect(detectNewJob([], [job('bash-1')])).toBe(true)
expect(detectNewJob([job('bash-1')], [job('bash-1'), job('bash-2')])).toBe(true)
// Settling only mutates status: same ids, no trigger.
expect(detectNewJob(
list({ root: [job('bash-1')] }),
list({ root: [job('bash-1'), job('bash-2')] }),
'root',
)).toBe(true)
})
it('stays quiet on settling, same ids, other sessions, or an absent mirror', () => {
// Settling only mutates status, never adds ids.
expect(detectNewJob(
list({ root: [job('bash-1')] }),
list({ root: [job('bash-1', { status: 'completed', finishedAt: 2_000 })] }),
'root',
[job('bash-1')],
[job('bash-1', { status: 'completed', finishedAt: 2_000 })],
)).toBe(false)
expect(detectNewJob(list({ root: [job('bash-1')] }), list({ root: [job('bash-1')] }), 'root')).toBe(false)
// Jobs owned by another session do not trigger the current one.
expect(detectNewJob(list({}), list({ child: [job('bash-1')] }), 'root')).toBe(false)
expect(detectNewJob(list({}), list({}), 'root')).toBe(false)
// Identical lists, and lists that only LOST a job (settled and dropped),
// are not new work.
expect(detectNewJob([job('bash-1')], [job('bash-1')])).toBe(false)
expect(detectNewJob([job('bash-1'), job('bash-2')], [job('bash-2')])).toBe(false)
// The first list of a fresh page is compared against itself by the caller
// (prev stays undefined until a read succeeds) — the helper's quiet case.
expect(detectNewJob([], [])).toBe(false)
})
})
+34 -37
View File
@@ -37,10 +37,8 @@ function makeCtx(store: Store, historySpy: ReturnType<typeof vi.fn>): Context {
return {
sessions: {
list: store,
setSubagentCatalogOpen: () => {},
openSubagent: () => {},
open: () => {},
refreshSubagents: async () => {},
},
connection: {
api: {
@@ -55,60 +53,55 @@ function jsonResponse(value: unknown): Response {
return { ok: true, status: 200, json: async () => value } as unknown as Response
}
/** A topology snapshot with two running direct subagents and a ready catalog. */
/** One `subagentCatalog` projection row (DSH 0.1.7 shape). */
function entry(
id: string,
mode: 'one-shot' | 'continuable' | 'unknown',
label?: string,
): { id: string; createdAt: number; mode: 'one-shot' | 'continuable' | 'unknown'; label?: string } {
return { id, createdAt: 1_000, mode, ...(label === undefined ? {} : { label }) }
}
/** One session's loaded projection snapshot. */
function ready(entries: ReturnType<typeof entry>[]) {
return { values: { subagentCatalog: entries }, state: 'ready' as const, error: null }
}
/**
* A topology snapshot with two direct subagents and their catalogs. The rows
* carry no activity of their own any more: the live channel below is what
* marks a child running.
*/
function runningSnapshot(): SidebarSessionList {
return {
current: 'root',
byId: {
root: { id: 'root', displayTitle: '主会话' },
a: { id: 'a', displayTitle: 'A', origin: 'subagent', parentId: 'root', running: true },
b: { id: 'b', displayTitle: 'B', origin: 'subagent', parentId: 'root', running: true },
},
subagentsByParent: {
root: {
entries: [
{ kind: 'child', id: 'a', activity: 'running', hasChildren: false, mode: 'one-shot', label: 'A' },
{ kind: 'child', id: 'b', activity: 'running', hasChildren: false, mode: 'one-shot', label: 'B' },
],
parentAvailable: true,
state: 'ready',
error: null,
},
projectionsBySession: {
root: ready([entry('a', 'one-shot', 'A'), entry('b', 'one-shot', 'B')]),
a: ready([]),
b: ready([]),
},
jobsBySession: {},
}
}
/** The same tree re-rooted under a new ancestor ('grand' becomes the root). */
function reRootedSnapshot(): SidebarSessionList {
return {
current: 'root',
byId: {
grand: { id: 'grand', displayTitle: '主会话' },
root: { id: 'root', displayTitle: 'R', origin: 'subagent', parentId: 'grand', running: true },
a: { id: 'a', displayTitle: 'A', origin: 'subagent', parentId: 'root', running: true },
b: { id: 'b', displayTitle: 'B', origin: 'subagent', parentId: 'root', running: true },
},
subagentsByParent: {
grand: {
entries: [
{ kind: 'child', id: 'root', activity: 'running', hasChildren: true, mode: 'continuable', label: 'R' },
],
parentAvailable: true,
state: 'ready',
error: null,
},
root: {
entries: [
{ kind: 'child', id: 'a', activity: 'running', hasChildren: false, mode: 'one-shot', label: 'A' },
{ kind: 'child', id: 'b', activity: 'running', hasChildren: false, mode: 'one-shot', label: 'B' },
],
parentAvailable: true,
state: 'ready',
error: null,
},
projectionsBySession: {
grand: ready([entry('root', 'continuable', 'R')]),
root: ready([entry('a', 'one-shot', 'A'), entry('b', 'one-shot', 'B')]),
a: ready([]),
b: ready([]),
},
jobsBySession: {},
}
}
@@ -132,8 +125,9 @@ describe('SubagentView live polling', () => {
if (method === 'subagents.live') {
const body = JSON.parse(String(init?.body)) as { rootSessionId?: string }
liveCalls.push(body.rootSessionId ?? '')
return jsonResponse({ ok: true, value: { live: {} } })
return jsonResponse({ ok: true, value: { live: { a: {}, b: {} } } })
}
if (method === 'jobs.list') return jsonResponse({ ok: true, value: { jobs: [] } })
throw new Error(`unexpected fetch ${String(url)}`)
})
@@ -165,6 +159,7 @@ describe('SubagentView live polling', () => {
liveCalls.push(body.rootSessionId ?? '')
return new Promise<Response>((resolve) => { resolveFirst = resolve })
}
if (method === 'jobs.list') return jsonResponse({ ok: true, value: { jobs: [] } })
throw new Error(`unexpected fetch ${String(url)}`)
})
@@ -182,7 +177,7 @@ describe('SubagentView live polling', () => {
// Settle the first request; only then does the next 3s tick fire.
await act(async () => {
resolveFirst?.(jsonResponse({ ok: true, value: { live: {} } }))
resolveFirst?.(jsonResponse({ ok: true, value: { live: { a: {}, b: {} } } }))
await Promise.resolve()
})
await act(async () => { await vi.advanceTimersByTimeAsync(3_000) })
@@ -203,6 +198,7 @@ describe('SubagentView live polling', () => {
liveCalls.push(body.rootSessionId ?? '')
return new Promise<Response>((resolve) => { resolveFirst = resolve })
}
if (method === 'jobs.list') return jsonResponse({ ok: true, value: { jobs: [] } })
throw new Error(`unexpected fetch ${String(url)}`)
})
@@ -242,6 +238,7 @@ describe('SubagentView live polling', () => {
const live = body.rootSessionId === 'root' ? { a: { text: 'hello' } } : {}
return jsonResponse({ ok: true, value: { live } })
}
if (method === 'jobs.list') return jsonResponse({ ok: true, value: { jobs: [] } })
throw new Error(`unexpected fetch ${String(url)}`)
})