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