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:
@@ -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
@@ -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)', () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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({})
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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
@@ -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' }],
|
||||
|
||||
@@ -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: [] }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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({})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)}`)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user