Merge remote-tracking branch 'origin/master' into worktree/llm-reasoning-effort

# Conflicts:
#	docs/architecture.i18n.yaml
#	docs/config-catalog.md
This commit is contained in:
Yichen Jiang
2026-07-26 13:13:31 +08:00
338 changed files with 15268 additions and 3386 deletions

View File

@@ -65,7 +65,7 @@ The GUI test structure (three tiers, lane map) is settled in the [GUI testing sy
Run the narrowest rung that covers what you touched; escalate only when the change surface demands it.
1. **Every GUI code change**`pnpm run test:gui` (seconds; no browser, no server): the client suites plus the host-side GUI packages. This is the inner loop; run it as freely as a typecheck.
2. **Changes to the build surface, boot wiring, or static serving** (`apps/web`, vite config, `dsh-host-webserver`) — additionally `pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`).
2. **Changes to the build surface, boot wiring, static serving, or the wire carriage** (`apps/web`, vite config, `dsh-host-webserver`, connection/handler/SSE) — additionally `pnpm run test:web`: rebuilds the frontend dist, then runs the browser smoke pair (the real-host case self-skips without `DEEPSEEK_API_KEY`) plus the keyless replayed e2e scenarios (`DSH_SNAPSHOT=refresh` rewrites their aria goldens after an intentional conversation-UI change; `DSH_SNAPSHOT=record` re-records fixtures with a key).
3. **Before a PR**`pnpm run check:pre-push` (the repo-wide gate ladder). Between PR windows this rung is not expected on every commit.
If `test:gui` is red on code you did not touch, neither silently fix nor ignore it: note it in your handoff so it lands in the next PR window's sweep.

View File

@@ -641,6 +641,59 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
emitHost({ type: 'host/workspace-changed', workspace: { ...created } })
return ok(request, { workspace: { ...created }, created: true })
},
rename: (request) => {
const { workspaceId, title } = request.payload
const workspace = workspaces.find(w => w.workspaceId === workspaceId)
if (workspace === undefined) {
return err(request, {
code: 'workspace-not-found',
message: `no workspace ${workspaceId}`,
details: { workspaceId },
})
}
const trimmed = title.trim()
if (trimmed !== workspace.title) {
if (workspaces.some(w => w.workspaceId !== workspaceId && w.title === trimmed)) {
return err(request, {
code: 'workspace-name-conflict',
message: `workspace name '${trimmed}' is already in use`,
details: { name: trimmed },
})
}
workspace.title = trimmed
workspace.updatedAt = new Date().toISOString()
emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } })
}
return ok(request, { workspace: { ...workspace } })
},
insertSessionBefore: (request) => {
const { workspaceId, sessionId, beforeSessionId } = request.payload
const workspace = workspaces.find(w => w.workspaceId === workspaceId)
if (workspace === undefined) {
return err(request, {
code: 'workspace-not-found',
message: `no workspace ${workspaceId}`,
details: { workspaceId },
})
}
if (!workspace.sessionIds.includes(sessionId)
|| (beforeSessionId !== undefined && !workspace.sessionIds.includes(beforeSessionId))) {
return err(request, {
code: 'workspace-move-invalid',
message: `session or anchor is not accounted by workspace ${workspaceId}`,
details: { workspaceId, sessionId, ...beforeSessionId === undefined ? {} : { beforeSessionId } },
})
}
const without = workspace.sessionIds.filter(id => id !== sessionId)
const at = beforeSessionId === undefined ? without.length : without.indexOf(beforeSessionId)
const sessionIds = [...without.slice(0, at), sessionId, ...without.slice(at)]
if (!sessionIds.every((id, index) => id === workspace.sessionIds[index])) {
workspace.sessionIds = sessionIds
workspace.updatedAt = new Date().toISOString()
emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } })
}
return ok(request, { workspace: { ...workspace } })
},
},
events: {
async *mux(_request, signal) {
@@ -757,6 +810,8 @@ export class FixtureApiClient extends AbstractApiClient {
case 'host.describe': return this.api.host.describe(request)
case 'workspace.list': return this.api.workspace.list(request)
case 'workspace.create': return this.api.workspace.create(request)
case 'workspace.rename': return this.api.workspace.rename(request)
case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request)
}
}

View File

@@ -77,6 +77,12 @@ export class FakeApiClient implements IApiClient {
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
created: true,
}))),
rename: (payload: unknown) => this.record('workspace.rename', payload, Promise.resolve(ok({
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
}))),
insertSessionBefore: (payload: unknown) => this.record('workspace.insertSessionBefore', payload, Promise.resolve(ok({
workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' },
}))),
}
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */

View File

@@ -311,6 +311,60 @@ describe('createFixtureApi', () => {
expect(rootPath.result.value.workspace.title).toBe('/')
})
it('workspace.rename covers not-found, conflict, no-op, and the changed frame', async () => {
const api = createFixtureApi()
const abort = new AbortController()
const seen: HostFrame[] = []
const consuming = (async () => {
for await (const envelope of api.events.host(req({}), abort.signal)) {
seen.push(envelope.payload)
if (seen.length >= 2) abort.abort()
}
})()
await new Promise(resolve => setTimeout(resolve, 10))
const wsid = 'fx-ws-fixture' as WorkspaceId
const missing = await api.workspace.rename(req({ workspaceId: 'fx-ws-void' as WorkspaceId, title: 'x' }))
expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } })
await api.workspace.create(req({ name: 'occupied' }))
const conflict = await api.workspace.rename(req({ workspaceId: wsid, title: ' occupied ' }))
expect(conflict.result).toMatchObject({ ok: false, error: { code: 'workspace-name-conflict', details: { name: 'occupied' } } })
const noop = await api.workspace.rename(req({ workspaceId: wsid, title: ' fixture ' }))
if (!noop.result.ok) throw new Error('no-op rename failed')
expect(noop.result.value.workspace.title).toBe('fixture')
const renamed = await api.workspace.rename(req({ workspaceId: wsid, title: 'renamed' }))
if (!renamed.result.ok) throw new Error('rename failed')
expect(renamed.result.value.workspace.title).toBe('renamed')
await consuming
// Only the create and the effective rename emit frames; the no-op stays silent.
expect(seen.map(f => f.type)).toEqual(['host/workspace-changed', 'host/workspace-changed'])
})
it('workspace.insertSessionBefore moves, appends, no-ops, and rejects invalid ids', async () => {
const api = createFixtureApi()
const wsid = 'fx-ws-fixture' as WorkspaceId
const missing = await api.workspace.insertSessionBefore(req({ workspaceId: 'fx-ws-void' as WorkspaceId, sessionId: sid('fx-alpha') }))
expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found' } })
const ghost = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-ghost') }))
expect(ghost.result).toMatchObject({ ok: false, error: { code: 'workspace-move-invalid', details: { sessionId: 'fx-ghost' } } })
const badAnchor = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha'), beforeSessionId: sid('fx-ghost') }))
expect(badAnchor.result).toMatchObject({ ok: false, error: { code: 'workspace-move-invalid', details: { beforeSessionId: 'fx-ghost' } } })
const moved = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-gamma'), beforeSessionId: sid('fx-beta') }))
if (!moved.result.ok) throw new Error('move failed')
expect(moved.result.value.workspace.sessionIds).toEqual(['fx-alpha', 'fx-gamma', 'fx-beta'])
const appended = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha') }))
if (!appended.result.ok) throw new Error('append failed')
expect(appended.result.value.workspace.sessionIds).toEqual(['fx-gamma', 'fx-beta', 'fx-alpha'])
const before = appended.result.value.workspace.updatedAt
const noop = await api.workspace.insertSessionBefore(req({ workspaceId: wsid, sessionId: sid('fx-alpha') }))
if (!noop.result.ok) throw new Error('no-op move failed')
expect(noop.result.value.workspace.sessionIds).toEqual(['fx-gamma', 'fx-beta', 'fx-alpha'])
expect(noop.result.value.workspace.updatedAt).toBe(before)
})
it('session.create({workspaceId}) lands on the account and unknown ids error', async () => {
const api = createFixtureApi()
const abort = new AbortController()
@@ -558,6 +612,15 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => {
const workspace = await client.workspace.create({ name: 'via-client' })
if (!workspace.result.ok) throw new Error('workspace create failed')
expect(workspace.result.value.workspace.title).toBe('via-client')
const wsid = workspace.result.value.workspace.workspaceId
const renamed = await client.workspace.rename({ workspaceId: wsid, title: 'via-client-2' })
if (!renamed.result.ok) throw new Error('workspace rename failed')
expect(renamed.result.value.workspace.title).toBe('via-client-2')
const attached = await client.sessions.create({ workspaceId: wsid })
if (!attached.result.ok) throw new Error('attached create failed')
const moved = await client.workspace.insertSessionBefore({ workspaceId: wsid, sessionId: attached.result.value.sessionId })
if (!moved.result.ok) throw new Error('workspace move failed')
expect(moved.result.value.workspace.sessionIds).toEqual([attached.result.value.sessionId])
})
it('maps empty, prompt-reject, and workspace-first query scenarios', async () => {

View File

@@ -1,7 +1,7 @@
/** Workspace baseline, incremental-frame, and unary-action owner. */
import type {
HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, WorkspaceView,
HostFrame, IApiClient, RpcError, RpcRequest, RpcResult, SessionId, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api'
import { mergeOrderedBaseline } from '../ordered-baseline.ts'
@@ -143,6 +143,40 @@ export class WorkspaceManager {
return result
}
/**
* Rename a Workspace, then publish its returned snapshot without waiting
* for the changed frame.
* @param workspaceId - target workspace.
* @param title - new display title.
* @returns the wire result.
*/
async rename(workspaceId: WorkspaceId, title: string): Promise<RpcResult<{ workspace: WorkspaceView }>> {
const { result } = await this.api.workspace.rename({ workspaceId, title })
if (result.ok) this.upsert(result.value.workspace)
return result
}
/**
* Move a session within its Workspace's manual order, then publish the
* returned snapshot without waiting for the changed frame.
* @param workspaceId - owning workspace.
* @param sessionId - accounted session to move.
* @param beforeSessionId - accounted anchor to insert before; omitted appends.
* @returns the wire result.
*/
async insertSessionBefore(
workspaceId: WorkspaceId,
sessionId: SessionId,
beforeSessionId?: SessionId,
): Promise<RpcResult<{ workspace: WorkspaceView }>> {
const { result } = await this.api.workspace.insertSessionBefore({
workspaceId, sessionId,
...beforeSessionId === undefined ? {} : { beforeSessionId },
})
if (result.ok) this.upsert(result.value.workspace)
return result
}
/**
* Host-frame entry. Non-workspace frames are ignored so the runtime can
* fan one host stream out to both object managers.
@@ -189,6 +223,11 @@ export class WorkspaceManager {
private upsert(view: WorkspaceView, identity?: Workspace): void {
this.refreshFrames?.push(view)
const index = this.items.findIndex(item => item.getSnapshot().view?.workspaceId === view.workspaceId)
// Mutation responses and changed frames race (two carriers, no ordering):
// reject a snapshot strictly older than the installed projection so a
// late unary response cannot roll back a newer frame.
const installed = index === -1 ? undefined : this.items[index]?.getSnapshot().view
if (installed !== undefined && Date.parse(view.updatedAt) < Date.parse(installed.updatedAt)) return
if (identity !== undefined) {
this.items = index === -1
? [identity, ...this.items]

View File

@@ -2,7 +2,7 @@
import type { Context } from 'cordis'
import type {
IApiClient, RpcError, WorkspaceId, WorkspaceView,
IApiClient, RpcError, SessionId, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import type { SnapshotStore } from '../contract/store.ts'
import { createSnapshotStore } from '../contract/store.ts'
@@ -100,6 +100,35 @@ export class WorkspacesService {
return result.value.workspace
}
/**
* Rename a Workspace.
* @param workspaceId - target workspace.
* @param title - new display title (trimmed non-empty by the Host).
* @returns the renamed Workspace view.
*/
async rename(workspaceId: WorkspaceId, title: string): Promise<WorkspaceView> {
const result = await this.manager.rename(workspaceId, title)
if (!result.ok) throw new Error(`workspace rename failed: ${result.error.code}: ${result.error.message}`)
return result.value.workspace
}
/**
* Move a session within its Workspace's manual order (DOM-insertBefore-like).
* @param workspaceId - owning workspace.
* @param sessionId - accounted session to move.
* @param beforeSessionId - accounted anchor to insert before; omitted appends.
* @returns the updated Workspace view.
*/
async insertSessionBefore(
workspaceId: WorkspaceId,
sessionId: SessionId,
beforeSessionId?: SessionId,
): Promise<WorkspaceView> {
const result = await this.manager.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
if (!result.ok) throw new Error(`workspace move failed: ${result.error.code}: ${result.error.message}`)
return result.value.workspace
}
/**
* Refresh the workspace baseline, reusing an in-flight pull.
* @returns completion of the current or newly started workspace baseline pull.

View File

@@ -92,9 +92,18 @@ export class FakeApiClient implements IApiClient {
onWorkspaceCreate: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>> =
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws'), created: true }))
onWorkspaceRename: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> =
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
onWorkspaceInsertSessionBefore: (payload: unknown) => Promise<RpcResponse<{ workspace: WorkspaceView }>> =
() => Promise.resolve(ok({ workspace: fakeWorkspace('fk-ws') }))
readonly workspace: IApiClient['workspace'] = {
list: (payload: unknown) => this.record('workspace.list', payload, this.onWorkspaceList(payload)),
create: (payload: unknown) => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)),
rename: (payload: unknown) => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)),
insertSessionBefore: (payload: unknown) =>
this.record('workspace.insertSessionBefore', payload, this.onWorkspaceInsertSessionBefore(payload)),
}
/** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */

View File

@@ -0,0 +1,22 @@
/* Block, not inline-flex: consumers wrap full-width list rows and an
* inline wrapper would shrink them; the card still measures this rect. */
.root {
position: relative;
display: block;
}
/* Preview card (figma session hover card): 244 wide, r12, pad 12/16, the
* menu card's elevation. Surface is #2C2C2E in both themes (figma value,
* light/dark identical), so a component-level variable, not a theme token. */
.card {
--dsw-hovercard-bg: #2C2C2E;
position: fixed;
z-index: 100;
box-sizing: border-box;
width: 244px;
padding: 12px 16px;
border-radius: 12px;
background: var(--dsw-hovercard-bg);
box-shadow: var(--dsw-shadow-lv3);
pointer-events: none;
}

View File

@@ -0,0 +1,112 @@
// HoverCard: delayed hover-preview card portaled to document.body.
// Same portal mechanics as Menu: the wrapper span supplies the anchor rect,
// the card is fixed-positioned at its right edge and repositions on
// scroll/resize while open. Display-only — the card ignores pointer events
// and closes the instant the pointer leaves the anchor (no close delay).
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { createPortal } from 'react-dom'
import css from './HoverCard.module.css'
/**
* Render an anchor with a hover-triggered preview card.
* @param props.anchor - the hover target (rendered in place inside a wrapper span).
* @param props.content - card content (display-only, no pointer interaction).
* @param props.openDelayMs - hover dwell before the card shows (default 500).
* @param props.disabled - suppress opening; turning true closes an open card.
* @returns anchor wrapper with the conditional portaled card.
*/
export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false }: {
anchor: ReactNode
content: ReactNode
openDelayMs?: number
disabled?: boolean
}) {
const rootRef = useRef<HTMLSpanElement>(null)
const cardRef = useRef<HTMLDivElement>(null)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [open, setOpen] = useState(false)
const [pos, setPos] = useState<{ left: number; top: number } | null>(null)
const clearTimer = () => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}
// Owner disabling mid-hover (menu opened, drag started) closes immediately.
useEffect(() => {
if (!disabled) return
clearTimer()
setOpen(false)
}, [disabled])
useEffect(() => clearTimer, [])
// Fixed-position from the anchor rect before paint; track the anchor while
// open (capture-phase scroll catches nested panes), as in Menu portal mode.
useLayoutEffect(() => {
if (!open) { setPos(null); return }
const place = () => {
const wrapper = rootRef.current
/* v8 ignore next -- the ref is attached before the layout effect runs and the listeners die with it. */
if (wrapper === null) return
const r = wrapper.getBoundingClientRect()
const h = cardRef.current?.offsetHeight ?? 0
const top = r.top + h > window.innerHeight - 8 ? window.innerHeight - h - 8 : r.top
setPos({ left: r.right + 8, top })
}
place()
window.addEventListener('scroll', place, true)
window.addEventListener('resize', place)
return () => {
window.removeEventListener('scroll', place, true)
window.removeEventListener('resize', place)
}
}, [open])
// The first placement ran before the card mounted (height read 0): once the
// card's real height is measurable, correct the bottom-edge clamp. The
// correction converges — a clamped top satisfies the guard, so it runs once.
useLayoutEffect(() => {
if (!open || pos === null) return
/* v8 ignore next -- the card is mounted whenever pos is set, so the ref is attached here. */
const h = cardRef.current?.offsetHeight ?? 0
if (pos.top + h > window.innerHeight - 8) {
setPos({ left: pos.left, top: window.innerHeight - h - 8 })
}
}, [open, pos])
const card = open && pos !== null && (
<div ref={cardRef} className={css.card} style={pos}>
{content}
</div>
)
return (
<span
ref={rootRef}
className={css.root}
onPointerEnter={() => {
if (disabled) return
clearTimer()
timerRef.current = setTimeout(() => { setOpen(true) }, openDelayMs)
}}
onPointerLeave={() => {
clearTimer()
setOpen(false)
}}
// Any press inside the anchor (row click, menu trigger) dismisses the
// card immediately, without waiting for the owner to flip `disabled`.
onPointerDownCapture={() => {
clearTimer()
setOpen(false)
}}
>
{anchor}
{card !== false && createPortal(card, document.body)}
</span>
)
}

View File

@@ -109,6 +109,27 @@
background: transparent;
}
/* Destructive row: error text/icon, danger hover fill. */
.danger {
color: var(--dsw-alias-state-error-primary);
}
.danger .itemIcon {
color: var(--dsw-alias-state-error-primary);
}
.danger:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover-danger);
}
/* Heading row: non-interactive small grey text, padding aligned with items. */
.label {
padding: 8px 10px;
font-size: 12px;
line-height: 16px;
color: var(--dsw-alias-label-tertiary);
}
/* Separator cell (figma 122:9481): py 4 / px 2 around the hairline. */
.separator {
height: 1px;

View File

@@ -4,6 +4,7 @@
// the anchor rect, for anchors inside overflow-clipping containers (sidebar).
// The owner controls `open`; outside-click closing uses one document listener
// active only while open. Submenus open on hover/focus inside the same root.
// Entries also cover non-interactive `label` headings and `danger` rows.
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
import type { CSSProperties, ReactNode } from 'react'
@@ -19,6 +20,8 @@ export interface MenuItem {
disabled?: boolean
/** Leading icon (figma .Menu_cell gap 8). */
icon?: ReactNode
/** Destructive row: error-colored text/icon and danger hover fill. */
danger?: boolean
/** Nested card opened to the right on hover/focus. */
submenu?: readonly MenuItem[]
}
@@ -29,13 +32,24 @@ export interface MenuSeparator {
id: string
}
/** One primary-menu entry: a row or a separator. */
export type MenuEntry = MenuItem | MenuSeparator
/** Non-interactive heading row above a group of items. */
export interface MenuLabel {
type: 'label'
id: string
text: string
}
/** One primary-menu entry: a row, a separator, or a heading label. */
export type MenuEntry = MenuItem | MenuSeparator | MenuLabel
function isSeparator(entry: MenuEntry): entry is MenuSeparator {
return 'type' in entry && entry.type === 'separator'
}
function isLabel(entry: MenuEntry): entry is MenuLabel {
return 'type' in entry && entry.type === 'label'
}
/**
* Render an anchored dropdown menu.
* @param props.open - whether the list is showing (owner-controlled).
@@ -50,6 +64,8 @@ function isSeparator(entry: MenuEntry): entry is MenuSeparator {
* from the anchor rect (repositions on scroll/resize while open). Use when an
* ancestor's overflow clipping would crop the in-place list; default false
* keeps the pure-CSS in-place behavior.
* @param props.closeOnPointerLeave - close the list when the pointer leaves
* it (default false keeps it open until outside click/Escape/selection).
* @param props.getAnchorRect - portal mode only: supply the anchor rect
* directly (e.g. from a host-owned trigger button) instead of measuring the
* Menu's own wrapper span. Required when the wrapper isn't itself laid out at
@@ -58,7 +74,7 @@ function isSeparator(entry: MenuEntry): entry is MenuSeparator {
* scroll/resize; return null to skip placement for that frame.
* @returns anchor wrapper with the conditional list.
*/
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, getAnchorRect, className }: {
export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align = 'start', side = 'bottom', portal = false, closeOnPointerLeave = false, getAnchorRect, className }: {
open: boolean
anchor: ReactNode
items: readonly MenuEntry[]
@@ -68,6 +84,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
align?: 'start' | 'end'
side?: 'bottom' | 'top'
portal?: boolean
closeOnPointerLeave?: boolean
getAnchorRect?: () => DOMRect | null
className?: string
}) {
@@ -135,11 +152,19 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
className={clsx(css.list, portal && css.portal, side === 'top' && !portal && css.sideTop, align === 'end' && !portal && css.alignEnd)}
style={fixedPos ?? undefined}
role="menu"
onPointerLeave={closeOnPointerLeave ? () => { onClose() } : undefined}
// React portals bubble synthetic events through the REACT tree: without
// this stop, an item click re-fires the anchor row's own onClick
// (open/toggle) after onSelect.
onClick={(e) => { e.stopPropagation() }}
>
{items.map(entry => {
if (isSeparator(entry)) {
return <div key={entry.id} className={css.separator} role="separator" />
}
if (isLabel(entry)) {
return <div key={entry.id} className={css.label} role="presentation">{entry.text}</div>
}
const hasSub = entry.submenu !== undefined && entry.submenu.length > 0
const subOpen = hasSub && openSubmenuId === entry.id
return (
@@ -152,7 +177,7 @@ export function Menu({ open, anchor, items, selectedId, onSelect, onClose, align
<button
type="button"
role="menuitem"
className={clsx(css.item, entry.id === selectedId && css.selected)}
className={clsx(css.item, entry.id === selectedId && css.selected, entry.danger === true && css.danger)}
disabled={entry.disabled}
aria-haspopup={hasSub ? 'menu' : undefined}
aria-expanded={hasSub ? subOpen : undefined}

View File

@@ -9,7 +9,8 @@ export type { ButtonVariant } from './Button.tsx'
export { Pill } from './Pill.tsx'
export { Input } from './Input.tsx'
export { Menu } from './Menu.tsx'
export type { MenuEntry, MenuItem, MenuSeparator } from './Menu.tsx'
export type { MenuEntry, MenuItem, MenuSeparator, MenuLabel } from './Menu.tsx'
export { HoverCard } from './HoverCard.tsx'
export { Modal } from './Modal.tsx'
export { ConnectionBanner } from './ConnectionBanner.tsx'
export { FishLogo } from './FishLogo.tsx'

View File

@@ -136,6 +136,51 @@ describe('Menu', () => {
expect(screen.getByRole('separator')).toBeDefined()
})
it('renders a non-interactive heading label and a danger row', () => {
const onSelect = vi.fn()
render(
<Menu
open
anchor={<span>trigger</span>}
items={[
{ type: 'label', id: 'h', text: 'Group by' },
{ id: 'del', label: 'Delete', danger: true },
]}
onSelect={onSelect}
onClose={() => {}}
/>)
const heading = screen.getByText('Group by')
expect(heading.getAttribute('role')).toBe('presentation')
// The heading is not a menu item — only the danger row is interactive.
expect(screen.getAllByRole('menuitem')).toHaveLength(1)
const danger = screen.getByRole('menuitem', { name: 'Delete' })
expect(danger.className).toMatch(/danger/)
fireEvent.click(danger)
expect(onSelect).toHaveBeenCalledWith('del')
})
it('closeOnPointerLeave closes when the pointer leaves the list; default stays open', () => {
const onClose = vi.fn()
const { rerender } = render(
<Menu open closeOnPointerLeave anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
fireEvent.pointerLeave(screen.getByRole('menu'))
expect(onClose).toHaveBeenCalledTimes(1)
rerender(
<Menu open anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={onClose} />)
fireEvent.pointerLeave(screen.getByRole('menu'))
expect(onClose).toHaveBeenCalledTimes(1)
})
it('a list click does not bubble to the anchor row (portal synthetic-event path)', () => {
const rowClick = vi.fn()
render(
<div onClick={rowClick}>
<Menu open anchor={<span>trigger</span>} items={items} onSelect={() => {}} onClose={() => {}} />
</div>)
fireEvent.click(screen.getByRole('menuitem', { name: 'Alpha' }))
expect(rowClick).not.toHaveBeenCalled()
})
it('opens a submenu on hover and selects a nested item', () => {
const onSelect = vi.fn()
render(

View File

@@ -0,0 +1,148 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { HoverCard } from '@deepseek-ai/dsh-client-ui-primitives'
afterEach(cleanup)
beforeEach(() => { vi.useFakeTimers() })
afterEach(() => { vi.useRealTimers() })
/** Anchor wrapper rect: the card positions from this (jsdom rects are all-zero by default). */
function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number }): void {
const wrapper = anchor.parentElement as HTMLElement
wrapper.getBoundingClientRect = () => ({
top: rect.top, right: rect.right, left: rect.right - 100, bottom: rect.top + 34,
width: 100, height: 34, x: rect.right - 100, y: rect.top, toJSON: () => ({}),
} as DOMRect)
}
function mount(props: { openDelayMs?: number; disabled?: boolean } = {}) {
const view = render(
<HoverCard anchor={<span>row</span>} content={<div>card body</div>} {...props} />,
)
const anchor = screen.getByText('row')
stubAnchorRect(anchor, { top: 40, right: 200 })
return { view, anchor, wrapper: anchor.parentElement as HTMLElement }
}
describe('HoverCard', () => {
it('opens after the dwell delay, positioned right of the anchor', () => {
const { wrapper } = mount()
fireEvent.pointerEnter(wrapper)
expect(screen.queryByText('card body')).toBeNull()
act(() => { vi.advanceTimersByTime(499) })
expect(screen.queryByText('card body')).toBeNull()
act(() => { vi.advanceTimersByTime(1) })
const card = screen.getByText('card body').parentElement as HTMLElement
expect(card.parentElement).toBe(document.body)
expect(card.style.left).toBe('208px')
expect(card.style.top).toBe('40px')
})
it('honors a custom openDelayMs', () => {
const { wrapper } = mount({ openDelayMs: 50 })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(50) })
expect(screen.getByText('card body')).toBeTruthy()
})
it('pointerleave before the delay cancels the pending open', () => {
const { wrapper } = mount()
fireEvent.pointerEnter(wrapper)
fireEvent.pointerLeave(wrapper)
act(() => { vi.advanceTimersByTime(1000) })
expect(screen.queryByText('card body')).toBeNull()
})
it('pointerleave closes an open card immediately; re-enter restarts the dwell', () => {
const { wrapper } = mount()
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getByText('card body')).toBeTruthy()
fireEvent.pointerLeave(wrapper)
expect(screen.queryByText('card body')).toBeNull()
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getByText('card body')).toBeTruthy()
})
it('a press inside the anchor dismisses the card without waiting for disabled', () => {
const { wrapper } = mount()
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getByText('card body')).toBeTruthy()
fireEvent.pointerDown(screen.getByText('row'))
expect(screen.queryByText('card body')).toBeNull()
// The pending timer is also cleared: no reopen after the dwell.
act(() => { vi.advanceTimersByTime(1000) })
expect(screen.queryByText('card body')).toBeNull()
})
it('disabled suppresses opening entirely', () => {
const { wrapper } = mount({ disabled: true })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(1000) })
expect(screen.queryByText('card body')).toBeNull()
})
it('flipping disabled true closes an open card', () => {
const { view, wrapper } = mount()
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getByText('card body')).toBeTruthy()
view.rerender(<HoverCard anchor={<span>row</span>} content={<div>card body</div>} disabled />)
expect(screen.queryByText('card body')).toBeNull()
})
it('corrects the bottom-edge clamp once the mounted card height is measurable', () => {
// First placement reads height 0 (card not yet mounted) and keeps the
// anchor top; the post-mount correction re-clamps with the real height.
window.innerHeight = 300
const offsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetHeight')!
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', { configurable: true, get: () => 120 })
try {
const { wrapper } = mount()
stubAnchorRect(screen.getByText('row'), { top: 280, right: 200 })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
const card = screen.getByText('card body').parentElement as HTMLElement
// 300 - 120 - 8 = 172, instead of the anchor top 280.
expect(card.style.top).toBe('172px')
} finally {
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', offsetHeight)
}
})
it('clamps inside placement itself when the card is already measured (resize path)', () => {
window.innerHeight = 300
const { wrapper } = mount()
stubAnchorRect(screen.getByText('row'), { top: 280, right: 200 })
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
const card = screen.getByText('card body').parentElement as HTMLElement
Object.defineProperty(card, 'offsetHeight', { value: 120 })
act(() => { fireEvent.resize(window) })
expect(card.style.top).toBe('172px')
})
it('repositions on capture-phase scroll while open and stops listening after close', () => {
const { wrapper } = mount()
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
stubAnchorRect(screen.getByText('row'), { top: 90, right: 300 })
act(() => { fireEvent.scroll(document) })
const card = screen.getByText('card body').parentElement as HTMLElement
expect(card.style.left).toBe('308px')
expect(card.style.top).toBe('90px')
fireEvent.pointerLeave(wrapper)
expect(screen.queryByText('card body')).toBeNull()
})
it('unmount clears a pending open timer', () => {
const { view, wrapper } = mount()
fireEvent.pointerEnter(wrapper)
view.unmount()
act(() => { vi.advanceTimersByTime(1000) })
expect(screen.queryByText('card body')).toBeNull()
})
})

View File

@@ -1,143 +0,0 @@
/**
* Sidebar tree row components (figma Cell set 14:3080): pure presentational —
* all data and callbacks arrive via props. Hover swaps (folder->chevron,
* time->ellipsis, action buttons) are CSS-only.
*/
import clsx from 'clsx'
import {
IconFolderClose16, IconFolderOpen16, IconPlusOutline16,
IconTriangleRightFill14, StateDot,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { GroupNode, SessionNode } from './tree.ts'
import { formatRelativeTime } from './tree.ts'
import css from './Rows.module.css'
/** Indent step per tree level: one 16px slot (figma session cell). */
const INDENT_STEP = 16
/**
* Project (workspace) header row: 54px, folder + title + session count;
* hover reveals the chevron and create button. `containsCurrent` arrives on
* the node (derivation fact, no renderer scan).
* @param props.group - derived group node.
* @param props.onToggle - expand/collapse the group.
* @param props.onCreate - start a frontend Session inside this Workspace.
* @returns the row element.
*/
export function ProjectRowItem({ group, onToggle, onCreate }: {
group: GroupNode
onToggle: () => void
onCreate: () => void
}) {
const row = group
const active = group.expanded && group.containsCurrent
const count = `${row.sessionCount} ${row.sessionCount === 1 ? 'session' : 'sessions'}`
return (
<div className={css.projectRow} role="treeitem" aria-expanded={row.expanded} onClick={onToggle}>
<span className={clsx(css.slot, css.folder, active && css.folderActive)}>
{row.expanded ? <IconFolderOpen16 /> : <IconFolderClose16 />}
</span>
<span className={clsx(css.slot, css.chevron)}>
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
</span>
<span className={css.projectText}>
<span className={css.title}>{row.label}</span>
<span className={css.meta}>{count}</span>
</span>
<span className={css.rowActions}>
<button
type="button"
className={css.iconButton}
aria-label={`New session in ${row.label}`}
onClick={(e) => { e.stopPropagation(); onCreate() }}
>
<IconPlusOutline16 />
</button>
</span>
</div>
)
}
/**
* The selected "New session" row for a frontend Session Intent targeted to a
* real Workspace. The row disappears when the Intent is replaced or connects.
* @returns the placeholder row element.
*/
export function IntentRowItem() {
return (
<div className={clsx(css.sessionRow, css.selected)} role="treeitem" aria-selected style={{ paddingLeft: 8 }}>
<span className={css.slot} />
<span className={css.slot} />
<span className={css.title}>New session</span>
</div>
)
}
/**
* One session subtree: the node's own 34px row (indent by depth, expand
* twist when it has children, running dot, relative time) plus its visible
* children, recursively — the component tree mirrors the derived tree.
* @param props.node - derived session node.
* @param props.depth - 0 = directly under the group header.
* @param props.currentId - selected session id (row highlight).
* @param props.now - epoch ms for relative-time formatting.
* @param props.onOpen - open a session by id.
* @param props.onToggle - unfold/fold a subtree by id.
* @returns the node's row followed by its children.
*/
export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle }: {
node: SessionNode
depth: number
currentId: string | undefined
now: number
onOpen: (id: SessionNode['id']) => void
onToggle: (id: SessionNode['id']) => void
}) {
const row = node
const selected = node.id === currentId
// Rail (figma session cell: pad 8, twist slot 16, status slot 16, gap 4 to
// the title): both slots are always reserved so titles align whether or not
// the twist/dot is lit. Extra depth rides the left padding.
const ownRow = (
<div
className={clsx(css.sessionRow, selected && css.selected)}
role="treeitem"
aria-selected={selected}
{...(row.hasChildren ? { 'aria-expanded': row.expanded } : {})}
style={{ paddingLeft: 8 + depth * INDENT_STEP }}
onClick={() => { onOpen(node.id) }}
>
{row.hasChildren
? (
<button
type="button"
className={css.twist}
aria-label={row.expanded ? 'Collapse' : 'Expand'}
onClick={(e) => { e.stopPropagation(); onToggle(node.id) }}
>
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
</button>
)
: <span className={css.slot} />}
<span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span>
<span className={css.title}>{row.title}</span>
<span className={css.time}>{formatRelativeTime(row.updatedAt, now)}</span>
</div>
)
return (
<>
{ownRow}
{node.children.map(child => (
<SessionNodeItem
key={child.id}
node={child}
depth={depth + 1}
currentId={currentId}
now={now}
onOpen={onOpen}
onToggle={onToggle}
/>
))}
</>
)
}

View File

@@ -48,7 +48,6 @@
refresh straight into the collapsed state renders statically. */
.railIn .iconButton,
.railIn .newSession,
.railIn .searchButton,
.railIn .foot {
animation: rail-in 150ms var(--ds-ease-in-out) 100ms backwards;
}
@@ -184,133 +183,9 @@
max-width: 0;
}
/* Section header: 36px, "WorkSpace" label + group-by / new-workspace buttons;
the right-anchored new-workspace button is the row's rail survivor. */
.sectionHeader {
flex: none;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
height: 36px;
padding-left: 12px;
margin-bottom: 4px;
box-sizing: border-box;
border-radius: 12px;
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
}
.collapsed .sectionHeader {
height: 36px;
padding-left: 0;
margin-bottom: 12px;
}
.sectionLabel {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
line-height: 20px;
}
/* Search input: 38px capsule (figma 133:7649); collapsed it renders as the
rail's search control. Upstream binds a dedicated design-system variable (light
#F1F3F5 / dark #1B1B1C) matching no shipped alias — a component token
pinned to the static scale mirrors it (ruled compliant: indirect via
custom property, upstream-variable equivalent). */
.search {
--dsh-search-input-fill: var(--dsw-static-neutral-bluish-75);
flex: none;
display: flex;
align-items: center;
gap: 8px;
height: 38px;
margin: 0 2px 12px; /* bottom: former listArea gap 4 + own 8 (spec padB12 to the first cell) */
padding: 0 14px;
box-sizing: border-box;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 24px;
background: var(--dsh-search-input-fill);
color: var(--dsw-alias-label-caption);
overflow: hidden;
}
:global(body[data-ds-dark-theme]) .search {
--dsh-search-input-fill: var(--dsw-static-neutral-bluish-900);
}
.collapsed .search {
height: 36px;
padding: 0;
margin: 0 0 12px;
gap: 0;
border-color: transparent;
background: transparent;
}
/* The capsule's leading icon, upgraded to the rail's search control. While
expanded it is decorative: pointer-events off so clicks reach the label
(native input focus); collapsed it becomes the hit target. */
.searchButton {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 50%;
padding: 0;
background: transparent;
pointer-events: none;
color: inherit;
}
.collapsed .searchButton {
width: 36px;
height: 36px;
pointer-events: auto;
cursor: pointer;
color: var(--dsw-alias-label-primary);
}
.collapsed .searchButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
.searchInput {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
font-size: 14px;
line-height: 20px;
color: var(--dsw-alias-label-primary);
}
.searchInput::placeholder {
color: var(--dsw-alias-label-tertiary);
}
.clearButton {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 50%;
padding: 0;
background: transparent;
cursor: pointer;
color: var(--dsw-alias-label-secondary);
}
/* Tree seat: always mounted so the foot never moves; the tree content inside
is wide-only and clips while the column squeezes. */
.listArea {
/* Region seat: always mounted so the foot never moves; the browser inside
handles its own wide/rail content. */
.regionArea {
flex: 1;
min-height: 0;
display: flex;
@@ -318,60 +193,6 @@
overflow: hidden;
}
/* Relative for the bottom fade overlay. */
.treeBody {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
position: relative;
}
/* Bottom fade (figma 133:7666): 72px overlay pinned to the visible bottom,
transparent -> sidebar fill so it tracks the theme. */
.fade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 72px;
background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill));
pointer-events: none;
}
/* Tree list: the only scrolling region. Block, not a flex column: as flex
items the 54/34 rows would shrink under content overflow (scrollHeight
collapses onto clientHeight and wheel scrolling dies); block children keep
their design heights and the 4px rhythm rides margins instead of gap. */
.list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-bottom: 12px;
}
/* One workspace section: header row + expanded session run. Rows inside
keep the former flat-list 4px gap as sibling margins; the inter-group
breathing room (figma 133:7661 batch separator, 20px after an expanded
run) rides the NEXT section's top margin so the last group adds none. */
.groupSection > * + * {
margin-top: 4px;
}
.groupSection + .groupSection {
margin-top: 4px;
}
.groupSection:has([aria-expanded='true']) + .groupSection {
margin-top: 20px;
}
.empty {
padding: 16px 12px;
color: var(--dsw-alias-label-tertiary);
font-size: 13px;
}
/* Foot: settings entry (figma 133:7668, 49 hug): the former 18/10 vertical
margins fold into the row so the hover pill spans the full 49px. */
.foot {
@@ -418,7 +239,6 @@
.fading > *,
.railIn .iconButton,
.railIn .newSession,
.railIn .searchButton,
.railIn .foot {
transition: none;
animation: none;

View File

@@ -1,170 +1,38 @@
/**
* Collapse is a slide plus crossfade: content freezes at its expanded
* width (inline style) and fades out in place while the sliding column
* (AppFrame grid tracks) clips it — nothing reflows mid-slide. At settle
* the wide-only content (brand, labels, input, tree) unmounts, dropping
* the sessions subscription, and the control rows snap to the 56px rail
* (one icon each, same top-down order) fading in as the slide ends. Rail
* search expands and focuses the search box.
* Sidebar shell: column geometry only. Collapse is a slide plus crossfade:
* content freezes at its expanded width (inline style) and fades out in place
* while the sliding column (AppFrame grid tracks) clips it — nothing reflows
* mid-slide. At settle the wide-only content unmounts and the control rows
* snap to the 56px rail (one icon each, same top-down order) fading in as the
* slide ends. The workspace/session browsing region between the New Session
* button and the foot is the `sidebar.workspaces` registrant's; the shell
* hands it the wide flag and an expand request callback.
*/
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import {
BrandWordmark, FishLogo,
IconCloseFill14, IconNewChatOutline16, IconPanelLeftOutline16, IconPersonalizationOutline16,
IconProjectAddOutline16, IconSearchOutline16, IconSettingsOutline14,
Menu, Tooltip,
IconNewChatOutline16, IconPanelLeftOutline16, IconSettingsOutline14,
Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
import type { SidebarRootComponentProps } from './contract/slots.ts'
import { deriveGroups, UNGROUPED_KEY } from './tree.ts'
import { IntentRowItem, ProjectRowItem, SessionNodeItem } from './Rows.tsx'
import css from './SidebarRoot.module.css'
/** Wide-content unmount delay; matches the 150ms wide-content fade-out. */
const COLLAPSE_SETTLE_MS = 150
/** Column slide length (--ds-transition-duration-slow): rail-search focus waits it out — focus() forces a synchronous layout and would jank the slide. */
const EXPAND_SLIDE_MS = 300
const GROUP_BY_ITEMS = [
{ id: 'workspace', label: 'Workspace' },
// Only workspace grouping is implemented.
{ id: 'update', label: 'Update', disabled: true },
{ id: 'status', label: 'Status', disabled: true },
]
/** Immutable membership toggle for the local expansion arrays. */
function toggled(list: readonly string[], key: string): string[] {
return list.includes(key) ? list.filter((k) => k !== key) : [...list, key]
}
/** Group-by strategy menu; own open state so it resets with the wide chrome. */
function GroupByMenu() {
const [open, setOpen] = useState(false)
return (
<Menu
open={open}
onClose={() => { setOpen(false) }}
items={GROUP_BY_ITEMS}
selectedId="workspace"
onSelect={() => { setOpen(false) }}
align="end"
anchor={(
<button
type="button"
className={clsx(css.iconButton, css.wide)}
aria-label="Group by"
onClick={() => { setOpen((v) => !v) }}
>
<IconPersonalizationOutline16 />
</button>
)}
/>
)
}
type SessionTreeProps = Pick<
SidebarRootComponentProps,
'useSessions' | 'startSession' | 'open'
> & {
workspaces: readonly WorkspaceView[]
/** Live search filter owned by the root (the query outlives the tree). */
query: string
}
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
function SessionTree({ useSessions, startSession, open, workspaces, query }: SessionTreeProps) {
const list = useSessions((s) => s)
const current = list.current
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
const [expandedSessions, setExpandedSessions] = useState<string[]>([])
// Re-expand when publication moves the selected intent into a real Workspace.
const intent = list.intent
const intentWorkspaceId = intent?.target.kind === 'workspace'
? intent.target.workspaceId
: undefined
const currentGroup = current === undefined
? undefined
: intent?.sessionId === current
? intentWorkspaceId
: (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined)
?? UNGROUPED_KEY
useEffect(() => {
if (current === undefined || currentGroup === undefined) return
setExpandedProjects((l) => (l.includes(currentGroup) ? l : [...l, currentGroup]))
}, [current, currentGroup])
const groups = useMemo(
() => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }),
[list, workspaces, expandedProjects, expandedSessions, query],
)
const now = Date.now()
return (
<div className={clsx(css.treeBody, css.wide)}>
<div className={css.list} role="tree" aria-label="Sessions">
{groups.length === 0 && (
<div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div>
)}
{groups.map(group => (
// Group section: header row + expanded session subtree. The
// inter-group breathing room (former flat-list batch separator)
// is the section's own margin (SidebarRoot.module.css).
<div key={group.key} className={css.groupSection}>
<ProjectRowItem
group={group}
onToggle={() => { setExpandedProjects((l) => toggled(l, group.key)) }}
onCreate={() => {
if (group.workspaceId !== undefined) startSession(group.workspaceId)
}}
/>
{group.intentHere && <IntentRowItem />}
{group.sessions.map(node => (
<SessionNodeItem
key={node.id}
node={node}
depth={0}
currentId={current}
now={now}
onOpen={open}
onToggle={(id) => { setExpandedSessions((l) => toggled(l, id)) }}
/>
))}
</div>
))}
</div>
<span className={css.fade} />
</div>
)
}
/**
* Render the sidebar column.
* Render the sidebar column shell.
* @param props - composed slot props (runtime share + injected callbacks, contract/slots.ts).
* @returns the sidebar element tree.
*/
export function SidebarRoot({
collapsed,
width,
useSessions,
useWorkspaces,
startSession,
open,
toggleSidebar,
renderSlot,
}: SidebarRootComponentProps) {
const workspaces = useWorkspaces(state => state.items)
// The query outlives the tree and the input (both wide-only) so collapsing
// does not silently drop an in-progress filter.
const [query, setQuery] = useState('')
const searchInput = useRef<HTMLInputElement | null>(null)
// Section-header opens the workspace picker (same popover in wide and
// rail states; the hole sits beside the button and opens rightward).
const [wsPickerOpen, setWsPickerOpen] = useState(false)
// Placement anchor for the picker popover: the slot span renders elsewhere
// in the DOM, so the picker positions off this button's rect.
const wsPlusRef = useRef<HTMLButtonElement>(null)
// Wide content stays mounted while the collapse animates (fading via
// .collapsed .wide), unmounts at settle, and remounts right away on expand.
const [settled, setSettled] = useState(collapsed)
@@ -186,19 +54,6 @@ export function SidebarRoot({
const everWide = useRef(!collapsed)
if (!collapsed) everWide.current = true
// Rail search = expand + land in the search box: the flag arms before the
// expand toggle; once expanded the input is mounted and takes focus.
const [searchOnExpand, setSearchOnExpand] = useState(false)
useEffect(() => {
if (!collapsed && searchOnExpand) {
const timer = window.setTimeout(() => {
searchInput.current?.focus({ preventScroll: true })
setSearchOnExpand(false)
}, EXPAND_SLIDE_MS)
return () => { window.clearTimeout(timer) }
}
}, [collapsed, searchOnExpand])
return (
<div
className={clsx(css.root, !wide && css.collapsed, !wide && everWide.current && css.railIn, collapsed && wide && css.fading)}
@@ -238,82 +93,15 @@ export function SidebarRoot({
</button>
</Tooltip>
<div className={css.sectionHeader}>
{wide && <span className={clsx(css.sectionLabel, css.wide)}>Workspaces</span>}
{wide && <GroupByMenu />}
<Tooltip label="New Workspace" disabled={wide}>
<button
ref={wsPlusRef}
type="button"
className={css.iconButton}
aria-label="Create workspace"
onClick={() => { setWsPickerOpen(v => !v) }}
>
<IconProjectAddOutline16 size={wide ? 16 : 18} />
</button>
</Tooltip>
{/* Picker hole beside the (same site in wide and rail states). */}
{renderSlot('sidebar.workspace', {
open: wsPickerOpen,
anchorRef: wsPlusRef,
onPick: (workspaceId) => {
setWsPickerOpen(false)
startSession(workspaceId)
},
onClose: () => { setWsPickerOpen(false) },
{/* The browsing region fills the column between the controls and the
foot in both states; its rail icon column rides the same slot. */}
<div className={css.regionArea}>
{renderSlot('sidebar.workspaces', {
wide,
expandSidebar: () => { if (collapsed) toggleSidebar() },
})}
</div>
{/* Expanded: the row is a click-to-focus field (the leading icon is
decorative). Collapsed: the icon is the rail's search control. */}
<div className={css.search} onClick={() => { if (!collapsed) searchInput.current?.focus() }}>
<Tooltip label="Search" disabled={wide}>
<button
type="button"
className={css.searchButton}
aria-label="Search sessions"
tabIndex={collapsed ? 0 : -1}
onClick={() => { if (collapsed) { setSearchOnExpand(true); toggleSidebar() } }}
>
<IconSearchOutline16 size={wide ? 14 : 18} />
</button>
</Tooltip>
{wide && (
<input
ref={searchInput}
className={clsx(css.searchInput, css.wide)}
type="text"
placeholder="Search name, keywords..."
value={query}
onChange={(e) => { setQuery(e.target.value) }}
/>
)}
{wide && query !== '' && (
<button
type="button"
className={clsx(css.clearButton, css.wide)}
aria-label="Clear search"
onClick={() => { setQuery('') }}
>
<IconCloseFill14 />
</button>
)}
</div>
{/* Always-mounted seat: its flex slot pins the foot to the bottom in
both states while the tree itself is wide-only. */}
<div className={css.listArea}>
{wide && (
<SessionTree
useSessions={useSessions}
workspaces={workspaces}
startSession={startSession}
open={open}
query={query}
/>
)}
</div>
<div className={css.foot} role="button" tabIndex={0} aria-label="Settings">
<IconSettingsOutline14 size={wide ? 14 : 18} />
{wide && <span className={clsx(css.footLabel, css.wide)}>Settings</span>}

View File

@@ -1,69 +1,54 @@
/**
* Sidebar slot contract: the registrant-side props composition for the
* layout-owned `sidebar` slot and the Workspace picker hole declared here.
* The runtime share combines layout-owned page state and actions with the
* global useSessions and useWorkspaces hooks; the injected share adds the
* runtime navigation actions and sidebar toggle.
* layout-owned `sidebar` slot, plus the workspace-browser hole this shell
* declares. The shell owns column geometry (fold state machine, brand row,
* New Session, Settings); everything between the section header and the list
* bottom is the `sidebar.workspaces` registrant's (ui-workspace).
*/
import type { RefObject } from 'react'
import type { PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pulls ui-layout's SlotMap merge (the 'sidebar' entry) into every
// program that sees this contract, so PropsRuntime<'sidebar'> resolves.
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
declare module '@deepseek-ai/dsh-client-ui-slots' {
interface SlotMap {
/**
* The workspace picker hole in the sidebar section header (anchored at
* the button). Declared by this package's 'sidebar' entry (declaring
* is claiming); ui-workspace registers the picker.
* The workspace/session browsing region: section header, search, the
* grouped/flat session list, and every workspace dialog. Declared by this
* package's 'sidebar' entry (declaring is claiming); ui-workspace
* registers the browser.
*/
'sidebar.workspace': { kind: 'single'; scope: 'root'; owner: SidebarWorkspaceOwnerProps }
'sidebar.workspaces': { kind: 'single'; scope: 'root'; owner: SidebarSectionOwnerProps }
}
}
/**
* Owner share of the sidebar workspace hole: popover geometry plus the
* sidebar's pick semantics. The picked Host Workspace is already real; the
* callback starts a frontend Session Intent targeted to it.
* Owner share of the browser hole — the only facts crossing the shell/region
* seam. Business data and actions arrive through the region's own inject.
*/
export interface SidebarWorkspaceOwnerProps {
/** Popover visibility ( button toggle state, host-local). */
open: boolean
/**
* The button element — the popover's placement anchor. The picker's
* slot span renders elsewhere in the DOM, so without this the menu
* positions off the zero-size placement span (order-dependent). Optional
* only until the host passes it; absent falls back to in-place placement.
*/
anchorRef?: RefObject<HTMLElement>
/** Start a frontend Session in a selected or newly created real Workspace. */
onPick: (workspaceId: WorkspaceId) => void
/** Close the popover (outside click / Escape / post-pick). */
onClose: () => void
export interface SidebarSectionOwnerProps {
/** Shell fold-state output: wide renders the full browser, rail the icon column. */
wide: boolean
/** Rail icons request expansion; the browser rides the wide flip for focus. */
expandSidebar: () => void
}
/**
* Registrant-private injected share (arrives via the register inject
* factory). Host Workspace and Session data use the global framework hooks;
* navigation and panel actions are plain callbacks, and viewing state remains
* component-local. A type alias supplies the implicit index signature required
* by the registry.
* factory). The shell keeps only its own controls: starting a Session from
* the New Session button and toggling the column.
*/
export type SidebarRootInjected = {
/** Start or replace the current frontend Session Intent. */
startSession: (workspaceId?: WorkspaceId, prompt?: string) => void
/** Open a real Session. */
open: (sessionId: SessionId) => void
/** Toggle the sidebar column through the layout service. */
toggleSidebar: () => void
}
/**
* Full component props: layout owner state/actions plus global useSessions
* and useWorkspaces, the declared Workspace picker render share, and this
* package's injected callback. No store is registered.
* Full component props: layout owner state/actions plus the browser hole's
* render share and this package's injected callbacks. No store is registered.
*/
export type SidebarRootComponentProps =
PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspace'> & SidebarRootInjected
PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspaces'> & SidebarRootInjected

View File

@@ -1,28 +1,27 @@
/** Registers the sidebar UI into the layout-owned slot. */
/** Registers the sidebar shell into the layout-owned slot. */
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { SidebarRootInjected } from './contract/slots.ts'
import { SidebarRoot } from './SidebarRoot.tsx'
export type { SidebarRootComponentProps, SidebarRootInjected, SidebarWorkspaceOwnerProps } from './contract/slots.ts'
export type { SidebarRootComponentProps, SidebarRootInjected, SidebarSectionOwnerProps } from './contract/slots.ts'
/** Services required by the sidebar plugin. */
export const inject = ['slots', 'layout', 'sessions', 'workspaces']
export const inject = ['slots', 'layout', 'workspaces']
/** Registers the sidebar component and its service callbacks.
/** Registers the sidebar shell and its service callbacks.
* @param ctx - Client root context.
*/
export function apply(ctx: ClientContext): void {
const injectProps = (): SidebarRootInjected => ({
startSession: (workspaceId, prompt) => { ctx.workspaces.startSession(workspaceId, prompt) },
open: (sessionId) => { ctx.sessions.open(sessionId) },
toggleSidebar: () => { ctx.layout.toggleSidebar() },
})
ctx.effect(
() => ctx.slots.register({
name: 'sidebar',
// SidebarRoot owns this picker site; ui-workspace registers the shared
// picker that selects a Host Workspace for a frontend Session Intent.
children: { 'sidebar.workspace': { kind: 'single', scope: 'root' } },
// The shell owns geometry; ui-workspace registers the whole browsing
// region (header, search, session list, workspace dialogs) here.
children: { 'sidebar.workspaces': { kind: 'single', scope: 'root' } },
inject: injectProps,
}, SidebarRoot),
'ui-sidebar: slot registration',

View File

@@ -1,4 +1,4 @@
/** Sidebar slot registration and its plain runtime/layout callbacks. */
/** Sidebar shell slot registration and its plain runtime/layout callbacks. */
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
@@ -9,10 +9,8 @@ async function bench(declare = true) {
const ctx = new Context()
await ctx.plugin(SlotsService).await()
const layout = { toggleSidebar: vi.fn() }
const sessions = { open: vi.fn() }
const workspaces = { startSession: vi.fn() }
ctx.provide('layout', layout)
ctx.provide('sessions', sessions as never)
ctx.provide('workspaces', workspaces as never)
const slots = ctx.get('slots') as SlotsService
if (declare) {
@@ -21,25 +19,23 @@ async function bench(declare = true) {
() => null,
)
}
return { ctx, slots, layout, sessions, workspaces }
return { ctx, slots, layout, workspaces }
}
describe('ui-sidebar apply', () => {
it('declares only the services it uses', () => {
expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces'])
expect(inject).toEqual(['slots', 'layout', 'workspaces'])
})
it('registers the sidebar and declares its Workspace picker hole', async () => {
it('registers the shell and declares the browsing-region hole', async () => {
const b = await bench()
await b.ctx.plugin({ inject: [...inject], apply }).await()
expect(b.slots.entries('sidebar')).toHaveLength(1)
expect(b.slots.spec('sidebar.workspace')).toEqual({ kind: 'single', scope: 'root' })
expect(b.slots.spec('sidebar.workspaces')).toEqual({ kind: 'single', scope: 'root' })
const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)()
expect(Object.keys(injected)).toEqual(['startSession', 'open', 'toggleSidebar'])
expect(Object.keys(injected)).toEqual(['startSession', 'toggleSidebar'])
injected.startSession('workspace' as never, 'prompt')
expect(b.workspaces.startSession).toHaveBeenCalledWith('workspace', 'prompt')
injected.open('session' as never)
expect(b.sessions.open).toHaveBeenCalledWith('session')
injected.toggleSidebar()
expect(b.layout.toggleSidebar).toHaveBeenCalledOnce()
})
@@ -55,6 +51,6 @@ describe('ui-sidebar apply', () => {
await fiber.await()
await fiber.dispose()
expect(b.slots.entries('sidebar')).toHaveLength(0)
expect(b.slots.spec('sidebar.workspace')).toBeUndefined()
expect(b.slots.spec('sidebar.workspaces')).toBeUndefined()
})
})

View File

@@ -1,76 +0,0 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import { IntentRowItem, ProjectRowItem, SessionNodeItem } from '../src/client/Rows.tsx'
import type { GroupNode, SessionNode } from '../src/client/tree.ts'
afterEach(cleanup)
const sid = (id: string) => id as SessionId
const wid = (id: string) => id as WorkspaceId
describe('sidebar rows', () => {
it('renders an active Workspace and keeps its create action separate from toggling', () => {
const onToggle = vi.fn()
const onCreate = vi.fn()
const group: GroupNode = {
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project',
sessionCount: 1, expanded: true, containsCurrent: true, intentHere: false, sessions: [],
}
render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={onCreate} />)
expect(screen.getByText('1 session')).toBeTruthy()
expect(screen.getByRole('treeitem').getAttribute('aria-expanded')).toBe('true')
fireEvent.click(screen.getByRole('button', { name: 'New session in Project' }))
expect(onCreate).toHaveBeenCalledOnce()
expect(onToggle).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('Project'))
expect(onToggle).toHaveBeenCalledOnce()
})
it('renders the frontend Intent placeholder as selected', () => {
render(<IntentRowItem />)
expect(screen.getByRole('treeitem').getAttribute('aria-selected')).toBe('true')
})
it('renders and operates selected, running, recursive Session nodes', () => {
const child: SessionNode = {
id: sid('child'), title: 'Child', children: [], hasChildren: false,
expanded: false, running: false, updatedAt: 0,
}
const parent: SessionNode = {
id: sid('parent'), title: 'Parent', children: [child], hasChildren: true,
expanded: true, running: true, updatedAt: 0,
}
const onOpen = vi.fn()
const onToggle = vi.fn()
const view = render(
<SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen} onToggle={onToggle} />,
)
const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')!
const childRow = screen.getByText('Child').closest('[role="treeitem"]')!
expect(parentRow.getAttribute('aria-selected')).toBe('true')
expect(parentRow.getAttribute('aria-expanded')).toBe('true')
expect(childRow.getAttribute('aria-selected')).toBe('false')
expect(childRow.hasAttribute('aria-expanded')).toBe(false)
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
expect(onToggle).toHaveBeenCalledWith(parent.id)
expect(onOpen).not.toHaveBeenCalled()
fireEvent.click(parentRow)
fireEvent.click(childRow)
expect(onOpen.mock.calls).toEqual([[parent.id], [child.id]])
view.rerender(
<SessionNodeItem
node={{ ...parent, children: [], expanded: false, running: false }}
depth={1} currentId={undefined} now={0} onOpen={onOpen} onToggle={onToggle}
/>,
)
expect(screen.getByRole('button', { name: 'Expand' })).toBeTruthy()
expect(screen.getByRole('treeitem').getAttribute('aria-selected')).toBe('false')
expect(screen.getByRole('treeitem').style.paddingLeft).toBe('24px')
})
})

View File

@@ -1,79 +1,42 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import type {
SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { SidebarRootComponentProps } from '../src/client/contract/slots.ts'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { SidebarRootComponentProps, SidebarSectionOwnerProps } from '../src/client/contract/slots.ts'
import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
afterEach(() => {
cleanup()
vi.useRealTimers()
})
const sid = (id: string) => id as SessionId
const wid = (id: string) => id as WorkspaceId
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
const workspace: WorkspaceView = {
workspaceId: wid('project'), path: '/projects/project', title: 'Project', sessionIds: [sid('s1')],
createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
}
const sessions: SessionListState = {
ids: [sid('s1')],
byId: { [sid('s1')]: { id: sid('s1'), displayTitle: 'First session', running: false, updatedAt: 1 } },
current: undefined, phase: 'ready',
intent: undefined,
}
const workspaces: WorkspaceListState = {
items: [workspace], state: 'idle', phase: 'ready', error: null,
intent: undefined, baselinesReady: true, recentWorkspaceId: workspace.workspaceId,
}
function mount(sessionState: SessionListState = sessions) {
const startSession = vi.fn()
const open = vi.fn()
let pickerOwner: unknown
const view = render(
<SidebarRoot
collapsed={false} width={300}
useSessions={hook(sessionState)} useWorkspaces={hook(workspaces)}
startSession={startSession} open={open} toggleSidebar={vi.fn()}
renderSlot={((_key: string, owner: unknown) => { pickerOwner = owner; return null }) as SidebarRootComponentProps['renderSlot']}
/>,
)
return { view, startSession, open, pickerOwner: () => pickerOwner }
}
// The shell never reads the global hooks itself, but they ride the standard
// props share; stub them as never-called functions.
const neverHook = (() => { throw new Error('shell must not read global hooks') }) as never
function mountSidebar({
sessionState = sessions,
workspaceState = workspaces,
collapsed = false,
width = 300,
}: {
sessionState?: SessionListState
workspaceState?: WorkspaceListState
collapsed?: boolean
width?: number
} = {}) {
function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; width?: number } = {}) {
const startSession = vi.fn()
const open = vi.fn()
const toggleSidebar = vi.fn()
let pickerOwner: unknown
let current = { sessionState, workspaceState, collapsed, width }
let regionOwner: SidebarSectionOwnerProps | undefined
let current = { collapsed, width }
const root = () => (
<SidebarRoot
collapsed={current.collapsed} width={current.width}
useSessions={hook(current.sessionState)} useWorkspaces={hook(current.workspaceState)}
startSession={startSession} open={open} toggleSidebar={toggleSidebar}
renderSlot={((_key: string, owner: unknown) => { pickerOwner = owner; return null }) as SidebarRootComponentProps['renderSlot']}
useSessions={neverHook} useWorkspaces={neverHook}
startSession={startSession} toggleSidebar={toggleSidebar}
renderSlot={((_key: string, owner: SidebarSectionOwnerProps) => {
regionOwner = owner
return <div data-testid="region" data-wide={owner.wide} />
}) as SidebarRootComponentProps['renderSlot']}
/>
)
const view = render(root())
return {
startSession,
open,
toggleSidebar,
pickerOwner: () => pickerOwner,
regionOwner: () => {
if (regionOwner === undefined) throw new Error('region owner not rendered')
return regionOwner
},
rerender(next: Partial<typeof current>) {
current = { ...current, ...next }
view.rerender(root())
@@ -81,181 +44,40 @@ function mountSidebar({
}
}
describe('SidebarRoot', () => {
it('renders real Workspaces from useWorkspaces and routes New Session', () => {
const b = mount()
expect(screen.getByText('Project')).toBeTruthy()
describe('SidebarRoot shell', () => {
it('routes New Session and the column toggle', () => {
const b = mountShell()
fireEvent.click(screen.getByRole('button', { name: 'New session' }))
expect(b.startSession).toHaveBeenCalledWith()
})
it('shows a frontend Session under its real Workspace and routes its row plus', () => {
const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: workspace.workspaceId }, prompt: '', phase: 'connecting' as const }
const b = mount({
...sessions,
current: intent.sessionId,
intent,
})
expect(screen.getByText('New session')).toBeTruthy()
expect(screen.getByText('2 sessions')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'New session in Project' }))
expect(b.startSession).toHaveBeenCalledWith(workspace.workspaceId)
})
it('forwards Workspace picker selection and closes the picker', () => {
const b = mount()
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
const owner = b.pickerOwner() as { open: boolean; onPick(id: WorkspaceId): void }
expect(owner.open).toBe(true)
owner.onPick(workspace.workspaceId)
expect(b.startSession).toHaveBeenCalledWith(workspace.workspaceId)
})
it('opens a real Session through the owner action', () => {
const b = mount({ ...sessions, current: sid('intent'), intent: {
sessionId: sid('intent'), target: { kind: 'workspace', workspaceId: workspace.workspaceId }, prompt: '', phase: 'ready',
} })
fireEvent.click(screen.getByText('Project'))
fireEvent.click(screen.getByText('First session'))
expect(b.open).toHaveBeenCalledWith(sid('s1'))
})
it('opens, selects, dismisses, and toggles the group-by menu', () => {
mount()
const button = screen.getByRole('button', { name: 'Group by' })
fireEvent.click(button)
fireEvent.click(screen.getByRole('menuitem', { name: 'Workspace' }))
expect(screen.queryByRole('menu')).toBeNull()
fireEvent.click(button)
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByRole('menu')).toBeNull()
fireEvent.click(button)
fireEvent.click(button)
expect(screen.queryByRole('menu')).toBeNull()
})
it('routes every Workspace picker close path', () => {
const b = mount()
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
const owner = b.pickerOwner() as { open: boolean; onClose(): void }
expect(owner.open).toBe(true)
act(() => { owner.onClose() })
expect((b.pickerOwner() as { open: boolean }).open).toBe(false)
})
it('focuses, filters, and clears search while distinguishing both empty states', () => {
mount()
const input = screen.getByPlaceholderText('Search name, keywords...')
fireEvent.click(input.parentElement!)
expect(document.activeElement).toBe(input)
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
fireEvent.change(input, { target: { value: 'missing' } })
expect(screen.getByText('No matches')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Clear search' }))
expect(screen.queryByText('No matches')).toBeNull()
cleanup()
const emptySessions = listState()
const emptyWorkspaces: WorkspaceListState = { ...workspaces, items: [], recentWorkspaceId: undefined }
mountSidebar({ sessionState: emptySessions, workspaceState: emptyWorkspaces })
expect(screen.getByText('No sessions yet')).toBeTruthy()
})
it('toggles Workspace and nested Session expansion in both directions', () => {
const parent = sid('parent')
const child = sid('child')
const nestedSessions: SessionListState = {
...sessions,
ids: [parent, child],
byId: {
[parent]: { id: parent, displayTitle: 'Parent', running: false, updatedAt: 2 },
[child]: { id: child, displayTitle: 'Child', running: false, updatedAt: 1, parentId: parent },
},
}
const nestedWorkspace: WorkspaceListState = {
...workspaces,
items: [{ ...workspace, sessionIds: [parent, child] }],
}
mountSidebar({ sessionState: nestedSessions, workspaceState: nestedWorkspace })
fireEvent.click(screen.getByText('Project'))
fireEvent.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('Child')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
expect(screen.queryByText('Child')).toBeNull()
fireEvent.click(screen.getByText('Project'))
expect(screen.queryByText('Parent')).toBeNull()
})
it('does not start a Session from an Ungrouped row create action', () => {
const loose = sid('loose')
const looseSessions: SessionListState = {
...listState(),
ids: [loose],
byId: { [loose]: { id: loose, displayTitle: 'Loose', running: false, updatedAt: 1 } },
current: loose,
}
const b = mountSidebar({
sessionState: looseSessions,
workspaceState: { ...workspaces, items: [], recentWorkspaceId: undefined },
})
fireEvent.click(screen.getByRole('button', { name: 'New session in Ungrouped' }))
expect(b.startSession).not.toHaveBeenCalled()
})
it('keeps an already expanded selected Workspace open and resolves later Workspace matches', () => {
const b = mountSidebar()
fireEvent.click(screen.getByText('Project'))
const other = { ...workspace, workspaceId: wid('other'), title: 'Other', sessionIds: [] }
b.rerender({
sessionState: { ...sessions, current: sid('s1') },
workspaceState: { ...workspaces, items: [other, workspace] },
})
expect(screen.getByText('First session')).toBeTruthy()
b.rerender({
sessionState: {
...sessions,
current: sid('draft'),
intent: { sessionId: sid('draft'), target: { kind: 'workspace-intent' }, prompt: '', phase: 'ready' },
},
})
expect(screen.getByText('Project')).toBeTruthy()
})
it('renders the static collapsed rail and expands rail search into focused input', () => {
vi.useFakeTimers()
const b = mountSidebar({ collapsed: true })
expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy()
expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Open sidebar' }))
expect(b.toggleSidebar).toHaveBeenCalledOnce()
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
expect(b.toggleSidebar).toHaveBeenCalledTimes(2)
b.rerender({ collapsed: false })
const input = screen.getByPlaceholderText('Search name, keywords...')
act(() => { vi.advanceTimersByTime(300) })
expect(document.activeElement).toBe(input)
})
it('keeps wide content during live collapse, then settles to the rail', () => {
vi.useFakeTimers()
const b = mountSidebar({ width: 320 })
fireEvent.click(screen.getByRole('button', { name: 'Collapse sidebar' }))
expect(b.toggleSidebar).toHaveBeenCalledOnce()
b.rerender({ collapsed: true, width: 56 })
expect(screen.getByPlaceholderText('Search name, keywords...')).toBeTruthy()
act(() => { vi.advanceTimersByTime(150) })
expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull()
})
it('hands the region its wide flag and clamps expandSidebar to the collapsed state', () => {
const b = mountShell()
expect(b.regionOwner().wide).toBe(true)
// Expanded: the request is a no-op (no accidental collapse).
b.regionOwner().expandSidebar()
expect(b.toggleSidebar).not.toHaveBeenCalled()
})
it('keeps the region mounted through collapse and expands on its request', () => {
vi.useFakeTimers()
const b = mountShell()
b.rerender({ collapsed: true })
// Wide content survives the crossfade window, then settles into the rail.
expect(b.regionOwner().wide).toBe(true)
vi.advanceTimersByTime(200)
b.rerender({})
expect(b.regionOwner().wide).toBe(false)
expect(screen.getByTestId('region')).toBeTruthy()
b.regionOwner().expandSidebar()
expect(b.toggleSidebar).toHaveBeenCalledOnce()
})
it('renders statically collapsed on a cold start (no crossfade classes)', () => {
const b = mountShell({ collapsed: true })
expect(b.regionOwner().wide).toBe(false)
expect(screen.getByRole('button', { name: 'Open sidebar' })).toBeTruthy()
})
})
function listState(): SessionListState {
return { ids: [], byId: {}, current: undefined, phase: 'ready', intent: undefined }
}

View File

@@ -35,6 +35,9 @@
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"dependencies": {
"clsx": "^2.0.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",

View File

@@ -0,0 +1,265 @@
/* Workspace browsing region (fills the sidebar shell's hole): section
header, search capsule, and the scrolling session list. Wide/rail
variants ride the shell's fold state through the `wide` owner prop —
rail state renders only the two 36x36 icon controls. */
.root {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.iconButton {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 50%;
padding: 0;
background: transparent;
cursor: pointer;
color: var(--dsw-alias-label-secondary);
}
.iconButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Section header: 36px, "Workspaces/Sessions" label + group-by /
new-workspace buttons; the right-anchored new-workspace button is the
row's rail survivor. */
.sectionHeader {
flex: none;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
height: 36px;
padding-left: 12px;
margin-bottom: 4px;
box-sizing: border-box;
border-radius: 12px;
overflow: hidden;
color: var(--dsw-alias-label-tertiary);
}
.sectionLabel {
flex: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
line-height: 20px;
}
/* Search input: 38px capsule (figma 133:7649); rail state renders it as the
region's search control. Upstream binds a dedicated design-system variable
(light #F1F3F5 / dark #1B1B1C) matching no shipped alias — a component
token pinned to the static scale mirrors it. */
.search {
--dsh-search-input-fill: var(--dsw-static-neutral-bluish-75);
flex: none;
display: flex;
align-items: center;
gap: 8px;
height: 38px;
margin: 0 2px 12px;
padding: 0 14px;
box-sizing: border-box;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 24px;
background: var(--dsh-search-input-fill);
color: var(--dsw-alias-label-caption);
overflow: hidden;
}
:global(body[data-ds-dark-theme]) .search {
--dsh-search-input-fill: var(--dsw-static-neutral-bluish-900);
}
/* The capsule's leading icon: decorative while wide (pointer-events off so
clicks reach the input), the hit target in rail state. */
.searchButton {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 50%;
padding: 0;
background: transparent;
pointer-events: none;
color: inherit;
}
.searchInput {
flex: 1;
min-width: 0;
border: none;
outline: none;
background: transparent;
font-size: 14px;
line-height: 20px;
color: var(--dsw-alias-label-primary);
}
.searchInput::placeholder {
color: var(--dsw-alias-label-tertiary);
}
.clearButton {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 50%;
padding: 0;
background: transparent;
cursor: pointer;
color: var(--dsw-alias-label-secondary);
}
/* Rail variant (own .rail class from the wide owner prop — the region never
reads the shell's class names): the two icon controls stack as 36x36
circles matching the shell's rail rhythm. */
.rail .sectionHeader {
padding-left: 0;
margin-bottom: 12px;
}
.rail .iconButton {
width: 36px;
height: 36px;
color: var(--dsw-alias-label-primary);
}
.rail .search {
height: 36px;
padding: 0;
margin: 0 0 12px;
gap: 0;
border-color: transparent;
background: transparent;
}
.rail .searchButton {
width: 36px;
height: 36px;
pointer-events: auto;
cursor: pointer;
color: var(--dsw-alias-label-primary);
}
.rail .searchButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
}
/* List seat: always mounted so the shell foot never moves. */
.listArea {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* Relative for the bottom fade overlay. */
.treeBody {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
position: relative;
}
/* Bottom fade (figma 133:7666): 72px overlay pinned to the visible bottom,
transparent -> sidebar fill so it tracks the theme. */
.fade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 72px;
background: linear-gradient(to bottom, transparent, var(--dsw-specific-sidebar-fill));
pointer-events: none;
}
/* Wide-only content fades back in on expand remount (mirrors the shell). */
.wide {
animation: wide-in 200ms var(--ds-ease-in-out);
}
@keyframes wide-in {
from { opacity: 0; }
}
/* List: the only scrolling region. Block, not a flex column: as flex items
the 54/34 rows would shrink under content overflow; block children keep
their design heights and the 4px rhythm rides margins instead of gap. */
.list {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-bottom: 12px;
}
/* One workspace section: header row + expanded session run. Rows inside
keep the former flat-list 4px gap as sibling margins; the inter-group
breathing room (figma 133:7661 batch separator, 20px after an expanded
run) rides the NEXT section's top margin so the last group adds none. */
.groupSection > * + * {
margin-top: 4px;
}
.groupSection + .groupSection {
margin-top: 4px;
}
.groupSection:has([aria-expanded='true']) + .groupSection {
margin-top: 20px;
}
.empty {
padding: 16px 12px;
color: var(--dsw-alias-label-tertiary);
font-size: 13px;
}
/* Rename dialog form (same figma dialog family as the create modals). */
.renameInput {
box-sizing: border-box;
width: 100%;
height: 44px;
padding: 7px 14px;
border: 1px solid var(--dsw-alias-border-l2);
border-radius: 22px;
outline: none;
background: transparent;
font-size: 14px;
font-weight: 400;
line-height: 22px;
color: var(--dsw-alias-label-primary);
}
.renameInput:disabled {
color: var(--dsw-alias-label-dimmed);
}
.renameError {
margin-top: 8px;
font-size: 12px;
line-height: 18px;
color: var(--dsw-alias-state-error-primary);
}
@media (prefers-reduced-motion: reduce) {
.wide {
animation: none;
}
}

View File

@@ -0,0 +1,433 @@
/**
* The workspace/session browsing region filling the sidebar shell's
* `sidebar.workspaces` hole: section header (title + group-by + new
* workspace), search, the grouped tree or flat list, and the workspace
* dialogs. Wide state renders the full browser; rail state renders the two
* region icons (search / new workspace), each requesting shell expansion
* through the owner share. The picker menu and create dialogs live in
* WorkspacePicker (same package — direct composition, no slot between them).
*/
import { useEffect, useMemo, useRef, useState } from 'react'
import clsx from 'clsx'
import {
Button, IconCloseFill14, IconPersonalizationOutline16,
IconProjectAddOutline16, IconSearchOutline16, Menu, Modal, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
import type { WorkspaceBrowserProps } from './contract/slots.ts'
import type { SessionNode } from './tree.ts'
import { deriveFlat, deriveGroups, UNGROUPED_KEY } from './tree.ts'
import { IntentRowItem, ProjectRowItem, SessionNodeItem } from './rows/Rows.tsx'
import { WorkspaceCreateFlow } from './WorkspacePicker.tsx'
import css from './WorkspaceBrowser.module.css'
/** Column slide length (--ds-transition-duration-slow): rail-search focus waits it out — focus() forces a synchronous layout and would jank the slide. */
const EXPAND_SLIDE_MS = 300
const GROUP_BY_ITEMS = [
{ type: 'label' as const, id: 'group-by', text: 'Group by' },
{ id: 'workspace', label: 'WorkSpace' },
{ id: 'flat', label: 'In one list' },
]
/** Immutable membership toggle for the local expansion arrays. */
function toggled(list: readonly string[], key: string): string[] {
return list.includes(key) ? list.filter((k) => k !== key) : [...list, key]
}
/** Group-by strategy menu; own open state so it resets with the wide chrome. */
function GroupByMenu({ groupBy, onPick }: {
groupBy: 'workspace' | 'flat'
onPick: (mode: 'workspace' | 'flat') => void
}) {
const [open, setOpen] = useState(false)
return (
<Menu
open={open}
onClose={() => { setOpen(false) }}
items={GROUP_BY_ITEMS}
selectedId={groupBy}
onSelect={(id) => {
/* v8 ignore next -- narrowing guard: the heading label is not selectable, so the only arriving ids are the two modes. */
if (id === 'workspace' || id === 'flat') onPick(id)
setOpen(false)
}}
align="end"
// Portal: the section header clips overflow, so an in-place list would
// be cut off at the header's bounds.
portal
anchor={(
<button
type="button"
className={clsx(css.iconButton, css.wide)}
aria-label="Group by"
onClick={() => { setOpen((v) => !v) }}
>
<IconPersonalizationOutline16 />
</button>
)}
/>
)
}
/** In-flight root-row drag: source identity plus the current insert marker. */
interface DragState {
workspaceId: WorkspaceId
sessionId: SessionNode['id']
/** Row the marker sits on and which half (insert above/below it). */
over: { id: SessionNode['id']; half: 'before' | 'after' } | null
}
type SessionTreeProps = Pick<
WorkspaceBrowserProps,
'useSessions' | 'startSession' | 'open' | 'insertSessionBefore'
> & {
workspaces: readonly WorkspaceView[]
/** Live search filter owned by the browser root (the query outlives the tree). */
query: string
/** Open the browser-owned rename dialog for a real Workspace group. */
onRenameRequest: (workspaceId: WorkspaceId, currentTitle: string) => void
}
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
function SessionTree({ useSessions, startSession, open, workspaces, query, onRenameRequest, insertSessionBefore }: SessionTreeProps) {
const list = useSessions((s) => s)
const current = list.current
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
const [expandedSessions, setExpandedSessions] = useState<string[]>([])
// Transient drag viewing state (never store-bound; order truth stays Host-side).
const [drag, setDrag] = useState<DragState | null>(null)
// Re-expand when publication moves the selected intent into a real Workspace.
const intent = list.intent
const intentWorkspaceId = intent?.target.kind === 'workspace'
? intent.target.workspaceId
: undefined
const currentGroup = current === undefined
? undefined
: intent?.sessionId === current
? intentWorkspaceId
: (workspaces.find(w => w.sessionIds.includes(current))?.workspaceId as string | undefined)
?? UNGROUPED_KEY
useEffect(() => {
if (current === undefined || currentGroup === undefined) return
setExpandedProjects((l) => (l.includes(currentGroup) ? l : [...l, currentGroup]))
}, [current, currentGroup])
const groups = useMemo(
() => deriveGroups(list, workspaces, { expandedProjects, expandedSessions, query }),
[list, workspaces, expandedProjects, expandedSessions, query],
)
const now = Date.now()
return (
<div className={clsx(css.treeBody, css.wide)}>
<div className={css.list} role="tree" aria-label="Sessions">
{groups.length === 0 && (
<div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div>
)}
{groups.map(group => (
// Group section: header row + expanded session subtree. The
// inter-group breathing room (former flat-list batch separator)
// is the section's own margin (WorkspaceBrowser.module.css).
<div key={group.key} className={css.groupSection}>
<ProjectRowItem
group={group}
onToggle={() => { setExpandedProjects((l) => toggled(l, group.key)) }}
onCreate={() => {
if (group.workspaceId !== undefined) startSession(group.workspaceId)
}}
onRename={group.workspaceId === undefined
? undefined
: () => {
/* v8 ignore next -- narrowing guard: the closure is only created for real-workspace groups. */
if (group.workspaceId !== undefined) onRenameRequest(group.workspaceId, group.label)
}}
/>
{group.expanded && group.intentHere && <IntentRowItem />}
{group.sessions.map((node, index) => {
// Draggable: real-workspace group roots outside search. The drag
// never leaves its group — rows of other groups show no markers
// and reject drops (visual movement confined to this section).
const draggable = group.workspaceId !== undefined && query === ''
const sameGroupDrag = drag !== null && drag.workspaceId === group.workspaceId
const dragProps = !draggable || group.workspaceId === undefined ? undefined : {
start: () => {
setDrag({ workspaceId: group.workspaceId as WorkspaceId, sessionId: node.id, over: null })
},
active: sameGroupDrag,
marker: sameGroupDrag && drag.over?.id === node.id ? drag.over.half : null,
hover: (half: 'before' | 'after') => {
/* v8 ignore next -- narrowing guard: Rows gates hover on `active`, which is false while the drag state is null. */
setDrag(d => (d === null ? d : { ...d, over: { id: node.id, half } }))
},
drop: (half: 'before' | 'after') => {
/* v8 ignore next -- narrowing guard: Rows gates drop on `active`, which is false while the drag state is null. */
if (drag === null) return
const roots = group.sessions
// Anchor = the row the insert line points at ('after' means
// the next root; end-of-list omits the anchor → append).
const anchor = half === 'before' ? node.id : roots[index + 1]?.id
setDrag(null)
if (anchor === drag.sessionId) return
// No-op when the drop lands back on the source position.
const sourceIndex = roots.findIndex(r => r.id === drag.sessionId)
const anchorIndex = anchor === undefined ? roots.length : roots.findIndex(r => r.id === anchor)
if (sourceIndex !== -1 && (anchorIndex === sourceIndex || anchorIndex === sourceIndex + 1)) return
insertSessionBefore(drag.workspaceId, drag.sessionId, anchor).catch((reason: unknown) => {
console.warn('session reorder rejected:', reason)
})
},
end: () => { setDrag(null) },
}
return (
<SessionNodeItem
key={node.id}
node={node}
depth={0}
currentId={current}
now={now}
onOpen={open}
onToggle={(id) => { setExpandedSessions((l) => toggled(l, id)) }}
drag={dragProps}
/>
)
})}
</div>
))}
</div>
<span className={css.fade} />
</div>
)
}
/** The flat "In one list" body: every session a top-level row, newest-first. */
function FlatList({ useSessions, open, query }: Pick<SessionTreeProps, 'useSessions' | 'open' | 'query'>) {
const list = useSessions((s) => s)
const rows = useMemo(() => deriveFlat(list, { query }), [list, query])
const now = Date.now()
// The intent placeholder renders outside search only; it suppresses the
// empty state only while actually rendered (a query hides both).
const intentRow = query === '' && list.intent !== undefined
return (
<div className={clsx(css.treeBody, css.wide)}>
<div className={css.list} role="tree" aria-label="Sessions">
{rows.length === 0 && !intentRow && (
<div className={css.empty}>{query === '' ? 'No sessions yet' : 'No matches'}</div>
)}
{intentRow && <IntentRowItem />}
{rows.map(node => (
<SessionNodeItem
key={node.id}
node={node}
depth={0}
currentId={list.current}
now={now}
onOpen={open}
/* v8 ignore next -- required-prop filler: flat rows render no twist, so it never fires. */
onToggle={() => {}}
flat
/>
))}
</div>
<span className={css.fade} />
</div>
)
}
/**
* Render the browsing region.
* @param props - composed slot props (shell owner share + store + injected actions).
* @returns the region element tree.
*/
export function WorkspaceBrowser({
wide,
expandSidebar,
useSessions,
useWorkspaces,
useStore,
actions,
startSession,
open,
renameWorkspace,
insertSessionBefore,
createWorkspace,
}: WorkspaceBrowserProps) {
const workspaces = useWorkspaces(state => state.items)
const groupBy = useStore(s => s.groupBy)
// The query outlives the tree and the input (both wide-only) so collapsing
// does not silently drop an in-progress filter.
const [query, setQuery] = useState('')
const searchInput = useRef<HTMLInputElement | null>(null)
// Section-header opens the picker menu (same popover in wide and rail
// states; the menu anchors on this button).
const [wsPickerOpen, setWsPickerOpen] = useState(false)
const wsPlusRef = useRef<HTMLButtonElement>(null)
// Rail search = expand + land in the search box: the flag arms before the
// expand request; once the shell flips wide the input mounts and takes focus.
const [searchOnExpand, setSearchOnExpand] = useState(false)
useEffect(() => {
if (wide && searchOnExpand) {
const timer = window.setTimeout(() => {
searchInput.current?.focus({ preventScroll: true })
setSearchOnExpand(false)
}, EXPAND_SLIDE_MS)
return () => { window.clearTimeout(timer) }
}
}, [wide, searchOnExpand])
// Rename dialog (browser-owned so it outlives row unmounts during collapse).
const [renameTarget, setRenameTarget] = useState<{ workspaceId: WorkspaceId; currentTitle: string } | null>(null)
const [renameDraft, setRenameDraft] = useState('')
const [renaming, setRenaming] = useState(false)
const [renameError, setRenameError] = useState<string | null>(null)
const renameTrimmed = renameDraft.trim()
const renameDuplicate = renameTarget !== null && renameTrimmed !== '' && renameTrimmed !== renameTarget.currentTitle
&& workspaces.some(w => w.title === renameTrimmed)
const renameBlocked = renaming || renameTrimmed === ''
|| renameTarget === null || renameTrimmed === renameTarget.currentTitle || renameDuplicate
const closeRename = () => {
if (renaming) return
setRenameTarget(null)
setRenameError(null)
}
const confirmRename = () => {
if (renameBlocked || renameTarget === null) return
setRenaming(true)
setRenameError(null)
renameWorkspace(renameTarget.workspaceId, renameTrimmed).then(() => {
setRenaming(false)
setRenameTarget(null)
}).catch((reason: unknown) => {
setRenaming(false)
setRenameError(reason instanceof Error ? reason.message : String(reason))
})
}
return (
<div className={clsx(css.root, !wide && css.rail)}>
<div className={css.sectionHeader}>
{wide && (
<span className={clsx(css.sectionLabel, css.wide)}>
{groupBy === 'flat' ? 'Sessions' : 'Workspaces'}
</span>
)}
{wide && <GroupByMenu groupBy={groupBy} onPick={(mode) => { actions.setGroupBy(mode) }} />}
<Tooltip label="New Workspace" disabled={wide}>
<button
ref={wsPlusRef}
type="button"
className={css.iconButton}
aria-label="Create workspace"
onClick={() => {
if (!wide) expandSidebar()
setWsPickerOpen(v => !v)
}}
>
<IconProjectAddOutline16 size={wide ? 16 : 18} />
</button>
</Tooltip>
{/* Picker menu + create dialogs (same package — direct composition). */}
<WorkspaceCreateFlow
open={wsPickerOpen}
anchorRef={wsPlusRef}
useWorkspaces={useWorkspaces}
createWorkspace={createWorkspace}
onPick={(workspaceId) => {
setWsPickerOpen(false)
startSession(workspaceId)
}}
onClose={() => { setWsPickerOpen(false) }}
/>
</div>
{/* Expanded: the row is a click-to-focus field (the leading icon is
decorative). Rail: the icon is the region's search control. */}
<div className={css.search} onClick={() => { if (wide) searchInput.current?.focus() }}>
<Tooltip label="Search" disabled={wide}>
<button
type="button"
className={css.searchButton}
aria-label="Search sessions"
tabIndex={wide ? -1 : 0}
onClick={() => { if (!wide) { setSearchOnExpand(true); expandSidebar() } }}
>
<IconSearchOutline16 size={wide ? 14 : 18} />
</button>
</Tooltip>
{wide && (
<input
ref={searchInput}
className={clsx(css.searchInput, css.wide)}
type="text"
placeholder="Search name, keywords..."
value={query}
onChange={(e) => { setQuery(e.target.value) }}
/>
)}
{wide && query !== '' && (
<button
type="button"
className={clsx(css.clearButton, css.wide)}
aria-label="Clear search"
onClick={() => { setQuery('') }}
>
<IconCloseFill14 />
</button>
)}
</div>
{/* Always-mounted seat keeps the region's flex slot while the list
itself is wide-only. */}
<div className={css.listArea}>
{wide && (groupBy === 'flat'
? <FlatList useSessions={useSessions} open={open} query={query} />
: (
<SessionTree
useSessions={useSessions}
workspaces={workspaces}
startSession={startSession}
open={open}
query={query}
insertSessionBefore={insertSessionBefore}
onRenameRequest={(workspaceId, currentTitle) => {
setRenameTarget({ workspaceId, currentTitle })
setRenameDraft(currentTitle)
setRenameError(null)
}}
/>
))}
</div>
<Modal
open={renameTarget !== null}
onClose={closeRename}
title="Rename workspace"
footer={(
<>
<Button variant="outline" disabled={renaming} onClick={closeRename}>Cancel</Button>
<Button variant="primary" disabled={renameBlocked} onClick={confirmRename}>Rename</Button>
</>
)}
>
<input
className={css.renameInput}
value={renameDraft}
aria-label="Workspace name"
autoFocus
disabled={renaming}
onChange={(e) => { setRenameDraft(e.target.value); setRenameError(null) }}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
confirmRename()
}
}}
/>
{renameDuplicate && (
<div className={css.renameError} role="alert">A workspace named {renameTrimmed} already exists.</div>
)}
{renameError !== null && <div className={css.renameError} role="alert">{renameError}</div>}
</Modal>
</div>
)
}

View File

@@ -1,9 +1,15 @@
/** Shared Workspace picker for the sidebar and New Session hero. */
/**
* Workspace pick/create flow. WorkspaceCreateFlow is the reusable core
* (menu + path/create dialogs) consumed directly by WorkspaceBrowser (same
* package) and wrapped by WorkspacePicker for the conversation empty-state
* slot registration.
*/
import type { RefObject } from 'react'
import { useCallback, useState } from 'react'
import {
Button, IconFolderClose16, IconPlusOutline16, Menu, Modal, type MenuEntry,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { WorkspaceId, WorkspaceListState, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
import type { WorkspacePickerProps } from './contract/slots.ts'
import css from './WorkspacePicker.module.css'
@@ -13,14 +19,35 @@ const CREATE_NEW = '::create-new'
type ModalKind = 'path' | 'create' | null
export function WorkspacePicker({
/** Core flow props: the owner supplies popover control and pick semantics. */
export interface WorkspaceCreateFlowProps {
/** Popover visibility (anchor button toggle state, owner-local). */
open: boolean
/** The anchor button element — the popover's placement anchor. */
anchorRef?: RefObject<HTMLElement | null> | undefined
/** Selector hook over the workspace list (framework standard hook). */
useWorkspaces: <S>(selector: (state: WorkspaceListState) => S) => S
/** Create or adopt a real Host Workspace. */
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
/** A real Workspace was picked or created. */
onPick: (workspaceId: WorkspaceId) => void
/** Close the popover (outside click / Escape / post-pick). */
onClose: () => void
}
/**
* Render the pick menu plus the two create dialogs.
* @param props - owner-controlled flow props.
* @returns menu + dialog elements.
*/
export function WorkspaceCreateFlow({
open,
anchorRef,
useWorkspaces,
createWorkspace,
onPick,
onClose,
createWorkspace,
}: WorkspacePickerProps) {
}: WorkspaceCreateFlowProps) {
const workspaceSnapshot = useWorkspaces(state => state)
const workspaces = workspaceSnapshot.items
const getAnchorRect = useCallback(
@@ -194,3 +221,29 @@ export function WorkspacePicker({
</>
)
}
/**
* The conversation empty-state registration: adapts the owner share to the
* core flow (all state and semantics live in the flow / the owner).
* @param props - empty-state slot props (owner share + injected creation callback).
* @returns the flow element.
*/
export function WorkspacePicker({
open,
anchorRef,
useWorkspaces,
onPick,
onClose,
createWorkspace,
}: WorkspacePickerProps) {
return (
<WorkspaceCreateFlow
open={open}
anchorRef={anchorRef}
useWorkspaces={useWorkspaces}
createWorkspace={createWorkspace}
onPick={onPick}
onClose={onClose}
/>
)
}

View File

@@ -1,30 +1,59 @@
/**
* Shared Workspace picker contract for the sidebar and page-local Session Intent hero
* slots. Each runtime share provides its owner's popover controls plus the
* global useWorkspaces hook; this package adds the injected Host Workspace
* creation callback.
* ui-workspace contracts. Two registrations share this package:
*
* - WorkspaceBrowser fills the sidebar shell's `sidebar.workspaces` hole —
* the whole browsing region (section header, search, grouped/flat session
* list, workspace dialogs). It registers this package's viewing store and
* consumes the shell's two-fact owner share (wide / expandSidebar).
* - WorkspacePicker fills the conversation empty-state hole (menu +
* create dialogs shared with the browser).
*/
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pull both owner SlotMap merges into programs that resolve the
// picker runtime union below.
import type { PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
// Type-only: pull the owner SlotMap merges into programs that resolve the
// runtime shares below.
import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client'
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
import type { createWorkspaceViewStore } from '../stores.ts'
/**
* Registrant-private injected share. Pick semantics remain in each owner's
* onPick callback; this callback creates only the real Host Workspace. A type
* alias supplies the implicit index signature required by the registry.
* Browser-private injected share (arrives via the register inject factory).
* Data reads use the global framework hooks; these are the Host actions the
* browsing region drives.
*/
export type WorkspaceBrowserInjected = {
/** Start or replace the current frontend Session Intent. */
startSession: (workspaceId?: WorkspaceId, prompt?: string) => void
/** Open a real Session. */
open: (sessionId: SessionId) => void
/** Rename a Host Workspace (rejects on name conflict; resolves on durability). */
renameWorkspace: (workspaceId: WorkspaceId, title: string) => Promise<void>
/**
* Reorder a session inside its Workspace account (DOM-insertBefore
* semantics: omitted anchor appends to the end). The view refreshes from
* the Host response/changed frame; failures leave the order unchanged.
*/
insertSessionBefore: (workspaceId: WorkspaceId, sessionId: SessionId, beforeSessionId?: SessionId) => Promise<void>
/** Explicitly create or adopt a real Workspace before targeting a Session. */
createWorkspace: (input: { name: string } | { path: string }) => Promise<WorkspaceView>
}
/** Full browser props: shell owner share + viewing store + injected actions. */
export type WorkspaceBrowserProps =
PropsRuntime<'sidebar.workspaces'>
& PropsStore<ReturnType<typeof createWorkspaceViewStore>>
& WorkspaceBrowserInjected
/**
* Picker-private injected share. Pick semantics remain in the owner's onPick
* callback; this callback creates only the real Host Workspace. A type alias
* supplies the implicit index signature required by the registry.
*/
export type WorkspacePickerInjected = {
/** Explicitly create or adopt a real Workspace before targeting a Session. */
createWorkspace(input: { name: string } | { path: string }): Promise<WorkspaceView>
}
/**
* Full picker props: either owner's runtime share, including useWorkspaces,
* plus this package's injected creation callback.
*/
/** Full picker props: the empty-state owner share plus the creation callback. */
export type WorkspacePickerProps =
(PropsRuntime<'sidebar.workspace'> | PropsRuntime<'conversation.empty.workspace'>)
& WorkspacePickerInjected
PropsRuntime<'conversation.empty.workspace'> & WorkspacePickerInjected

View File

@@ -1,54 +1,85 @@
/**
* Shared Workspace picker plugin, browser half. WorkspacePicker registers in
* the sidebar and page-local Session Intent hero slots, reads real Host Workspaces
* through the global useWorkspaces hook, and delegates selection semantics to
* each owner. Its injected share creates a Workspace without creating a
* Session. Export discipline: packages/client/AGENTS.md.
* Workspace plugin, browser half. Two registrations: WorkspaceBrowser fills
* the sidebar shell's `sidebar.workspaces` hole (the whole browsing region),
* and WorkspacePicker fills the conversation empty-state hole. Both read real
* Host Workspaces through the global useWorkspaces hook. Export discipline:
* packages/client/AGENTS.md.
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import type { WorkspacePickerInjected } from './contract/slots.ts'
import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from './contract/slots.ts'
import { createWorkspaceViewStore } from './stores.ts'
import { WorkspaceBrowser } from './WorkspaceBrowser.tsx'
import { WorkspacePicker } from './WorkspacePicker.tsx'
export type { WorkspacePickerInjected, WorkspacePickerProps } from './contract/slots.ts'
export type {
WorkspaceBrowserInjected, WorkspaceBrowserProps, WorkspacePickerInjected, WorkspacePickerProps,
} from './contract/slots.ts'
/**
* Required services (cordis fiber inject). The target slot is declared by
* the ui-sidebar apply, whose activation order relative to this one is NOT
* constrained: dshClient.inject edges are informational (loading/prefetch
* metadata, never apply sequencing) and the sidebar provides no waitable
* service. apply therefore registers via declaration-aware deferral instead
* of assuming order.
* Required services (cordis fiber inject). The target slots are declared by
* the ui-sidebar / ui-conversation applies, whose activation order relative
* to this one is NOT constrained: dshClient.inject edges are informational
* (loading/prefetch metadata, never apply sequencing) and neither owner
* provides a waitable service. apply therefore registers via
* declaration-aware deferral instead of assuming order.
*/
export const inject = ['slots', 'workspaces']
export const inject = ['slots', 'sessions', 'workspaces']
/**
* Register WorkspacePicker in both owner slots once their declarations are on
* the ledger. The inject factory returns a plain Workspace creation callback;
* data reads use the framework's global useWorkspaces hook.
* Register the browser and picker once their slot declarations are on the
* ledger. Inject factories return plain callbacks; data reads use the
* framework's global hooks.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
const injected = (): WorkspacePickerInjected => ({
const browserInjected = (): WorkspaceBrowserInjected => ({
startSession: (workspaceId, prompt) => { ctx.workspaces.startSession(workspaceId, prompt) },
open: (sessionId) => { ctx.sessions.open(sessionId) },
renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) },
insertSessionBefore: async (workspaceId, sessionId, beforeSessionId) => {
await ctx.workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
},
createWorkspace: input => ctx.workspaces.create(input),
})
// Declaration-aware registration: the sidebar's declaring apply may
// activate after this one (entry activation order is unconstrained), and a
// register into an undeclared slot throws. Register once the declaration
// is on the ledger; the subscription also re-registers after an HMR
// collapse re-declares the slot (the cascade disposed our entry with it).
const pickerInjected = (): WorkspacePickerInjected => ({
createWorkspace: input => ctx.workspaces.create(input),
})
// Declaration-aware registration: each owner's declaring apply may activate
// after this one (entry activation order is unconstrained), and a register
// into an undeclared slot throws. Register once the declaration is on the
// ledger; the subscription also re-registers after an HMR collapse
// re-declares the slot (the cascade disposed our entry with it).
ctx.effect(() => {
const slotNames = ['sidebar.workspace', 'conversation.empty.workspace'] as const
const disposers = new Map<(typeof slotNames)[number], () => void>()
const tryRegister = (name: (typeof slotNames)[number]): void => {
if (ctx.slots.spec(name) === undefined) return
if (ctx.slots.entries(name).some(e => e.component === WorkspacePicker)) return
disposers.set(name, ctx.slots.register({ name, inject: injected }, WorkspacePicker))
const registrations = [
{
name: 'sidebar.workspaces' as const,
component: WorkspaceBrowser,
register: () => ctx.slots.register(
{ name: 'sidebar.workspaces', store: createWorkspaceViewStore(), inject: browserInjected },
WorkspaceBrowser,
),
},
{
name: 'conversation.empty.workspace' as const,
component: WorkspacePicker,
register: () => ctx.slots.register(
{ name: 'conversation.empty.workspace', inject: pickerInjected },
WorkspacePicker,
),
},
]
const disposers = new Map<string, () => void>()
const tryRegister = (entry: (typeof registrations)[number]): void => {
if (ctx.slots.spec(entry.name) === undefined) return
if (ctx.slots.entries(entry.name).some(e => e.component === entry.component)) return
disposers.set(entry.name, entry.register())
}
const unsubscribers = slotNames.map(name => ctx.slots.subscribe(name, () => { tryRegister(name) }))
for (const name of slotNames) tryRegister(name)
const unsubscribers = registrations.map(entry =>
ctx.slots.subscribe(entry.name, () => { tryRegister(entry) }))
for (const entry of registrations) tryRegister(entry)
return () => {
for (const unsubscribe of unsubscribers) unsubscribe()
for (const dispose of disposers.values()) dispose()
}
}, 'ui-workspace: picker registrations')
}, 'ui-workspace: browser + picker registrations')
}

View File

@@ -150,14 +150,63 @@
}
.projectRow:hover .rowActions,
.sessionRow:hover .rowActions {
.sessionRow:hover .rowActions,
.projectRow.menuOpen .rowActions,
.sessionRow.menuOpen .rowActions {
display: inline-flex;
}
.sessionRow:hover .time {
.sessionRow:hover .time,
.sessionRow.menuOpen .time {
display: none;
}
/* An open row menu pins the hover affordances (figma: the row keeps its
hover fill while its dropdown is up). */
.projectRow.menuOpen,
.sessionRow.menuOpen {
background: var(--dsw-alias-interactive-bg-hover);
}
/* Drag reorder insert line (workspace-group roots): 2px accent above or
below the hovered row, drawn with box-shadow so no layout shift. */
.sessionRow.dropBefore {
box-shadow: 0 -2px 0 0 var(--dsw-alias-state-business-primary);
}
.sessionRow.dropAfter {
box-shadow: 0 2px 0 0 var(--dsw-alias-state-business-primary);
}
/* Hover-card body (figma 169:16903): dark surface, fixed colors both themes. */
.hoverContent {
display: flex;
flex-direction: column;
gap: 8px;
}
.hoverTitle {
font-size: 14px;
line-height: 20px;
color: #FFFFFF;
overflow-wrap: break-word;
}
.hoverTime {
font-size: 12px;
line-height: 16px;
color: #CFD3D6;
}
.hoverStatus {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
line-height: 20px;
color: #ADB2B8;
}
.iconButton {
flex: none;
display: inline-flex;

View File

@@ -0,0 +1,284 @@
/**
* Workspace browser tree row components (figma Cell set 14:3080): pure presentational —
* all data and callbacks arrive via props. Hover swaps (folder->chevron,
* time->ellipsis, action buttons) are CSS-only. Row ... menus are visual-only
* except workspace Rename; the session hover card is suppressed while a menu
* is open.
*/
import { useState } from 'react'
import clsx from 'clsx'
import {
HoverCard, IconBranchOutline16, IconEditOutline16, IconEllipsisOutline16,
IconFolderClose16, IconFolderOpen16, IconPlusOutline16,
IconTrashOutline16, IconTriangleRightFill14, Menu, StateDot,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { GroupNode, SessionNode } from '../tree.ts'
import { formatRelativeTime } from '../tree.ts'
import css from './Rows.module.css'
/** Indent step per tree level: one 16px slot (figma session cell). */
const INDENT_STEP = 16
const SESSION_MENU_ITEMS = [
{ id: 'rename', label: 'Rename', icon: <IconEditOutline16 /> },
{ id: 'fork', label: 'Fork session', icon: <IconBranchOutline16 /> },
{ id: 'delete', label: 'Delete session', icon: <IconTrashOutline16 />, danger: true },
]
const WORKSPACE_MENU_ITEMS = [
{ id: 'rename', label: 'Rename', icon: <IconEditOutline16 /> },
{ id: 'delete', label: 'Delete workspace', icon: <IconTrashOutline16 />, danger: true },
]
/**
* Project (workspace) header row: 54px, folder + title + session count;
* hover reveals the chevron and create button. `containsCurrent` arrives on
* the node (derivation fact, no renderer scan).
* @param props.group - derived group node.
* @param props.onToggle - expand/collapse the group.
* @param props.onCreate - start a frontend Session inside this Workspace.
* @returns the row element.
*/
export function ProjectRowItem({ group, onToggle, onCreate, onRename }: {
group: GroupNode
onToggle: () => void
onCreate: () => void
/** Open the rename dialog; absent for the ungrouped bucket (no menu shown). */
onRename?: (() => void) | undefined
}) {
const row = group
const active = group.expanded && group.containsCurrent
const count = `${row.sessionCount} ${row.sessionCount === 1 ? 'session' : 'sessions'}`
const [menuOpen, setMenuOpen] = useState(false)
return (
<div
className={clsx(css.projectRow, menuOpen && css.menuOpen)}
role="treeitem"
aria-expanded={row.expanded}
onClick={onToggle}
>
<span className={clsx(css.slot, css.folder, active && css.folderActive)}>
{row.expanded ? <IconFolderOpen16 /> : <IconFolderClose16 />}
</span>
<span className={clsx(css.slot, css.chevron)}>
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
</span>
<span className={css.projectText}>
<span className={css.title}>{row.label}</span>
<span className={css.meta}>{count}</span>
</span>
<span className={css.rowActions}>
{onRename !== undefined && (
<Menu
open={menuOpen}
onClose={() => { setMenuOpen(false) }}
items={WORKSPACE_MENU_ITEMS}
onSelect={(id) => {
setMenuOpen(false)
if (id === 'rename') onRename()
// Delete is visual-only for now.
}}
portal
closeOnPointerLeave
anchor={(
<button
type="button"
className={css.iconButton}
aria-label={`Workspace actions for ${row.label}`}
onClick={(e) => { e.stopPropagation(); setMenuOpen(v => !v) }}
>
<IconEllipsisOutline16 />
</button>
)}
/>
)}
<button
type="button"
className={css.iconButton}
aria-label={`New session in ${row.label}`}
onClick={(e) => { e.stopPropagation(); onCreate() }}
>
<IconPlusOutline16 />
</button>
</span>
</div>
)
}
/**
* The selected "New session" row for a frontend Session Intent targeted to a
* real Workspace. The row disappears when the Intent is replaced or connects.
* One status-slot indent in both grouped and flat lists (session rows carry
* no twist slot either, so titles align).
* @returns the placeholder row element.
*/
export function IntentRowItem() {
return (
<div className={clsx(css.sessionRow, css.selected)} role="treeitem" aria-selected style={{ paddingLeft: 8 }}>
<span className={css.slot} />
<span className={css.title}>New session</span>
</div>
)
}
/**
* One session subtree: the node's own 34px row (indent by depth, expand
* twist when it has children, running dot, relative time) plus its visible
* children, recursively — the component tree mirrors the derived tree.
* @param props.node - derived session node.
* @param props.depth - 0 = directly under the group header.
* @param props.currentId - selected session id (row highlight).
* @param props.now - epoch ms for relative-time formatting.
* @param props.onOpen - open a session by id.
* @param props.onToggle - unfold/fold a subtree by id.
* @returns the node's row followed by its children.
*/
/** Hover-card body: full title, relative time, and the status line (running/idle until wire status lands). */
function SessionHoverContent({ node, now }: { node: SessionNode; now: number }) {
return (
<div className={css.hoverContent}>
<div className={css.hoverTitle}>{node.title}</div>
<div className={css.hoverTime}>{`${formatRelativeTime(node.updatedAt, now)} ago`}</div>
<div className={css.hoverStatus}>
<StateDot state={node.running ? 'ongoing' : 'done'} />
<span>{node.running ? 'Running' : 'Idle'}</span>
</div>
</div>
)
}
/**
* Root-row drag wiring supplied by the group owner (workspace groups only).
* `drop` reports the half of the row the pointer released on: 'before'
* inserts above this row, 'after' below it (the owner resolves the anchor).
*/
export interface RowDragProps {
/** Start dragging this row. */
start: () => void
/** A drag from the same group is in flight (rows show insert markers). */
active: boolean
/** Current marker on this row: insert line above, below, or none. */
marker: 'before' | 'after' | null
/** Report the hovered half while a same-group drag passes over this row. */
hover: (half: 'before' | 'after') => void
drop: (half: 'before' | 'after') => void
end: () => void
}
/** Pointer-position half of a row (insert line above or below). */
function rowHalf(e: { clientY: number; currentTarget: HTMLElement }): 'before' | 'after' {
const rect = e.currentTarget.getBoundingClientRect()
return e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'
}
export function SessionNodeItem({ node, depth, currentId, now, onOpen, onToggle, drag, flat = false }: {
node: SessionNode
depth: number
currentId: string | undefined
now: number
onOpen: (id: SessionNode['id']) => void
onToggle: (id: SessionNode['id']) => void
/** Present only on draggable rows (workspace-group roots outside search). */
drag?: RowDragProps | undefined
/** Flat-list variant: no twist slot (figma flat cell) — titles align on the status slot. */
flat?: boolean
}) {
const row = node
const selected = node.id === currentId
const [menuOpen, setMenuOpen] = useState(false)
// Rail (figma session cell: pad 8, twist slot 16, status slot 16, gap 4 to
// the title): both slots are always reserved so titles align whether or not
// the twist/dot is lit. Extra depth rides the left padding.
const ownRow = (
<div
className={clsx(
css.sessionRow, selected && css.selected, menuOpen && css.menuOpen,
drag?.marker === 'before' && css.dropBefore, drag?.marker === 'after' && css.dropAfter,
)}
role="treeitem"
aria-selected={selected}
{...(row.hasChildren ? { 'aria-expanded': row.expanded } : {})}
style={{ paddingLeft: 8 + depth * INDENT_STEP }}
onClick={() => { onOpen(node.id) }}
draggable={drag !== undefined}
onDragStart={drag === undefined
? undefined
: (e) => {
e.dataTransfer.effectAllowed = 'move'
drag.start()
}}
onDragEnd={drag?.end}
onDragOver={drag === undefined
? undefined
: (e) => {
if (!drag.active) return
e.preventDefault()
e.dataTransfer.dropEffect = 'move'
drag.hover(rowHalf(e))
}}
onDrop={drag === undefined
? undefined
: (e) => {
if (!drag.active) return
e.preventDefault()
drag.drop(rowHalf(e))
}}
>
{row.hasChildren && !flat
? (
<button
type="button"
className={css.twist}
aria-label={row.expanded ? 'Collapse' : 'Expand'}
onClick={(e) => { e.stopPropagation(); onToggle(node.id) }}
>
<IconTriangleRightFill14 className={clsx(css.arrow, row.expanded && css.arrowOpen)} />
</button>
)
: null}
<span className={css.slot}>{row.running && <StateDot state="ongoing" />}</span>
<span className={css.title}>{row.title}</span>
<span className={css.time}>{formatRelativeTime(row.updatedAt, now)}</span>
<span className={css.rowActions}>
<Menu
open={menuOpen}
onClose={() => { setMenuOpen(false) }}
items={SESSION_MENU_ITEMS}
onSelect={() => { setMenuOpen(false) }} // Visual-only for now.
portal
closeOnPointerLeave
anchor={(
<button
type="button"
className={css.iconButton}
aria-label={`Session actions for ${row.title}`}
onClick={(e) => { e.stopPropagation(); setMenuOpen(v => !v) }}
>
<IconEllipsisOutline16 />
</button>
)}
/>
</span>
</div>
)
return (
<>
<HoverCard
anchor={ownRow}
content={<SessionHoverContent node={node} now={now} />}
disabled={menuOpen || drag?.active === true}
/>
{node.children.map(child => (
<SessionNodeItem
key={child.id}
node={child}
depth={depth + 1}
currentId={currentId}
now={now}
onOpen={onOpen}
onToggle={onToggle}
/>
))}
</>
)
}

View File

@@ -0,0 +1,36 @@
/**
* The workspace browser's viewing store: the session-list grouping mode,
* persisted across reloads. Module level exports the factory only (a
* module-level handle would pin the store identity across plugin reloads);
* register() receives the factory and the browser derives its PropsStore
* share from the return type.
*/
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
/** Session-list grouping mode: workspace sections or one flat recency list. */
export type WorkspaceGroupBy = 'workspace' | 'flat'
/** Workspace browser viewing state (grouping mode only; transient UI facts stay component-local). */
type WorkspaceViewState = { groupBy: WorkspaceGroupBy }
/**
* Annotation twin of the actions literal below (the export needs a declared
* return type); drift fails assignability at the defineStore call.
*/
type WorkspaceViewActions = {
setGroupBy: (draft: WorkspaceViewState, mode: WorkspaceGroupBy) => void
}
/**
* Create the workspace browser viewing store handle.
* @returns the store handle (spec + type + identity + factory in one).
*/
export function createWorkspaceViewStore(): EngineStoreHandle<WorkspaceViewState, WorkspaceViewActions> {
return defineStore({
init: (): WorkspaceViewState => ({ groupBy: 'workspace' }),
persist: 'dsh.workspace.view',
actions: {
setGroupBy: (d, mode: WorkspaceGroupBy) => { d.groupBy = mode },
},
})
}

View File

@@ -1,5 +1,5 @@
/**
* Derives the sidebar tree from Host Workspace order and membership.
* Derives the workspace browser tree from Host Workspace order and membership.
* Unassigned Sessions trail under Ungrouped; only Intents targeting real Workspaces render.
*/
import type { SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-runtime/client'
@@ -223,11 +223,12 @@ function buildSearch(g: Group, visible: ReadonlySet<SessionId>): SessionNode[] {
}
/**
* Derive the nested sidebar group structure.
* Derive the nested workspace browser group structure.
*
* Normal mode: every group shows; sessions populate under expanded groups,
* descending only into expanded sessions. A frontend Session Intent targeting
* a real Workspace marks that group `intentHere` and forces it expanded. Search mode (non-blank query,
* a real Workspace marks that group `intentHere` (rendered only while the
* group is expanded; expansion stays viewer-owned). Search mode (non-blank query,
* case-insensitive display-title substring): expansion state is ignored
* matched sessions and their ancestor chains are forced visible, groups
* without a display-title or label hit are dropped, a label-only hit keeps
@@ -263,7 +264,9 @@ export function deriveGroups(
&& g.workspaceId !== undefined && intentWorkspaceId === g.workspaceId
const intentHere = q === '' && hasIntent
if (q === '') {
const expanded = intentHere || expandedProjects.has(g.key)
// The intent never forces expansion — the viewer auto-expands the
// target group once (current-group effect); the toggle stays live.
const expanded = expandedProjects.has(g.key)
groups.push({
key: g.key,
workspaceId: g.workspaceId,
@@ -294,6 +297,29 @@ export function deriveGroups(
return groups
}
/**
* Derive the flat session list ("In one list" mode): every session fork
* children included as a top-level row, strictly newest-first. No grouping,
* no parent/child adjacency; rows reuse SessionNode with children always
* empty so the renderer stays branch-free. Search mode filters by
* case-insensitive display-title substring.
* @param list - sessions list snapshot.
* @param view - the search query (expansion state does not apply).
* @returns flat rows in render order.
*/
export function deriveFlat(list: SessionListState, view: Pick<TreeView, 'query'>): SessionNode[] {
const q = view.query.trim().toLowerCase()
const rows: SessionSummary[] = []
for (const id of list.ids) {
const s = list.byId[id]
if (s === undefined) continue
if (q !== '' && !s.displayTitle.toLowerCase().includes(q)) continue
rows.push(s)
}
rows.sort(byRecency)
return rows.map(s => sessionNode(s, [], false, false))
}
/**
* Compact relative time for session rows ("now", "5min", "3h", "2d", "4mo", "1y").
* @param updatedAt - epoch ms of the session's last activity.

View File

@@ -2,7 +2,8 @@ import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client'
import type { WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client'
import type { WorkspaceBrowserInjected, WorkspacePickerInjected } from '@deepseek-ai/dsh-client-ui-workspace/client'
import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx'
import { WorkspacePicker } from '../src/client/WorkspacePicker.tsx'
async function bench() {
@@ -13,32 +14,33 @@ async function bench() {
path: 'name' in input ? `/projects/${input.name}` : input.path,
title: 'new', sessionIds: [], createdAt: '0', updatedAt: '0',
}))
ctx.provide('workspaces', { create })
return { ctx, slots: ctx.get('slots') as SlotsService, create }
const startSession = vi.fn()
const rename = vi.fn(async () => ({}))
const insertSessionBefore = vi.fn(async () => ({}))
const open = vi.fn()
ctx.provide('workspaces', { create, startSession, rename, insertSessionBefore } as never)
ctx.provide('sessions', { open } as never)
return { ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename, insertSessionBefore, open }
}
function declare(slots: SlotsService, name: 'sidebar.workspace' | 'conversation.empty.workspace'): () => void {
return slots.register(
{ name: 'root', children: { [name]: { kind: 'single', scope: 'root' } } } as never,
() => null,
)
}
type HoleName = 'sidebar.workspaces' | 'conversation.empty.workspace'
function injectedOf(slots: SlotsService, name: 'sidebar.workspace' | 'conversation.empty.workspace'): WorkspacePickerInjected {
const entry = slots.entries(name)[0]!
return (entry.inject as () => WorkspacePickerInjected)()
/** Declare one or both holes with a single root registration ('root' is a single slot). */
function declare(slots: SlotsService, ...names: HoleName[]): () => void {
const children = Object.fromEntries(names.map(name => [name, { kind: 'single', scope: 'root' }]))
return slots.register({ name: 'root', children } as never, () => null)
}
describe('ui-workspace apply', () => {
it('declares the independent Workspace service', () => {
expect(inject).toEqual(['slots', 'workspaces'])
it('declares the services it drives', () => {
expect(inject).toEqual(['slots', 'sessions', 'workspaces'])
})
it('registers the shared picker for declarations that arrive before or after apply', async () => {
it('registers browser and picker for declarations arriving before or after apply', async () => {
const before = await bench()
declare(before.slots, 'sidebar.workspace')
declare(before.slots, 'sidebar.workspaces')
await before.ctx.plugin({ inject: [...inject], apply }).await()
expect(before.slots.entries('sidebar.workspace')[0]!.component).toBe(WorkspacePicker)
expect(before.slots.entries('sidebar.workspaces')[0]!.component).toBe(WorkspaceBrowser)
const after = await bench()
await after.ctx.plugin({ inject: [...inject], apply }).await()
@@ -47,23 +49,35 @@ describe('ui-workspace apply', () => {
expect(after.slots.entries('conversation.empty.workspace')[0]!.component).toBe(WorkspacePicker)
})
it('routes name and path creation to WorkspacesService', async () => {
it('routes browser actions and picker creation to the services', async () => {
const b = await bench()
declare(b.slots, 'sidebar.workspace')
declare(b.slots, 'sidebar.workspaces', 'conversation.empty.workspace')
await b.ctx.plugin({ inject: [...inject], apply }).await()
const injected = injectedOf(b.slots, 'sidebar.workspace')
await injected.createWorkspace({ name: 'project' })
await injected.createWorkspace({ path: '/tmp/project' })
expect(b.create).toHaveBeenNthCalledWith(1, { name: 'project' })
expect(b.create).toHaveBeenNthCalledWith(2, { path: '/tmp/project' })
const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)()
browser.startSession('ws' as never, 'prompt')
expect(b.startSession).toHaveBeenCalledWith('ws', 'prompt')
browser.open('session' as never)
expect(b.open).toHaveBeenCalledWith('session')
await browser.renameWorkspace('ws' as never, 'renamed')
expect(b.rename).toHaveBeenCalledWith('ws', 'renamed')
await browser.insertSessionBefore('ws' as never, 's1' as never, 's2' as never)
expect(b.insertSessionBefore).toHaveBeenCalledWith('ws', 's1', 's2')
await browser.createWorkspace({ name: 'project' })
expect(b.create).toHaveBeenCalledWith({ name: 'project' })
const picker = (b.slots.entries('conversation.empty.workspace')[0]!.inject as () => WorkspacePickerInjected)()
await picker.createWorkspace({ path: '/tmp/project' })
expect(b.create).toHaveBeenCalledWith({ path: '/tmp/project' })
})
it('unregisters picker entries on teardown', async () => {
it('unregisters both entries on teardown', async () => {
const b = await bench()
declare(b.slots, 'sidebar.workspace')
declare(b.slots, 'sidebar.workspaces', 'conversation.empty.workspace')
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
await fiber.dispose()
expect(b.slots.entries('sidebar.workspace')).toHaveLength(0)
expect(b.slots.entries('sidebar.workspaces')).toHaveLength(0)
expect(b.slots.entries('conversation.empty.workspace')).toHaveLength(0)
})
})

View File

@@ -0,0 +1,251 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, createEvent, fireEvent, render, screen } from '@testing-library/react'
import type { SessionId, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client'
import type { RowDragProps } from '../src/client/rows/Rows.tsx'
import { IntentRowItem, ProjectRowItem, SessionNodeItem } from '../src/client/rows/Rows.tsx'
import type { GroupNode, SessionNode } from '../src/client/tree.ts'
afterEach(cleanup)
const sid = (id: string) => id as SessionId
const wid = (id: string) => id as WorkspaceId
/** Half detection reads the row rect; jsdom rects are all-zero by default. */
function stubRect(row: HTMLElement): void {
row.getBoundingClientRect = () => ({
top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34,
x: 0, y: 100, toJSON: () => ({}),
} as DOMRect)
}
function dragProps(overrides: Partial<RowDragProps> = {}): RowDragProps {
return {
start: vi.fn(), active: false, marker: null,
hover: vi.fn(), drop: vi.fn(), end: vi.fn(),
...overrides,
}
}
const dataTransfer = { effectAllowed: '', dropEffect: '' }
/** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */
function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void {
const event = kind === 'dragOver' ? createEvent.dragOver(row) : createEvent.drop(row)
Object.defineProperty(event, 'clientY', { value: clientY })
Object.defineProperty(event, 'dataTransfer', { value: { ...dataTransfer } })
fireEvent(row, event)
}
describe('workspace browser rows', () => {
it('renders an active Workspace and keeps its create action separate from toggling', () => {
const onToggle = vi.fn()
const onCreate = vi.fn()
const group: GroupNode = {
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project',
sessionCount: 1, expanded: true, containsCurrent: true, intentHere: false, sessions: [],
}
render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={onCreate} />)
expect(screen.getByText('1 session')).toBeTruthy()
expect(screen.getByRole('treeitem').getAttribute('aria-expanded')).toBe('true')
fireEvent.click(screen.getByRole('button', { name: 'New session in Project' }))
expect(onCreate).toHaveBeenCalledOnce()
expect(onToggle).not.toHaveBeenCalled()
fireEvent.click(screen.getByText('Project'))
expect(onToggle).toHaveBeenCalledOnce()
})
it('renders the frontend Intent placeholder as selected', () => {
render(<IntentRowItem />)
expect(screen.getByRole('treeitem').getAttribute('aria-selected')).toBe('true')
})
it('renders and operates selected, running, recursive Session nodes', () => {
const child: SessionNode = {
id: sid('child'), title: 'Child', children: [], hasChildren: false,
expanded: false, running: false, updatedAt: 0,
}
const parent: SessionNode = {
id: sid('parent'), title: 'Parent', children: [child], hasChildren: true,
expanded: true, running: true, updatedAt: 0,
}
const onOpen = vi.fn()
const onToggle = vi.fn()
const view = render(
<SessionNodeItem node={parent} depth={0} currentId={parent.id} now={0} onOpen={onOpen} onToggle={onToggle} />,
)
const parentRow = screen.getByText('Parent').closest('[role="treeitem"]')!
const childRow = screen.getByText('Child').closest('[role="treeitem"]')!
expect(parentRow.getAttribute('aria-selected')).toBe('true')
expect(parentRow.getAttribute('aria-expanded')).toBe('true')
expect(childRow.getAttribute('aria-selected')).toBe('false')
expect(childRow.hasAttribute('aria-expanded')).toBe(false)
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
expect(onToggle).toHaveBeenCalledWith(parent.id)
expect(onOpen).not.toHaveBeenCalled()
fireEvent.click(parentRow)
fireEvent.click(childRow)
expect(onOpen.mock.calls).toEqual([[parent.id], [child.id]])
view.rerender(
<SessionNodeItem
node={{ ...parent, children: [], expanded: false, running: false }}
depth={1} currentId={undefined} now={0} onOpen={onOpen} onToggle={onToggle}
/>,
)
expect(screen.getByRole('button', { name: 'Expand' })).toBeTruthy()
expect(screen.getByRole('treeitem').getAttribute('aria-selected')).toBe('false')
expect(screen.getByRole('treeitem').style.paddingLeft).toBe('24px')
})
it('workspace row menu opens on the ellipsis, renames, and shows the danger delete row', () => {
const onRename = vi.fn()
const onToggle = vi.fn()
const group: GroupNode = {
key: 'project', workspaceId: wid('project'), cwd: '/projects/project', label: 'Project',
sessionCount: 0, expanded: false, containsCurrent: false, intentHere: false, sessions: [],
}
render(<ProjectRowItem group={group} onToggle={onToggle} onCreate={vi.fn()} onRename={onRename} />)
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
// Opening the menu neither toggles the group nor renames yet.
expect(onToggle).not.toHaveBeenCalled()
expect(screen.getByRole('menuitem', { name: 'Delete workspace' }).className).toMatch(/danger/)
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
expect(onRename).toHaveBeenCalledOnce()
expect(screen.queryByRole('menu')).toBeNull()
// Delete stays visual-only: selecting it just closes the menu.
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Delete workspace' }))
expect(screen.queryByRole('menu')).toBeNull()
expect(onRename).toHaveBeenCalledOnce()
// Escape closes without selecting (Menu onClose path).
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Project' }))
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByRole('menu')).toBeNull()
})
it('ungrouped bucket renders no workspace menu', () => {
const group: GroupNode = {
key: '', workspaceId: undefined, cwd: undefined, label: 'Ungrouped',
sessionCount: 0, expanded: false, containsCurrent: false, intentHere: false, sessions: [],
}
render(<ProjectRowItem group={group} onToggle={vi.fn()} onCreate={vi.fn()} />)
expect(screen.queryByRole('button', { name: /Workspace actions/ })).toBeNull()
})
it('session row menu opens without opening the session and closes on selection', () => {
const onOpen = vi.fn()
const node: SessionNode = {
id: sid('s1'), title: 'One', children: [], hasChildren: false,
expanded: false, running: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={onOpen} onToggle={vi.fn()} />)
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
expect(onOpen).not.toHaveBeenCalled()
expect(screen.getByRole('menuitem', { name: 'Delete session' }).className).toMatch(/danger/)
fireEvent.click(screen.getByRole('menuitem', { name: 'Fork session' }))
expect(screen.queryByRole('menu')).toBeNull()
expect(onOpen).not.toHaveBeenCalled()
// Escape closes without selecting (Menu onClose path).
fireEvent.click(screen.getByRole('button', { name: 'Session actions for One' }))
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByRole('menu')).toBeNull()
})
it('flat variant renders no twist even for a parent and ignores toggling', () => {
const node: SessionNode = {
id: sid('p'), title: 'Parent', children: [], hasChildren: true,
expanded: false, running: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} flat />)
expect(screen.queryByRole('button', { name: 'Expand' })).toBeNull()
})
it('shows the hover card after the dwell and suppresses it while the row menu is open', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('s1'), title: 'Hovered', children: [], hasChildren: false,
expanded: false, running: true, updatedAt: 0,
}
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={60_000} onOpen={vi.fn()} onToggle={vi.fn()} />)
const wrapper = screen.getByRole('treeitem').parentElement as HTMLElement
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(500) })
// Card body: full title + relative time + running status.
expect(screen.getAllByText('Hovered')).toHaveLength(2)
expect(screen.getByText('1min ago')).toBeTruthy()
expect(screen.getByText('Running')).toBeTruthy()
fireEvent.pointerLeave(wrapper)
// Menu open (disabled=true) suppresses the card for the same hover.
fireEvent.click(screen.getByRole('button', { name: 'Session actions for Hovered' }))
fireEvent.pointerEnter(wrapper)
act(() => { vi.advanceTimersByTime(1000) })
expect(screen.queryByText('1min ago')).toBeNull()
} finally {
vi.useRealTimers()
}
})
it('idle hover card shows the Idle status line', () => {
vi.useFakeTimers()
try {
const node: SessionNode = {
id: sid('s1'), title: 'Quiet', children: [], hasChildren: false,
expanded: false, running: false, updatedAt: 0,
}
render(<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} />)
fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement)
act(() => { vi.advanceTimersByTime(500) })
expect(screen.getByText('Idle')).toBeTruthy()
expect(screen.getByText('now ago')).toBeTruthy()
} finally {
vi.useRealTimers()
}
})
it('draggable row wires start/end and gates hover/drop on an active same-group drag', () => {
const node: SessionNode = {
id: sid('s1'), title: 'Drag me', children: [], hasChildren: false,
expanded: false, running: false, updatedAt: 0,
}
const inactive = dragProps()
const { rerender } = render(
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={inactive} />,
)
const row = screen.getByRole('treeitem')
stubRect(row)
expect(row.getAttribute('draggable')).toBe('true')
fireEvent.dragStart(row, { dataTransfer })
expect(inactive.start).toHaveBeenCalledOnce()
// Inactive drag: hover and drop are rejected.
fireEvent.dragOver(row, { dataTransfer })
fireEvent.drop(row, { dataTransfer })
expect(inactive.hover).not.toHaveBeenCalled()
expect(inactive.drop).not.toHaveBeenCalled()
fireEvent.dragEnd(row)
expect(inactive.end).toHaveBeenCalledOnce()
const active = dragProps({ active: true, marker: 'before' })
rerender(
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={active} />,
)
stubRect(screen.getByRole('treeitem'))
// Top half hovers/drops 'before'; bottom half 'after' (row mid = 117).
fireDrag(screen.getByRole('treeitem'), 'dragOver', 105)
expect(active.hover).toHaveBeenCalledWith('before')
fireDrag(screen.getByRole('treeitem'), 'dragOver', 130)
expect(active.hover).toHaveBeenCalledWith('after')
fireDrag(screen.getByRole('treeitem'), 'drop', 130)
expect(active.drop).toHaveBeenCalledWith('after')
const after = dragProps({ active: true, marker: 'after' })
rerender(
<SessionNodeItem node={node} depth={0} currentId={undefined} now={0} onOpen={vi.fn()} onToggle={vi.fn()} drag={after} />,
)
expect(screen.getByRole('treeitem').className).toMatch(/dropAfter/)
})
})

View File

@@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'
import type {
SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import { deriveGroups, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.ts'
import { deriveFlat, deriveGroups, formatRelativeTime, projectLabel, UNGROUPED_KEY, UNGROUPED_LABEL } from '../src/client/tree.ts'
import { createWorkspaceViewStore } from '../src/client/stores.ts'
const sid = (id: string) => id as SessionId
const wid = (id: string) => id as WorkspaceId
@@ -52,6 +53,12 @@ describe('deriveGroups', () => {
expect(deriveGroups({ ...list(), intent: hiddenIntent }, [target], view())[0]!.intentHere).toBe(false)
})
it('an Intent no longer forces its target group expanded (viewer owns expansion)', () => {
const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('first') }, prompt: '', phase: 'connecting' as const }
const groups = deriveGroups({ ...list(), intent }, [workspace('first', [])], view())
expect(groups[0]).toEqual(expect.objectContaining({ intentHere: true, expanded: false }))
})
it('search filters real Sessions and omits the Intent placeholder', () => {
const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('first') }, prompt: '', phase: 'ready' as const }
const groups = deriveGroups({ ...list(summary('match', 1)), intent }, [workspace('first', ['match'])], view([], 'match'))
@@ -135,6 +142,39 @@ describe('deriveGroups', () => {
})
})
describe('deriveFlat', () => {
it('flattens every session — fork children included — newest-first with id tiebreak', () => {
const parent = summary('parent', 10)
const child = { ...summary('child', 30), parentId: parent.id }
const tieB = summary('tie-b', 20)
const tieA = summary('tie-a', 20)
const rows = deriveFlat(list(parent, child, tieB, tieA), { query: '' })
expect(rows.map(row => row.id)).toEqual([sid('child'), sid('tie-a'), sid('tie-b'), sid('parent')])
// Rows are branch-free: no children, no expansion.
expect(rows.every(row => row.children.length === 0 && !row.hasChildren && !row.expanded)).toBe(true)
})
it('search filters by case-insensitive display-title substring', () => {
const hit = { ...summary('hit', 2), displayTitle: 'Needle row' }
const miss = { ...summary('miss', 1), displayTitle: 'Other' }
expect(deriveFlat(list(hit, miss), { query: ' NEEDLE ' }).map(row => row.id)).toEqual([sid('hit')])
})
it('tolerates ids whose summary has not landed yet', () => {
const partial: SessionListState = { ...list(summary('present', 1)), ids: [sid('ghost'), sid('present')] }
expect(deriveFlat(partial, { query: '' }).map(row => row.id)).toEqual([sid('present')])
})
})
describe('createWorkspaceViewStore', () => {
it('defaults to workspace grouping; setGroupBy is the sole mutation', () => {
const store = createWorkspaceViewStore().create()
expect(store.getSnapshot().groupBy).toBe('workspace')
store.actions.setGroupBy('flat')
expect(store.getSnapshot().groupBy).toBe('flat')
})
})
describe('projectLabel', () => {
it('uses the Ungrouped fallback and extracts POSIX and Windows basenames', () => {
expect(projectLabel(undefined)).toBe(UNGROUPED_LABEL)

View File

@@ -0,0 +1,458 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, createEvent, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
import type {
SessionId, SessionListState, SessionSummary, WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
import type { WorkspaceBrowserProps } from '../src/client/contract/slots.ts'
import { createWorkspaceViewStore } from '../src/client/stores.ts'
import { WorkspaceBrowser } from '../src/client/WorkspaceBrowser.tsx'
afterEach(cleanup)
beforeEach(() => { localStorage.clear() })
const sid = (id: string) => id as SessionId
const wid = (id: string) => id as WorkspaceId
const summary = (id: string, updatedAt: number, overrides: Partial<SessionSummary> = {}): SessionSummary => ({
id: sid(id), displayTitle: id, running: false, updatedAt, ...overrides,
})
const sessionState = (items: readonly SessionSummary[], overrides: Partial<SessionListState> = {}): SessionListState => ({
ids: items.map(item => item.id),
byId: Object.fromEntries(items.map(item => [item.id, item])),
current: undefined,
phase: 'ready',
intent: undefined,
...overrides,
})
const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({
workspaceId: wid(id), path: `/projects/${id}`, title,
sessionIds: sessionIds.map(sid), createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z',
})
const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({
items, intent: undefined, state: 'idle', phase: 'ready', error: null, baselinesReady: true,
recentWorkspaceId: items[0]?.workspaceId,
})
const hook = <T,>(snapshot: T) => <S,>(selector: (state: T) => S): S => selector(snapshot)
/** jsdom lacks DragEvent — the fireEvent fallback drops clientY, so pin it on the built event. */
function fireDrag(row: HTMLElement, kind: 'dragOver' | 'drop', clientY: number): void {
const event = kind === 'dragOver' ? createEvent.dragOver(row) : createEvent.drop(row)
Object.defineProperty(event, 'clientY', { value: clientY })
Object.defineProperty(event, 'dataTransfer', { value: { effectAllowed: '', dropEffect: '' } })
fireEvent(row, event)
}
function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
const store = createWorkspaceViewStore().create()
const props: WorkspaceBrowserProps = {
wide: true,
expandSidebar: vi.fn(),
useSessions: hook(sessionState([])),
useWorkspaces: hook(workspaceState([])),
useStore: bindSnapshotSelector(store),
actions: store.actions,
startSession: vi.fn(),
open: vi.fn(),
renameWorkspace: vi.fn(async () => {}),
insertSessionBefore: vi.fn(async () => {}),
createWorkspace: vi.fn(async () => workspace('created', [])),
...overrides,
}
const view = render(<WorkspaceBrowser {...props} />)
return { view, props, store }
}
/** Re-render with (possibly) changed props — WorkspaceBrowser has no side channel. */
function rerender(b: ReturnType<typeof mount>, overrides: Partial<WorkspaceBrowserProps>) {
Object.assign(b.props, overrides)
b.view.rerender(<WorkspaceBrowser {...b.props} />)
}
describe('WorkspaceBrowser', () => {
it('renders the grouped tree by default and switches to the flat list via Group by', () => {
const sessions = sessionState([summary('alpha-s', 2), summary('beta-s', 1)])
const b = mount({
useSessions: hook(sessions),
useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s']), workspace('beta', ['beta-s'])])),
})
expect(screen.getByText('Workspaces')).toBeTruthy()
expect(screen.getByText('alpha')).toBeTruthy()
// Sessions hidden while their group is folded.
expect(screen.queryByText('alpha-s')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Group by' }))
expect(screen.getByText('Group by')).toBeTruthy() // the menu heading label
fireEvent.click(screen.getByRole('menuitem', { name: 'In one list' }))
// Store-driven flip: title changes, rows flatten newest-first, headers gone.
expect(b.store.getSnapshot().groupBy).toBe('flat')
expect(screen.getByText('Sessions')).toBeTruthy()
expect(screen.queryByText('alpha')).toBeNull()
expect(screen.getByText('alpha-s')).toBeTruthy()
expect(screen.getByText('beta-s')).toBeTruthy()
// Back to workspace grouping through the same menu.
fireEvent.click(screen.getByRole('button', { name: 'Group by' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'WorkSpace' }))
expect(b.store.getSnapshot().groupBy).toBe('workspace')
expect(screen.getByText('Workspaces')).toBeTruthy()
// Escape closes the menu without picking.
fireEvent.click(screen.getByRole('button', { name: 'Group by' }))
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByRole('menu')).toBeNull()
expect(b.store.getSnapshot().groupBy).toBe('workspace')
})
it('expands a group on click and opens a session row', () => {
const open = vi.fn()
mount({
useSessions: hook(sessionState([summary('alpha-s', 1)])),
useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])),
open,
})
fireEvent.click(screen.getByText('alpha'))
fireEvent.click(screen.getByText('alpha-s'))
expect(open).toHaveBeenCalledWith(sid('alpha-s'))
// Collapse hides the row again.
fireEvent.click(screen.getByText('alpha'))
expect(screen.queryByText('alpha-s')).toBeNull()
})
it('unfolds a session subtree through the row twist', () => {
const parent = summary('parent-s', 2)
const child = { ...summary('child-s', 1), parentId: parent.id }
mount({
useSessions: hook(sessionState([parent, child])),
useWorkspaces: hook(workspaceState([workspace('alpha', ['parent-s', 'child-s'])])),
})
fireEvent.click(screen.getByText('alpha'))
expect(screen.queryByText('child-s')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Expand' }))
expect(screen.getByText('child-s')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Collapse' }))
expect(screen.queryByText('child-s')).toBeNull()
})
it('auto-expands the selected session group and starts a session from the group ', () => {
const startSession = vi.fn()
mount({
useSessions: hook(sessionState([summary('alpha-s', 1)], { current: sid('alpha-s') })),
useWorkspaces: hook(workspaceState([workspace('alpha', ['alpha-s'])])),
startSession,
})
// The current-group effect expanded the owning group without a click.
expect(screen.getByText('alpha-s')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'New session in alpha' }))
expect(startSession).toHaveBeenCalledWith(wid('alpha'))
})
it('auto-expands the Ungrouped bucket for a loose current session; its header has no menu and its is inert', () => {
const startSession = vi.fn()
mount({
useSessions: hook(sessionState([summary('loose', 1)], { current: sid('loose') })),
useWorkspaces: hook(workspaceState([workspace('alpha', [])])),
startSession,
})
// The loose session's group is UNGROUPED_KEY: expanded by the effect.
expect(screen.getByText('loose')).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Workspace actions for Ungrouped' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'New session in Ungrouped' }))
expect(startSession).not.toHaveBeenCalled()
})
it('keeps an already-expanded group when the selection moves within it', () => {
const first = sessionState([summary('a', 2), summary('b', 1)], { current: sid('a') })
const b = mount({
useSessions: hook(first),
useWorkspaces: hook(workspaceState([workspace('alpha', ['a', 'b'])])),
})
expect(screen.getByText('a')).toBeTruthy()
// Selection hop inside the same group: the effect re-runs and leaves the
// expansion list unchanged (no duplicate key, group still open).
rerender(b, { useSessions: hook({ ...first, current: sid('b') }) })
expect(screen.getByText('b')).toBeTruthy()
fireEvent.click(screen.getByText('alpha'))
expect(screen.queryByText('b')).toBeNull()
})
it('renders the intent placeholder in both modes', () => {
const intent = { sessionId: sid('intent'), target: { kind: 'workspace' as const, workspaceId: wid('alpha') }, prompt: '', phase: 'connecting' as const }
const sessions = sessionState([], { intent, current: sid('intent') })
const b = mount({
useSessions: hook(sessions),
useWorkspaces: hook(workspaceState([workspace('alpha', [])])),
})
// Grouped: the current-group effect expands the target group.
expect(screen.getByText('New session')).toBeTruthy()
b.store.actions.setGroupBy('flat')
rerender(b, {})
expect(screen.getByText('New session')).toBeTruthy()
})
it('searches across groups, clears via the clear button, and shows the empty states', () => {
const sessions = sessionState([
summary('needle-row', 2, { displayTitle: 'Needle row' }),
summary('other-row', 1, { displayTitle: 'Other row' }),
])
mount({
useSessions: hook(sessions),
useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-row', 'other-row'])])),
})
const input = screen.getByPlaceholderText<HTMLInputElement>('Search name, keywords...')
fireEvent.change(input, { target: { value: 'needle' } })
// Search forces matches visible without expansion state.
expect(screen.getByText('Needle row')).toBeTruthy()
expect(screen.queryByText('Other row')).toBeNull()
fireEvent.change(input, { target: { value: 'zzz' } })
expect(screen.getByText('No matches')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Clear search' }))
expect(input.value).toBe('')
// Clicking the field row focuses the input (wide mode).
fireEvent.click(input.parentElement as HTMLElement)
expect(document.activeElement).toBe(input)
})
it('shows the no-sessions empty state in both modes', () => {
const b = mount()
expect(screen.getByText('No sessions yet')).toBeTruthy()
b.store.actions.setGroupBy('flat')
rerender(b, {})
expect(screen.getByText('No sessions yet')).toBeTruthy()
// Flat search misses show No matches.
fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'x' } })
expect(screen.getByText('No matches')).toBeTruthy()
})
it('rail state renders icon controls that request expansion', () => {
vi.useFakeTimers()
try {
const expandSidebar = vi.fn()
const b = mount({ wide: false, expandSidebar })
// No wide chrome in rail state.
expect(screen.queryByText('Workspaces')).toBeNull()
expect(screen.queryByPlaceholderText('Search name, keywords...')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
expect(expandSidebar).toHaveBeenCalledTimes(1)
// The wide flip mounts the input and focuses it after the slide.
rerender(b, { wide: true })
const input = screen.getByPlaceholderText('Search name, keywords...')
act(() => { vi.advanceTimersByTime(300) })
expect(document.activeElement).toBe(input)
// Wide search button is decorative (tabIndex -1, no expand call).
fireEvent.click(screen.getByRole('button', { name: 'Search sessions' }))
expect(expandSidebar).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
it('rail create-workspace expands the shell and opens the picker; wide toggles in place', () => {
const expandSidebar = vi.fn()
const b = mount({ wide: false, expandSidebar, useWorkspaces: hook(workspaceState([workspace('alpha', [])])) })
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
expect(expandSidebar).toHaveBeenCalledTimes(1)
rerender(b, { wide: true })
// The picker menu is open (anchored on the ); picking starts a session.
fireEvent.click(screen.getByRole('menuitem', { name: 'alpha' }))
expect(b.props.startSession).toHaveBeenCalledWith(wid('alpha'))
expect(screen.queryByRole('menu')).toBeNull()
// Wide toggle: open and close without expand requests.
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
expect(screen.getByRole('menu')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
expect(screen.queryByRole('menu')).toBeNull()
expect(expandSidebar).toHaveBeenCalledTimes(1)
// Escape closes the picker through its own onClose.
fireEvent.click(screen.getByRole('button', { name: 'Create workspace' }))
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByRole('menu')).toBeNull()
})
it('drag reorder reports the anchor to insertSessionBefore and skips no-op drops', () => {
const insertSessionBefore = vi.fn(async () => {})
const sessions = sessionState([summary('one', 3), summary('two', 2), summary('three', 1)])
mount({
useSessions: hook(sessions),
useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two', 'three'])])),
insertSessionBefore,
})
fireEvent.click(screen.getByText('alpha'))
const rows = screen.getAllByRole('treeitem').slice(1) // drop the group header
const [one, , three] = rows as [HTMLElement, HTMLElement, HTMLElement]
three.getBoundingClientRect = () => ({
top: 200, bottom: 234, left: 0, right: 200, width: 200, height: 34, x: 0, y: 200, toJSON: () => ({}),
} as DOMRect)
const dataTransfer = { effectAllowed: '', dropEffect: '' }
fireEvent.dragStart(one, { dataTransfer })
// Drop on the top half of "three": insert one before three.
fireDrag(three, 'dragOver', 205)
fireDrag(three, 'drop', 205)
expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('three'))
// Dropping right back onto its own position is a no-op — top half
// (anchor = itself) and bottom half (anchor = the next root) alike.
fireEvent.dragStart(one, { dataTransfer })
one.getBoundingClientRect = () => ({
top: 100, bottom: 134, left: 0, right: 200, width: 200, height: 34, x: 0, y: 100, toJSON: () => ({}),
} as DOMRect)
fireDrag(one, 'dragOver', 105)
fireDrag(one, 'drop', 105)
expect(insertSessionBefore).toHaveBeenCalledTimes(1)
fireEvent.dragStart(one, { dataTransfer })
fireDrag(one, 'drop', 130)
expect(insertSessionBefore).toHaveBeenCalledTimes(1)
})
it('still sends the reorder when the dragged row left the group mid-drag', () => {
const insertSessionBefore = vi.fn(async () => {})
const sessions = sessionState([summary('one', 2), summary('two', 1)])
const b = mount({
useSessions: hook(sessions),
useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])),
insertSessionBefore,
})
fireEvent.click(screen.getByText('alpha'))
const one = screen.getByText('one').closest('[role="treeitem"]') as HTMLElement
fireEvent.dragStart(one, { dataTransfer: { effectAllowed: '', dropEffect: '' } })
// The host dropped "one" from the workspace account while the drag is in
// flight: the source index is gone but the drop still resolves its anchor.
rerender(b, { useWorkspaces: hook(workspaceState([workspace('alpha', ['two'])])) })
const two = screen.getByText('two').closest('[role="treeitem"]') as HTMLElement
two.getBoundingClientRect = () => ({
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
} as DOMRect)
fireDrag(two, 'drop', 155)
expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), sid('two'))
})
it('drag end without a drop clears markers; bottom-half drop appends past the last row', () => {
const insertSessionBefore = vi.fn(async () => {})
const sessions = sessionState([summary('one', 2), summary('two', 1)])
mount({
useSessions: hook(sessions),
useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])),
insertSessionBefore,
})
fireEvent.click(screen.getByText('alpha'))
const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
two.getBoundingClientRect = () => ({
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
} as DOMRect)
const dataTransfer = { effectAllowed: '', dropEffect: '' }
fireEvent.dragStart(one, { dataTransfer })
fireEvent.dragEnd(one)
// The drag ended: rows no longer accept drops.
fireDrag(two, 'drop', 180)
expect(insertSessionBefore).not.toHaveBeenCalled()
// Bottom half of the last row: append (anchor omitted).
fireEvent.dragStart(one, { dataTransfer })
fireDrag(two, 'dragOver', 180)
fireDrag(two, 'drop', 180)
expect(insertSessionBefore).toHaveBeenCalledWith(wid('alpha'), sid('one'), undefined)
})
it('logs and keeps the order when the reorder call rejects', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const insertSessionBefore = vi.fn(async () => { throw new Error('stale anchor') })
const sessions = sessionState([summary('one', 2), summary('two', 1)])
mount({
useSessions: hook(sessions),
useWorkspaces: hook(workspaceState([workspace('alpha', ['one', 'two'])])),
insertSessionBefore,
})
fireEvent.click(screen.getByText('alpha'))
const [one, two] = screen.getAllByRole('treeitem').slice(1) as [HTMLElement, HTMLElement]
two.getBoundingClientRect = () => ({
top: 150, bottom: 184, left: 0, right: 200, width: 200, height: 34, x: 0, y: 150, toJSON: () => ({}),
} as DOMRect)
const dataTransfer = { effectAllowed: '', dropEffect: '' }
fireEvent.dragStart(one, { dataTransfer })
fireDrag(two, 'drop', 180)
await waitFor(() => { expect(warn).toHaveBeenCalledWith('session reorder rejected:', expect.any(Error)) })
} finally {
warn.mockRestore()
}
})
it('renames a workspace through the row menu dialog', async () => {
let resolveRename!: () => void
const renameWorkspace = vi.fn(() => new Promise<void>((resolve) => { resolveRename = resolve }))
mount({
useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha'), workspace('beta', [], 'Beta')])),
renameWorkspace,
})
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
const input = screen.getByLabelText<HTMLInputElement>('Workspace name')
expect(input.value).toBe('Alpha')
// Unchanged and blank names stay blocked.
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true)
fireEvent.change(input, { target: { value: ' ' } })
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true)
// A duplicate of another workspace's title shows the inline conflict.
fireEvent.change(input, { target: { value: ' Beta ' } })
expect(screen.getByRole('alert').textContent).toBe('A workspace named “Beta” already exists.')
expect((screen.getByRole('button', { name: 'Rename' }) as HTMLButtonElement).disabled).toBe(true)
fireEvent.change(input, { target: { value: 'Gamma' } })
fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Gamma')
// While renaming: input disabled, close blocked, Enter ignored.
expect(input.disabled).toBe(true)
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.getByRole('dialog')).toBeTruthy()
await act(async () => { resolveRename() })
expect(screen.queryByRole('dialog')).toBeNull()
})
it('rename via Enter, failure surfaces the error, Cancel closes', async () => {
const renameWorkspace = vi.fn(async () => { throw new Error('rename conflict') })
mount({
useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])),
renameWorkspace,
})
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
const input = screen.getByLabelText<HTMLInputElement>('Workspace name')
// Enter with a blocked draft (unchanged) does nothing.
fireEvent.keyDown(input, { key: 'Enter' })
expect(renameWorkspace).not.toHaveBeenCalled()
fireEvent.change(input, { target: { value: 'Renamed' } })
fireEvent.keyDown(input, { key: 'a' })
fireEvent.keyDown(input, { key: 'Enter' })
expect(renameWorkspace).toHaveBeenCalledWith(wid('alpha'), 'Renamed')
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('rename conflict') })
// The dialog stays for retry; typing clears the error; Cancel closes.
fireEvent.change(input, { target: { value: 'Renamed2' } })
expect(screen.queryByRole('alert')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.queryByRole('dialog')).toBeNull()
})
it('reports non-Error rename failures as text', async () => {
const renameWorkspace = vi.fn(async () => { throw 'denied' })
mount({
useWorkspaces: hook(workspaceState([workspace('alpha', [], 'Alpha')])),
renameWorkspace,
})
fireEvent.click(screen.getByRole('button', { name: 'Workspace actions for Alpha' }))
fireEvent.click(screen.getByRole('menuitem', { name: 'Rename' }))
fireEvent.change(screen.getByLabelText('Workspace name'), { target: { value: 'Other' } })
fireEvent.click(screen.getByRole('button', { name: 'Rename' }))
await waitFor(() => { expect(screen.getByRole('alert').textContent).toBe('denied') })
})
it('search hides drag affordances (rows are not draggable during search)', () => {
const sessions = sessionState([summary('needle-a', 2, { displayTitle: 'Needle A' })])
mount({
useSessions: hook(sessions),
useWorkspaces: hook(workspaceState([workspace('alpha', ['needle-a'])])),
})
fireEvent.change(screen.getByPlaceholderText('Search name, keywords...'), { target: { value: 'needle' } })
const row = screen.getByText('Needle A').closest('[role="treeitem"]') as HTMLElement
expect(row.getAttribute('draggable')).toBe('false')
})
})

View File

@@ -23,9 +23,12 @@ class TestSessionQueryService extends SessionQueryService {
}
override searchEvents(
..._args: Parameters<SessionQueryService['searchEvents']>
...args: Parameters<SessionQueryService['searchEvents']>
): ReturnType<SessionQueryService['searchEvents']> {
return Promise.resolve({ items: [] })
return this.readSurface(args[0].sessionId).then(surface => ({
session: surface.session,
items: [],
}))
}
}

View File

@@ -517,16 +517,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
jsDoc: '/**\n * Load a header and balanced contiguous log. A complete interrupted final\n * turn is preserved and durably closed with missing tool errors plus any open\n * step and turn boundaries; only a torn final record is discarded. Unknown\n * versions and corruption in the committed prefix reject. Implementations\n * MUST NOT crash-repair an identity still bound to a live Session: a balanced\n * live log may return with its stored header as a durable snapshot, while an\n * open live turn rejects.\n * A coordinator-backed cold load reserves the identity across storage awaits,\n * so concurrent publication of a same-id live Session rejects.\n * @param id - the persisted session to reload.\n * @returns the header and a log ending on a balanced `turn/end`.\n */',
},
{
signature: 'abstract inspect(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>',
jsDoc: '/**\n * Inspect a header and its valid contiguous stored prefix without repairing\n * a torn tail, closing an interrupted turn, or publishing coordinator state.\n * This read is serialized with writes for the same id and returns detached\n * values, so observers cannot mutate backend-owned state.\n * @param id - the persisted session to inspect.\n * @returns the header and valid stored event prefix exactly as observed.\n */',
signature: 'abstract inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }>',
jsDoc: '/**\n * Inspect a header and its valid contiguous stored prefix without repairing\n * a torn tail, closing an interrupted turn, or publishing coordinator state.\n * This read is serialized with writes for the same id and returns detached\n * values, so observers cannot mutate backend-owned state.\n * @param id - the persisted session to inspect.\n * @param signal - optional cancellation for queued and backend read work.\n * @returns the header and valid stored event prefix exactly as observed.\n */',
},
{
signature: 'abstract list(): Promise<SessionHeader[]>',
jsDoc: '/**\n * Lightweight listing from metadata, without a full-log parse.\n * @returns one header per materialized session.\n */',
signature: 'abstract list(signal?: AbortSignal): Promise<SessionHeader[]>',
jsDoc: '/**\n * Lightweight listing from metadata, without a full-log parse.\n * @param signal - optional cancellation for backend listing work.\n * @returns one header per materialized session.\n */',
},
{
signature: 'abstract listSnapshots(): Promise<SessionPersistenceSnapshot[]>',
jsDoc: '/**\n * List materialized sessions with cheap per-log change tokens.\n *\n * Repeated observations of an unchanged log return the same revision. A\n * successful mutating {@link load} repair changes the next listed revision.\n * Revisions also distinguish independently backed stores so backend-local\n * counters cannot compare equal across different persistence sources.\n * @returns one header and opaque revision per materialized session without loading full logs.\n */',
signature: 'abstract listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot[]>',
jsDoc: '/**\n * List materialized sessions with cheap per-log change tokens.\n *\n * Repeated observations of an unchanged log return the same revision. A\n * successful mutating {@link load} repair changes the next listed revision.\n * Revisions also distinguish independently backed stores so backend-local\n * counters cannot compare equal across different persistence sources.\n * @param signal - optional cancellation for backend snapshot-listing work.\n * @returns one header and opaque revision per materialized session without loading full logs.\n */',
},
],
},
@@ -539,24 +539,32 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
jsDoc: '/**\n * Search the live-preferred logical corpus and group by session.\n * @param request - query text, metadata filters, page size, and cursor.\n * @param exec - optional cancellation control.\n * @returns session hits ranked by their strongest matching event.\n */',
},
{
signature: 'abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, ): Promise<SessionSearchPage<SessionEventSearchHit>>',
jsDoc: '/**\n * Search events within one live-preferred logical session.\n * @param request - target session, query text, filters, page size, and cursor.\n * @param exec - optional cancellation control.\n * @returns matching event hits in deterministic relevance order.\n */',
signature: 'abstract searchEvents( request: SessionEventSearchRequest, exec?: SessionSearchExecContext, ): Promise<SessionEventSearchPage>',
jsDoc: '/**\n * Search events within one live-preferred logical session.\n * @param request - target session, query text, filters, page size, and cursor.\n * @param exec - optional cancellation control.\n * @returns matching event hits and their target header from one indexed generation.\n */',
},
{
signature: 'listSessions(): Promise<SessionRecord[]>',
jsDoc: '/**\n * List the complete logical corpus using live-preferred records.\n * @returns deterministic newest-first cloned session records.\n */',
signature: 'listSessions(signal?: AbortSignal): Promise<SessionRecord[]>',
jsDoc: '/**\n * List the complete logical corpus using live-preferred records.\n * @param signal - optional cancellation for persistence listing.\n * @returns deterministic newest-first cloned session records.\n */',
},
{
signature: 'async readSession(sessionId: SessionId): Promise<SessionLogSnapshot>',
jsDoc: '/**\n * Read and replay-validate one complete logical session log without making it live.\n * @param sessionId - live or persisted session id to read.\n * @returns cloned header and complete raw event log from one observation.\n * @throws when persistence, header compatibility, or replay validation fails.\n */',
},
{
signature: 'async filterSessions(filters: readonly SessionResultFilter[]): Promise<SessionRecord[]>',
jsDoc: '/**\n * Filter the complete logical corpus with provider-independent predicates.\n * @param filters - ANDed session metadata and availability clauses.\n * @returns matching cloned records in deterministic newest-first order.\n */',
signature: 'async filterSessions( filters: readonly SessionResultFilter[], signal?: AbortSignal, ): Promise<SessionRecord[]>',
jsDoc: '/**\n * Filter the complete logical corpus with provider-independent predicates.\n * @param filters - ANDed session metadata and availability clauses.\n * @param signal - optional cancellation for persistence listing.\n * @returns matching cloned records in deterministic newest-first order.\n */',
},
{
signature: 'async readTitle(sessionId: SessionId): Promise<SessionTitleSnapshot | undefined>',
jsDoc: '/**\n * Fold the latest log-backed title from one live-preferred logical session.\n * @param sessionId - live or persisted session id to read.\n * @returns latest title snapshot, or `undefined` when the log has no title event.\n */',
signature: 'async readTitle( sessionId: SessionId, signal?: AbortSignal, ): Promise<SessionTitleSnapshot | undefined>',
jsDoc: '/**\n * Fold the latest log-backed title from one live-preferred logical session.\n * @param sessionId - live or persisted session id to read.\n * @param signal - optional cancellation for source resolution and title folding.\n * @returns latest title snapshot, or `undefined` when the log has no title event.\n */',
},
{
signature: 'async readTitleSnapshot( sessionId: SessionId, signal?: AbortSignal, ): Promise<SessionTitleObservation>',
jsDoc: '/**\n * Fold the latest title and return its source header from one corpus observation.\n * @param sessionId - live or persisted session id to read.\n * @param signal - optional cancellation for source resolution and title folding.\n * @returns cloned source header and optional latest title snapshot.\n */',
},
{
signature: 'async readTitleSnapshots( sessionIds: readonly SessionId[], signal?: AbortSignal, ): Promise<SessionTitleObservationResult[]>',
jsDoc: '/**\n * Fold titles for unique sessions from one cancellable corpus observation.\n *\n * Results preserve first-occurrence input order. Operational failures stay\n * isolated per session, while cancellation rejects the complete operation.\n * @param sessionIds - live or persisted session ids to observe.\n * @param signal - optional cancellation shared by all source reads.\n * @returns one fulfilled or rejected result per unique requested id.\n */',
},
{
signature: 'async listEvents(sessionId: SessionId): Promise<SessionEventRecord[]>',
@@ -571,16 +579,16 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
jsDoc: '/**\n * Read one session\'s complete current model surface from one corpus observation.\n * @param sessionId - live-preferred session id to read.\n * @returns cloned header, current surface, and raw-log capture boundary.\n * @throws when source resolution fails or the session surface is invalid.\n */',
},
{
signature: 'async traceSession(sessionId: SessionId): Promise<SessionLineageTrace>',
jsDoc: '/**\n * Trace known ancestry and descendants from one corpus observation.\n * @param sessionId - logical session id to trace.\n * @returns a complete lineage or an explicit unresolved parent boundary.\n * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles.\n */',
signature: 'async traceSession(sessionId: SessionId, signal?: AbortSignal): Promise<SessionLineageTrace>',
jsDoc: '/**\n * Trace known ancestry and descendants from one corpus observation.\n * @param sessionId - logical session id to trace.\n * @param signal - optional cancellation for persistence listing.\n * @returns a complete lineage or an explicit unresolved parent boundary.\n * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles.\n */',
},
{
signature: 'async traceEvent(request: SessionEventTraceRequest): Promise<SessionEventTrace>',
jsDoc: '/**\n * Trace one event\'s direct positional and provenance relationships.\n * @param request - target session id and event seq.\n * @returns direct links plus the target\'s positional replacement chain.\n * @throws when source resolution fails, the target is absent, or surface/provenance validation fails.\n */',
signature: 'async traceEvent(request: SessionEventTraceRequest, signal?: AbortSignal): Promise<SessionEventTraceObservation>',
jsDoc: '/**\n * Trace one event\'s direct positional and provenance relationships.\n * @param request - target session id and event seq.\n * @param signal - optional cancellation for persisted source resolution.\n * @returns source header, direct links, and the target\'s positional replacement chain.\n * @throws when source resolution fails, the target is absent, or surface/provenance validation fails.\n */',
},
{
signature: 'async readEvent(request: SessionEventReadRequest): Promise<SessionEventWindow>',
jsDoc: '/**\n * Read one full event plus a bounded raw-log context window.\n * @param request - target session/seq and context sizes.\n * @returns cloned target and neighboring events.\n */',
signature: 'async readEvent(request: SessionEventReadRequest, signal?: AbortSignal): Promise<SessionEventWindow>',
jsDoc: '/**\n * Read one full event plus a bounded raw-log context window.\n * @param request - target session/seq and context sizes.\n * @param signal - optional cancellation for persisted source resolution.\n * @returns cloned target and neighboring events.\n */',
},
],
},
@@ -942,10 +950,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'list(): Workspace[]',
jsDoc: '/**\n * Synchronous workspace projection in durable registry order. Every\n * entity\'s `sessionIds` getter is already filtered by the startup/live\n * canonical-cwd header index; this method performs no persistence reads.\n * @returns a fresh ordered array of workspace entities.\n */',
},
{
signature: 'async touchSession(sessionId: SessionId): Promise<void>',
jsDoc: '/**\n * Move one accounted, cwd-validated session to the front of its workspace.\n * Ungrouped sessions and candidates filtered by the header check are\n * no-ops. The owning workspace\'s relative position never changes.\n * @param sessionId - Session whose activity was observed.\n * @returns resolution after the possible record write.\n */',
},
{
signature: 'async resolveByPath(path: string): Promise<Workspace | undefined>',
jsDoc: '/**\n * Resolve by canonical directory path without creating or mutating a\n * workspace. A missing path rejects during `realpath`; an existing unowned\n * directory returns `undefined`.\n * @param path - Existing directory path in any spelling.\n * @returns the workspace owning the canonical path, when one exists.\n */',
@@ -1972,6 +1976,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionEventSearchHit',
declaration: 'export interface SessionEventSearchHit extends SessionEventRecord {\n snippet: string;\n}',
},
{
name: 'SessionEventSearchPage',
declaration: 'export interface SessionEventSearchPage extends SessionSearchPage<SessionEventSearchHit> {\n session: SessionHeader;\n}',
},
{
name: 'SessionEventSearchRequest',
declaration: 'export interface SessionEventSearchRequest {\n sessionId: SessionId;\n query: string;\n filters?: readonly SessionEventMetadataFilter[];\n limit?: number;\n cursor?: SessionSearchCursor;\n}',
@@ -1984,6 +1992,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionEventTrace',
declaration: 'export interface SessionEventTrace {\n target: SessionEventRecord;\n replacedBy?: number;\n replacementChain: number[];\n replacedEventSeqs: number[];\n sourceEventSeqs: number[];\n derivedEventSeqs: number[];\n}',
},
{
name: 'SessionEventTraceObservation',
declaration: 'export interface SessionEventTraceObservation extends SessionEventTrace {\n session: SessionHeader;\n}',
},
{
name: 'SessionEventTraceRequest',
declaration: 'export interface SessionEventTraceRequest {\n sessionId: SessionId;\n seq: number;\n}',
@@ -2092,6 +2104,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionTitleModelProvenance',
declaration: 'export interface SessionTitleModelProvenance {\n readonly provider: string;\n readonly model: string;\n}',
},
{
name: 'SessionTitleObservation',
declaration: 'export interface SessionTitleObservation {\n session: SessionHeader;\n title?: SessionTitleSnapshot;\n}',
},
{
name: 'SessionTitleObservationResult',
declaration: 'export type SessionTitleObservationResult = {\n sessionId: SessionId;\n status: \'fulfilled\';\n value: SessionTitleObservation;\n} | {\n sessionId: SessionId;\n status: \'rejected\';\n reason: unknown;\n};',
},
{
name: 'SessionTitleProvider',
declaration: 'export interface SessionTitleProvider {\n readonly id: SessionTitleProviderId;\n readonly automatic: SessionTitleAutomaticMode;\n generate(request: SessionTitleProviderRequest): Promise<SessionTitleProviderResult>;\n}',
@@ -2526,7 +2546,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'Workspace',
declaration: 'export interface Workspace {\n readonly id: WorkspaceId;\n readonly path: string;\n readonly title: string;\n readonly createdAt: string;\n readonly updatedAt: string;\n readonly sessionIds: readonly SessionId[];\n setTitle(title: string): Promise<void>;\n attachSession(sessionId: SessionId): Promise<void>;\n detachSession(sessionId: SessionId): Promise<void>;\n status(): Promise<\'ok\' | \'missing-dir\'>;\n}',
declaration: 'export interface Workspace {\n readonly id: WorkspaceId;\n readonly path: string;\n readonly title: string;\n readonly createdAt: string;\n readonly updatedAt: string;\n readonly sessionIds: readonly SessionId[];\n setTitle(title: string): Promise<void>;\n attachSession(sessionId: SessionId): Promise<void>;\n insertSessionBefore(sessionId: SessionId, beforeSessionId?: SessionId): Promise<void>;\n detachSession(sessionId: SessionId): Promise<void>;\n status(): Promise<\'ok\' | \'missing-dir\'>;\n}',
},
]

View File

@@ -359,7 +359,7 @@ describe('config-driven session id', () => {
await ctx2.fiber.dispose()
})
it('config-driven resumeSessionId continues a persisted session (env-var resume)', async () => {
it('config-driven resumeSessionId continues a persisted session', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-resume-'))
dirs.push(root)

View File

@@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
const catalog = await collectToolCatalog()
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
for (const entry of catalog) {
for (const schema of entry.schemas) {

View File

@@ -5,12 +5,12 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling
| Package | npm name | Role |
|---|---|---|
| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin, with fallback session titles and an opt-in persisted-goal stack |
| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | Full-screen terminal app: the spine + persisted goals + `/goal` command + JSONL persistence + `dsh-tui` + a pre-created `main` agent, with a boot `bin` |
| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | Full-screen terminal app bundle: the spine + persisted goals + `/goal` command + JSONL persistence + `dsh-tui` + a pre-created `main` agent; no bin, booted by the [`dsh`](../../apps/cli/README.md) CLI |
| `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | Headless one-shot app: the spine + JSONL persistence + a pre-created `main` agent, with text and DSH-native JSON output |
| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP automation server app: the spine + persisted goals + JSONL persistence + the [`acp`](../acp/acp/README.md) bridge (no stdout logger), with a boot `bin` |
| `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | Bin-only runtime that boots an external `cordis.yml` for the stdio JSON-RPC SDK client |
`agent-spine-demo` is the shared bundle; `tui-demo`, `cli-demo`, and `acp-demo` compose it with full-screen terminal, headless one-shot, and ACP automation front doors and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches.
`agent-spine-demo` is the shared bundle; `tui-demo`, `cli-demo`, and `acp-demo` compose it with full-screen terminal, headless one-shot, and ACP automation front doors. `cli-demo` and `acp-demo` own their boot bins; `tui-demo` ships only the bundle plugin, and the product [`dsh`](../../apps/cli/README.md) CLI is its terminal front door. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches.
These are **not** product API. The spine pieces they bundle live in [`core/`](../core/README.md), human/SDK channels and boot glue in [`ui/`](../ui/README.md), the automation transport in [`acp/`](../acp/README.md), and swappable backends in their capability groups; a demo bundle just picks one concrete composition of them. Swap or fork one freely.

View File

@@ -9,9 +9,10 @@ ACP automation server app: the default agent spine, client-created agents throug
| `@deepseek-ai/dsh-agent-spine-demo` | Providerless agent spine with no pre-created agents; `session/new` creates each agent. |
| `@deepseek-ai/dsh-session-persistence-jsonl` | Durable session logs used by checkpointing, observability, and snapshot replay. |
| `@deepseek-ai/dsh-session-checkpoint-policy` | Durability barriers before model calls and top-level tool effects, plus completed-step checkpoints. |
| `@deepseek-ai/dsh-session-query-sqlite` | Derived exact/FTS session-query service, opened before the ACP transport so leaf consumers are ready for the first model request. |
| `@deepseek-ai/dsh-acp` | Automation-only ACP transport over stdin/stdout. |
The app does not install commands, user interaction, session navigation, configuration pickers, or a stdout logger. It owns the four plugins through one ordered effect so ACP sessions quiesce before checkpointing and persistence detach. Leaf configurations supply LLM, executor, sandbox, approval, filesystem, and model-facing tool plugins.
The app does not install commands, user interaction, session navigation, configuration pickers, or a stdout logger. It owns these plugins through one ordered effect so the query service is ready before ACP accepts work and ACP sessions quiesce before checkpointing and persistence detach. Leaf configurations supply LLM, executor, sandbox, approval, filesystem, and model-facing tool plugins.
## Config
@@ -25,7 +26,7 @@ The app does not install commands, user interaction, session navigation, configu
| `tools` | `{ mode: 'native' }` | Native, Code Mode, or combined model tool transport. |
| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home shared by bash and local skill discovery. |
| `sessionTitle` | spine example limits | Durable fallback-title limits; titles remain off the ACP wire. |
| `persistenceRoot` | `./.sessions` | JSONL backend root. |
| `persistenceRoot` | `./.sessions` | JSONL backend root and parent directory of the derived `session-query.db` index. |
| `packChunks` | `false` | Pack consecutive delta-chunk events in storage. |
| `persistenceCompression` | `zstd` | Checksummed Zstandard frames or raw `none`. |
| `workspaceContext` | required | Workspace-instruction byte budget/config, or `false`. |
@@ -35,7 +36,7 @@ The app does not install commands, user interaction, session navigation, configu
| `goals` | owner defaults | Persisted same-session goal domain and model tools, or `false`. |
| `llmRetry` | owner defaults | Bounded transient model-request retry policy. |
The shipped [`examples/acp-agent/cordis.yml`](../../../examples/acp-agent/cordis.yml) adds the DeepSeek adapter, sandboxed bash and filesystem providers, one-shot approval policy, compaction, subagents, workflows, hooks, and model-facing tools. Snapshot overlays replace only nondeterministic providers or policy values.
The shipped [`examples/acp-agent/cordis.yml`](../../../examples/acp-agent/cordis.yml) adds the DeepSeek adapter, sandboxed bash and filesystem providers, one-shot approval policy, compaction, subagents, workflows, hooks, and model-facing tools. The app supplies the derived session-query index, while the model-facing query consumer remains an explicit leaf opt-in. Snapshot overlays replace only nondeterministic providers or policy values.
## Bin

View File

@@ -43,6 +43,8 @@
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session-checkpoint-policy": "^0.0.1",
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
"@deepseek-ai/dsh-session-query": "^0.0.1",
"@deepseek-ai/dsh-session-query-sqlite": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-workspace-context": "^0.0.1",
"cordis": "^4.0.0-rc.7",
@@ -58,6 +60,8 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-session-query-sqlite": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",

View File

@@ -12,6 +12,7 @@
*/
import type { Context } from 'cordis'
import { join } from 'node:path'
import z from 'schemastery'
import * as acp from '@deepseek-ai/dsh-acp'
import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo'
@@ -22,6 +23,7 @@ import SessionPersistenceJsonl, {
type JsonlCompression,
} from '@deepseek-ai/dsh-session-persistence-jsonl'
import * as sessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy'
import SessionQuerySqlite from '@deepseek-ai/dsh-session-query-sqlite'
export const name = 'acp-demo'
const DEFAULT_PERSISTENCE_ROOT = './.sessions'
@@ -51,7 +53,7 @@ export interface Config {
dshHome?: string
/** Fallback session-title limits forwarded through agent-spine-demo. */
sessionTitle?: NonNullable<agentCore.Config['sessionTitle']>
/** Directory for JSONL sessions. Defaults to `./.sessions`. */
/** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */
persistenceRoot?: string
/** Write delta-chunk runs as packed storage rows (the JSONL backend's `packChunks`). Defaults to `false`. */
packChunks?: boolean
@@ -101,28 +103,39 @@ export const Config: z<Config> = z.object({
/**
* Compose the spine with the ACP automation transport. The agent-spine-demo bundle pre-creates
* NO agents (its `agents` list defaults to `[]`) and carries the deployment
* `persona`; the JSONL backend persists under
* `persona`; the JSONL backend and derived query index persist under
* `persistenceRoot`; the ACP bridge owns stdout for JSON-RPC and creates one
* agent per `session/new` from the provider/model pair. The composite effect
* unloads in reverse order, keeping checkpoint and persistence listeners
* attached until ACP agents have flushed their closing events. No logger, no
* `hmr` — stdout stays pure.
*/
export function apply(ctx: Context, config: Config): void {
export async function apply(ctx: Context, config: Config): Promise<void> {
const goals = config.goals ?? {}
const persistenceRoot = config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT
ctx.effect(function* () {
yield ctx.plugin(agentCore, { ...agentCore.pickSpineConfig(config), goals }).dispose
await ctx.effect(async function* () {
const spine = ctx.plugin(agentCore, { ...agentCore.pickSpineConfig(config), goals })
await spine
yield spine.dispose
// Same rationale as the Config schema above: each front door forwards its own
// persistence passthroughs rather than sharing a facade with stdio-demo.
/* jscpd:ignore-start */
yield ctx.plugin(SessionPersistenceJsonl, {
const persistence = ctx.plugin(SessionPersistenceJsonl, {
root: persistenceRoot,
...config.packChunks !== undefined ? { packChunks: config.packChunks } : {},
...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }),
}).dispose
})
await persistence
yield persistence.dispose
/* jscpd:ignore-end */
yield ctx.plugin(sessionCheckpointPolicy).dispose
yield ctx.plugin(acp, { provider: config.provider, model: config.model }).dispose
const checkpoint = ctx.plugin(sessionCheckpointPolicy)
await checkpoint
yield checkpoint.dispose
const query = ctx.plugin(SessionQuerySqlite, { path: join(persistenceRoot, 'session-query.db') })
await query
yield query.dispose
const transport = ctx.plugin(acp, { provider: config.provider, model: config.model })
await transport
yield transport.dispose
}, 'acp-demo.composition')
}

View File

@@ -30,9 +30,6 @@ async function mount(config: acpAgent.Config, withBash = false): Promise<Context
})
}
await ctx.plugin(acpAgent, config)
// The bundle mounts its children inside apply() (not awaited there); let their
// fibers settle so the spine services are ready.
await new Promise(resolve => setTimeout(resolve, 50))
return ctx
}
@@ -89,7 +86,7 @@ describe('dsh-acp-demo composition', () => {
expect(ctx.get('agents')).toBeDefined()
expect(ctx.get('sessions')).toBeDefined()
expect(ctx.get('sessionPersistence')).toBeDefined()
expect(ctx.get('sessionQuery')).toBeUndefined()
expect(ctx.get('sessionQuery')).toBeDefined()
expect(ctx.get('sessionReferences')).toBeUndefined()
expect((ctx.get('sessionPersistence') as unknown as { config: { compression?: string } }).config.compression).toBe('none')
expect(ctx.get('agentLoop')).toBeDefined()
@@ -122,8 +119,12 @@ describe('dsh-acp-demo composition', () => {
// persistenceRoot, so the runtime fallback is the one that fires.
const ctx = new Context()
// No persona: covers the omitted-persona forwarding branch too.
acpAgent.apply(ctx, { provider: 'mock', model: 'mock', skills: await isolatedSkillsConfig(), workspaceContext: false })
await new Promise(resolve => setTimeout(resolve, 50))
await acpAgent.apply(ctx, {
provider: 'mock',
model: 'mock',
skills: await isolatedSkillsConfig(),
workspaceContext: false,
})
expect(ctx.get('sessionPersistence')).toBeDefined()
await ctx.fiber.dispose()
})
@@ -144,8 +145,7 @@ describe('dsh-acp-demo composition', () => {
it('uses default skill config when apply is called directly without skills', async () => {
await withIsolatedSkillHomes(async () => {
const ctx = new Context()
acpAgent.apply(ctx, { provider: 'mock', model: 'mock', workspaceContext: false })
await new Promise(resolve => setTimeout(resolve, 50))
await acpAgent.apply(ctx, { provider: 'mock', model: 'mock', workspaceContext: false })
expect(ctx.skills).toBeDefined()
expect(await ctx.skills.list()).toEqual([])
await ctx.fiber.dispose()

View File

@@ -28,8 +28,8 @@ const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
// Repo root is four levels up from packages/examples/acp-demo/tests.
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
// A minimal leaf that loads this app + the two backends the same shape as
// examples/acp-agent/cordis.yml, inlined so the package test owns its fixture.
// A minimal opt-in leaf that loads this app + the two backends and the optional
// session-query consumer/policies, inlined so the package test owns its fixture.
const CORDIS_YML = `
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
@@ -44,6 +44,16 @@ const CORDIS_YML = `
model: deepseek-v4-flash
persona: 'You are a test agent.'
workspaceContext: false
- id: tool-session-query
name: '@deepseek-ai/dsh-tool-session-query'
- id: timeout-policy
name: '@deepseek-ai/dsh-timeout-policy'
- id: spill-local
name: '@deepseek-ai/dsh-spill-local'
- id: spill-policy
name: '@deepseek-ai/dsh-spill-policy'
config:
maxInlineBytes: 50000
`
interface Spawned {

View File

@@ -26,6 +26,12 @@
{
"path": "../../core/agent"
},
{
"path": "../../session-query/session-query"
},
{
"path": "../../session-query/session-query-sqlite"
},
{
"path": "../agent-spine-demo"
},

View File

@@ -3,8 +3,7 @@
"compilerOptions": {
"composite": true,
"rootDir": "src",
"outDir": "lib/types",
"tsBuildInfoFile": "../../../.typecheck/cli-demo.tsbuildinfo"
"outDir": "lib/types"
},
"include": ["src/**/*.ts"],
"references": [

View File

@@ -1,8 +1,8 @@
# @deepseek-ai/dsh-tui-demo
The full-screen terminal app: a Cordis plugin that composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), persisted same-session goals, the human-command registry and `/goal` producer, JSONL persistence, keyboard-backed user interaction, a pre-created `main` agent, and [`@deepseek-ai/dsh-tui`](../../ui/tui/README.md). Its `bin` boots a leaf `cordis.yml`.
The full-screen terminal app bundle: a Cordis plugin that composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), persisted same-session goals, the human-command registry and `/goal` producer, JSONL persistence, keyboard-backed user interaction, a pre-created `main` agent, and [`@deepseek-ai/dsh-tui`](../../ui/tui/README.md). A `cordis.yml` mounts it as one entry; the [`dsh`](../../../apps/cli/README.md) CLI is the front door that boots such a config.
Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and other non-interactive runs. This package requires a TTY pair and has no line-oriented fallback.
Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and other non-interactive runs. This bundle requires a TTY pair and has no line-oriented fallback.
## What it bakes in
@@ -13,7 +13,7 @@ Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and
| `@deepseek-ai/dsh-command-goal` | Direct `/goal` status and mutation over the spine's persisted-goal stack |
| `@deepseek-ai/dsh-session-persistence-jsonl` | Durable session log under `persistenceRoot` |
| `@deepseek-ai/dsh-session-checkpoint-policy` | Semantic durability barriers before model requests and top-level tool effects, plus completed-step checkpoints |
| `@deepseek-ai/dsh-session-query-sqlite` + `@deepseek-ai/dsh-session-reference` | Combined exact/FTS session queries and bounded `@session` snapshots consumed by the TUI |
| `@deepseek-ai/dsh-session-query-sqlite` + `@deepseek-ai/dsh-session-reference` | Combined exact/FTS session queries and bounded `@session` snapshots consumed by the TUI; model-facing query tools remain a leaf opt-in |
| `@deepseek-ai/dsh-user-interaction` | Provider-neutral human question service |
| `@deepseek-ai/dsh-tui` | Full-screen transcript, editor, tool cards, plan, and question overlays |
| `@deepseek-ai/dsh-tool-ask-user` | Model-facing `ask_user_question` tool |
@@ -47,9 +47,9 @@ Swappable LLM, bash, filesystem, and other capability providers remain in the le
Fresh runs mint a `main-session-<uuid>` session id and pass it to both the TUI and configured agent. Resumed runs bind both components to `resumeSessionId`. The TUI mounts before the spine so it can render a matching config-start failure instead of leaving a blank terminal. The app composes persistence and session query for `/resume`; an embedding host may additionally provide `tuiResumeHost` for in-place process handoff.
## The bin
## Front door
`dsh-tui-demo [path-to-cordis.yml]` defaults to `./cordis.yml`, loads the optional cwd `.env`, boots the Cordis Loader, and waits for the full plugin tree. The repository installs Loader's optional native helper, so bare package specifiers resolve under plain Node.
This package ships no bin. The [`dsh`](../../../apps/cli/README.md) CLI is the terminal front door: bare `dsh` boots the shipped `examples/tui-agent/cordis.yml` (which mounts this bundle), and `dsh --config <path-to-cordis.yml>` boots an alternate leaf config that mounts it. It loads the optional cwd `.env`, drives the Cordis Loader, and waits for the full plugin tree. The repository installs Loader's optional native helper, so bare package specifiers resolve under plain Node.
## Example leaf

View File

@@ -1,14 +1,11 @@
{
"name": "@deepseek-ai/dsh-tui-demo",
"description": "Full-screen terminal app: agent spine + persisted goals + human commands + JSONL persistence + pi-tui front door + pre-created main agent",
"description": "Full-screen TUI app bundle plugin: agent spine + persisted goals + human commands + JSONL persistence + pi-tui front door + pre-created main agent (mounted by the dsh CLI's config)",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"bin": {
"dsh-tui-demo": "lib/bin.js"
},
"exports": {
".": {
"types": "./lib/types/index.d.ts",
@@ -18,26 +15,19 @@
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./bin": {
"types": "./lib/types/bin.d.ts",
"default": "./lib/bin.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/bin.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@cordisjs/plugin-include": "^1.0.4",
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-app-boot": "^0.0.1",
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
"@deepseek-ai/dsh-commands": "^0.0.1",
@@ -60,9 +50,7 @@
"schemastery": "^3.17.0"
},
"devDependencies": {
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",

View File

@@ -1,27 +0,0 @@
#!/usr/bin/env node
/**
* Boot a TUI app from a leaf `cordis.yml`; usage is `dsh-tui-demo [config]`, defaulting to the
* cwd file. Shared `.env` loading, fail-loud Loader guards, and settled-tree boot live in
* dsh-app-boot. The tui-agent and cordis-agent demos invoke this bin with their own leaf configs.
* @module @deepseek-ai/dsh-tui-demo/bin
*/
import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
const NAME = 'dsh-tui-demo'
/* v8 ignore start -- thin self-executing composition over the unit-tested
dsh-app-boot helpers; exercised end-to-end by the tui-agent PTY smoke and
the built-bin fail-loud smoke */
// Refuse pipes BEFORE booting: a compose-time throw inside the Loader tree is
// logged per-entry rather than rethrown, so a piped launch would otherwise
// settle into an idle UI-less process instead of exiting nonzero.
if (!process.stdin.isTTY || !process.stdout.isTTY) {
process.stderr.write(`${NAME}: the TUI requires stdin and stdout to be interactive TTYs; `
+ 'use the one-shot dsh-cli-demo bin for pipes and automation\n')
process.exit(1)
}
installFailLoud(NAME)
loadEnv(NAME)
await boot(NAME, resolveConfigPath(process.argv[2] ?? './cordis.yml', undefined))
/* v8 ignore stop */

View File

@@ -64,8 +64,8 @@ export interface Config {
/**
* Shell command template the TUI prints on exit and lists under `/resume`,
* with `{session}` replaced by the live session id (forwarded to the front
* door). Set it to a command that resumes via this app's env var, e.g.
* `RESUME_SESSION_ID={session} dsh`.
* door). Set it to a command that resumes the session, e.g.
* `dsh --resume {session}`.
*/
resumeCommand?: string
/** Full-screen TUI presentation settings. */

View File

@@ -1,98 +0,0 @@
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { mkdtemp, mkdir, rm, symlink, readFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
/**
* Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer.
* The TUI app owns no non-TTY fallback, so the piped subprocess must refuse to boot with a
* nonzero exit and a stderr pointer at the one-shot CLI — the bin guards BEFORE the Loader
* because a compose-time throw inside the tree is logged per-entry, not rethrown. The consumer
* links only the bin's import chain (dsh-app-boot and its vendored Loader stack): the refusal
* fires before any config is read, so no plugin tree is needed. Missing-config fail-loud and
* full-boot coverage for the shared dsh-app-boot glue live in cli-demo's built-bin suite; it
* skips before build, and interactive TTY behavior is PTY-covered by examples/tui-agent (the
* one sanctioned PTY surface).
*/
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
const tuiBin = join(repoRoot, 'packages/examples/tui-demo/lib/bin.js')
// Symlink each package the bin imports at module load by package name so plain
// Node resolves its built `main`, matching an installed dependency rather than
// tsconfig paths.
const dshPackages = ['examples/tui-demo', 'ui/app-boot']
const vendorPackages = ['cordis', 'loader', 'include', 'schemastery', 'cosmokit']
async function pkgName(absDir: string): Promise<string> {
const json = JSON.parse(await readFile(join(absDir, 'package.json'), 'utf8')) as { name: string }
return json.name
}
/** Build a temporary external consumer with built workspace/vendor links. */
async function makeConsumer(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'tui-built-bin-'))
const nm = join(dir, 'node_modules')
for (const rel of dshPackages) {
const abs = join(repoRoot, 'packages', rel)
const target = join(nm, await pkgName(abs))
await mkdir(dirname(target), { recursive: true })
await symlink(abs, target)
}
for (const v of vendorPackages) {
const abs = join(repoRoot, 'vendor', v)
const target = join(nm, await pkgName(abs))
await mkdir(dirname(target), { recursive: true })
await symlink(abs, target)
}
return dir
}
/** Run the built bin in `cwd` with PIPED stdio; resolve with output + exit code. */
function runBuiltBin(cwd: string): Promise<{ stdout: string; code: number; stderr: string }> {
return new Promise((resolve, reject) => {
// NO tsx — this is the published `node lib/bin.js` path; the guard fires
// before the Loader resolves the config tree.
const child = spawn(process.execPath, [tuiBin, './cordis.yml'], {
cwd,
env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
stdio: ['pipe', 'pipe', 'pipe'],
})
let stdout = ''
let stderr = ''
child.stdout.setEncoding('utf8')
child.stdout.on('data', (c: string) => { stdout += c })
child.stderr.setEncoding('utf8')
child.stderr.on('data', (c: string) => { stderr += c })
const timer = setTimeout(() => {
child.kill('SIGKILL')
reject(new Error(`built bin did not exit within 25s. stdout:\n${stdout}\nstderr:\n${stderr}`))
}, 25_000)
child.on('exit', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) })
child.on('error', (err) => { clearTimeout(timer); reject(err) })
child.stdin.end()
})
}
let consumer: string | undefined
afterEach(async () => {
// Windows can briefly retain released handles after exit; retry removal.
if (consumer !== undefined) await rm(consumer, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
consumer = undefined
})
describe.skipIf(!existsSync(tuiBin))('dsh-tui-demo BUILT bin (node lib/bin.js, no tsx)', () => {
it('refuses pipes LOUD (non-zero exit + stderr) before booting the Loader', async () => {
consumer = await makeConsumer()
const { stdout, code, stderr } = await runBuiltBin(consumer)
expect(code).not.toBe(0)
expect(stderr).toContain('requires stdin and stdout to be interactive TTYs')
expect(stderr).toContain('dsh-cli-demo')
// The refusal happens before any plugin mounts: stdout stays silent.
expect(stdout).toBe('')
}, 30_000)
})

View File

@@ -14,12 +14,6 @@
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../../vendor/loader"
},
{
"path": "../../ui/app-boot"
},
{
"path": "../../core/agent"
},

View File

@@ -1,14 +1,14 @@
import { defineConfig } from 'tsdown'
/**
* tui-demo ships two entries: the plugin (`index`) and the CLI `bin`
* (`bin`), the latter referenced by package.json `bin`/`exports["./bin"]`.
* The root tsdown builds only `lib/types/index.js`, so this override adds
* `lib/types/bin.js`. Declarations come from `tsc -b` (dts: false),
* matching every package.
* tui-demo ships the plugin (`index`) and its invariant companion; the CLI
* front door is `dsh` (apps/cli), which mounts this bundle through its config.
* The root tsdown builds only `lib/types/index.js`, so this override adds the
* invariant entry. Declarations come from `tsc -b` (dts: false), matching
* every package.
*/
export default defineConfig({
entry: ['lib/types/index.js', 'lib/types/invariant.js', 'lib/types/bin.js'],
entry: ['lib/types/index.js', 'lib/types/invariant.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',

View File

@@ -14,7 +14,8 @@ import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace'
import {
workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId, WorkspaceNameConflictError,
workspaceDomainState, workspaceRecord, WorkspaceId as brandWorkspaceId,
WorkspaceMoveInvalidError, WorkspaceNameConflictError,
} from '@deepseek-ai/dsh-workspace'
// Type-only: brings the `ctx.tools` Context merge into this program (viewFor reads presenters).
import type {} from '@deepseek-ai/dsh-tools'
@@ -298,6 +299,15 @@ class SessionCwdConflict extends Error {
/** Host failed before the registry could adopt a name-created directory. */
class WorkspaceDirectoryCreationError extends Error {}
/** Shared workspace-not-found error response of the workspace.* mutation rows. */
function workspaceNotFound<T>(request: RpcRequest<unknown>, workspaceId: string): RpcResponse<T> {
return err(request, {
code: 'workspace-not-found',
message: `workspace "${workspaceId}" not found`,
details: { workspaceId },
})
}
/** Wire projection of one workspace entity (the workspace.* value row). */
function workspaceView(workspace: Workspace): WorkspaceView {
return {
@@ -680,6 +690,60 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}
},
async rename(request) {
const { payload } = request
const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId)
const title = payload.title.trim()
// Uniqueness AND the same-title no-op both ride the create chain so
// they observe the state left by earlier queued renames — checked
// up front, a queued A→A could report success while an earlier A→B
// still lands afterwards.
const operation = workspaceCreationChain.then(async () => {
if (title === workspace.title) return
if (ctx.workspace.list().some(other => other.id !== workspace.id && other.title === title)) {
throw new WorkspaceNameConflictError(title)
}
await workspace.setTitle(title)
})
workspaceCreationChain = operation.then(() => undefined, () => undefined)
try {
await operation
} catch (error: unknown) {
if (error instanceof WorkspaceNameConflictError) {
return err(request, {
code: 'workspace-name-conflict',
message: error.message,
details: { name: error.workspaceName },
})
}
throw error
}
return ok(request, { workspace: workspaceView(workspace) })
},
async insertSessionBefore(request) {
const { payload } = request
const workspace = ctx.workspace.get(brandWorkspaceId(payload.workspaceId))
if (workspace === undefined) return workspaceNotFound(request, payload.workspaceId)
try {
await workspace.insertSessionBefore(payload.sessionId, payload.beforeSessionId)
} catch (error: unknown) {
// Only the entity's unaccounted-id rejection is the business code;
// storage/durability failures propagate as internal errors.
if (!(error instanceof WorkspaceMoveInvalidError)) throw error
return err(request, {
code: 'workspace-move-invalid',
message: error.message,
details: {
workspaceId: payload.workspaceId,
sessionId: payload.sessionId,
...payload.beforeSessionId === undefined ? {} : { beforeSessionId: payload.beforeSessionId },
},
})
}
return ok(request, { workspace: workspaceView(workspace) })
},
},
host: {

View File

@@ -19,6 +19,8 @@ export interface RpcMethodMap {
'host.describe': HostApi['describe']
'workspace.list': WorkspaceApi['list']
'workspace.create': WorkspaceApi['create']
'workspace.rename': WorkspaceApi['rename']
'workspace.insertSessionBefore': WorkspaceApi['insertSessionBefore']
}
/** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */

View File

@@ -40,6 +40,7 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
z.object({ code: z.literal('workspace-not-found'), message: z.string(), details: z.object({ workspaceId: z.string() }) }),
z.object({ code: z.literal('workspace-invalid-path'), message: z.string(), details: z.object({ path: z.string() }) }),
z.object({ code: z.literal('workspace-name-conflict'), message: z.string(), details: z.object({ name: z.string() }) }),
z.object({ code: z.literal('workspace-move-invalid'), message: z.string(), details: z.object({ workspaceId: z.string(), sessionId: z.string(), beforeSessionId: z.string().optional() }) }),
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
]) as unknown as z.ZodType<RpcError>

View File

@@ -37,6 +37,7 @@ export interface RpcErrorDetailsMap {
'workspace-not-found': { workspaceId: string }
'workspace-invalid-path': { path: string }
'workspace-name-conflict': { name: string }
'workspace-move-invalid': { workspaceId: string; sessionId: SessionId; beforeSessionId?: SessionId }
'agent-busy': { reason: string }
'internal': {}
}

View File

@@ -44,3 +44,29 @@ export const workspaceCreateValueSchema = z.object({
workspace: workspaceViewSchema,
created: z.boolean(),
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.create'>>>
/** workspace.rename request payload: the new title must be non-blank. */
export const workspaceRenameRequestSchema = z.object({
workspaceId: workspaceIdSchema,
title: z.string(),
}).refine(
payload => payload.title.trim() !== '',
{ message: 'workspace.rename requires a non-blank title' },
) satisfies z.ZodType<Wire<RequestPayload<'workspace.rename'>>>
/** workspace.rename response value. */
export const workspaceRenameValueSchema = z.object({
workspace: workspaceViewSchema,
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.rename'>>>
/** workspace.insertSessionBefore request payload (anchor omitted = append to end). */
export const workspaceInsertSessionBeforeRequestSchema = z.object({
workspaceId: workspaceIdSchema,
sessionId: sessionIdSchema,
beforeSessionId: sessionIdSchema.optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'workspace.insertSessionBefore'>>>
/** workspace.insertSessionBefore response value. */
export const workspaceInsertSessionBeforeValueSchema = z.object({
workspace: workspaceViewSchema,
}) satisfies z.ZodType<Wire<ResponseValue<'workspace.insertSessionBefore'>>>

View File

@@ -24,7 +24,10 @@ export interface WorkspaceView {
path: string
/** Unique display title (defaults to the path basename at create). */
title: string
/** Sessions accounted under this workspace, newest-first for display. */
/**
* Sessions accounted under this workspace, in manually owned order
* (attach prepends, insertSessionBefore reorders; activity never does).
*/
sessionIds: SessionId[]
/** ISO-8601 creation instant. */
createdAt: string
@@ -52,4 +55,27 @@ export interface WorkspaceApi {
*/
create(request: RpcRequest<{ path?: string; name?: string }>):
Promise<RpcResponse<{ workspace: WorkspaceView; created: boolean }>>
/**
* Renames a workspace. `title` is trimmed and must be non-empty
* (schema-enforced). An unknown id fails with `workspace-not-found`; a
* title equal to another workspace's fails with `workspace-name-conflict`.
* Renaming to the current title is a no-op success (no durable write).
*/
rename(request: RpcRequest<{ workspaceId: WorkspaceId; title: string }>):
Promise<RpcResponse<{ workspace: WorkspaceView }>>
/**
* Moves an accounted session within its workspace's manual order,
* DOM-insertBefore-like: with `beforeSessionId` the session is inserted
* before that anchor; omitted appends to the end. An unknown workspace
* fails with `workspace-not-found`; a session or anchor not accounted by
* the workspace fails with `workspace-move-invalid`. A move to the current
* position is a no-op success.
*/
insertSessionBefore(request: RpcRequest<{
workspaceId: WorkspaceId
sessionId: SessionId
beforeSessionId?: SessionId
}>): Promise<RpcResponse<{ workspace: WorkspaceView }>>
}

View File

@@ -23,7 +23,9 @@ import {
} from '../api/sessions.schema.ts'
import {
workspaceCreateValueSchema,
workspaceInsertSessionBeforeValueSchema,
workspaceListValueSchema,
workspaceRenameValueSchema,
} from '../api/workspace.schema.ts'
/**
@@ -55,6 +57,8 @@ export interface IApiClient {
workspace: {
list(payload: RequestPayload<'workspace.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.list'>>>
create(payload: RequestPayload<'workspace.create'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.create'>>>
rename(payload: RequestPayload<'workspace.rename'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.rename'>>>
insertSessionBefore(payload: RequestPayload<'workspace.insertSessionBefore'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'workspace.insertSessionBefore'>>>
}
events: {
mux(payload: Parameters<ApiProxy['events']['mux']>[0]['payload'], signal: AbortSignal, onOpen?: () => void): AsyncIterable<RpcRequest<MuxFrame>>
@@ -77,6 +81,8 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'host.describe': hostDescribeValueSchema,
'workspace.list': workspaceListValueSchema,
'workspace.create': workspaceCreateValueSchema,
'workspace.rename': workspaceRenameValueSchema,
'workspace.insertSessionBefore': workspaceInsertSessionBeforeValueSchema,
}
/** Default unary timeout (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */
@@ -266,6 +272,8 @@ export abstract class AbstractApiClient implements IApiClient {
readonly workspace: IApiClient['workspace'] = {
list: (payload, signal) => this.callUnary('workspace.list', payload, signal),
create: (payload, signal) => this.callUnary('workspace.create', payload, signal),
rename: (payload, signal) => this.callUnary('workspace.rename', payload, signal),
insertSessionBefore: (payload, signal) => this.callUnary('workspace.insertSessionBefore', payload, signal),
}
readonly events: IApiClient['events'] = {

View File

@@ -24,7 +24,9 @@ import {
import { hostDescribeRequestSchema } from '../api/host.schema.ts'
import {
workspaceCreateRequestSchema,
workspaceInsertSessionBeforeRequestSchema,
workspaceListRequestSchema,
workspaceRenameRequestSchema,
} from '../api/workspace.schema.ts'
/**
@@ -50,6 +52,8 @@ const UNARY_ROUTES: UnaryRoutes = {
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
'workspace.list': { schema: workspaceListRequestSchema, invoke: (api, r) => api.workspace.list(r) },
'workspace.create': { schema: workspaceCreateRequestSchema, invoke: (api, r) => api.workspace.create(r) },
'workspace.rename': { schema: workspaceRenameRequestSchema, invoke: (api, r) => api.workspace.rename(r) },
'workspace.insertSessionBefore': { schema: workspaceInsertSessionBeforeRequestSchema, invoke: (api, r) => api.workspace.insertSessionBefore(r) },
}
/** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */

View File

@@ -37,6 +37,8 @@ function scriptedApi(overrides: {
workspace: {
list: r => ok(r, { items: [] }),
create: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' }, created: true }),
rename: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
insertSessionBefore: r => ok(r, { workspace: { workspaceId: 'w1' as never, path: '/t', title: 't', sessionIds: [], createdAt: '0', updatedAt: '0' } }),
},
events: { mux: () => empty<MuxFrame>(), host: () => empty<HostFrame>(), ...overrides.events },
respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })),
@@ -66,6 +68,19 @@ describe('unary round trip', () => {
expect(response.result).toEqual({ ok: true, value: { items: [{ sessionId: 's1', updatedAt: 7, running: false }] } })
})
it('routes workspace rename and insertSessionBefore through the wire', async () => {
const api = scriptedApi()
const c = client(api)
const renamed = await c.workspace.rename({ workspaceId: 'w1' as never, title: 'next' })
expect(renamed.result.ok).toBe(true)
const blankTitle = await c.workspace.rename({ workspaceId: 'w1' as never, title: ' ' })
expect(blankTitle.result).toMatchObject({ ok: false, error: { code: 'bad-request' } })
const anchored = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1'), beforeSessionId: sid('s2') })
expect(anchored.result.ok).toBe(true)
const appended = await c.workspace.insertSessionBefore({ workspaceId: 'w1' as never, sessionId: sid('s1') })
expect(appended.result.ok).toBe(true)
})
it('passes business errors through as 200 + err result, not a throw', async () => {
const api = scriptedApi({
sessions: {

View File

@@ -52,6 +52,18 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' }, created: true } },
}
},
async rename(request) {
return {
rpcId: request.rpcId,
result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } },
}
},
async insertSessionBefore(request) {
return {
rpcId: request.rpcId,
result: { ok: true, value: { workspace: { workspaceId: 'w1' as never, path: '/w', title: 'w', sessionIds: [], createdAt: 't', updatedAt: 't' } } },
}
},
},
events: {
mux: (_request, signal) => stream(muxFrames, signal),

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { RpcId } from '../src/api/rpc.ts'
import { RpcId, transportError } from '../src/api/rpc.ts'
import {
clientRequestSchema, clientResponseSchema, rpcErrorSchema, rpcIdSchema, rpcMessageSchema,
rpcReceiptSchema, rpcResultSchema, serverRequestSchema, serverResponseSchema,
@@ -13,8 +13,10 @@ import {
} from '../src/api/sessions.schema.ts'
import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts'
import {
workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema, workspaceListRequestSchema,
workspaceListValueSchema, workspaceViewSchema,
workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema,
workspaceInsertSessionBeforeRequestSchema, workspaceInsertSessionBeforeValueSchema,
workspaceListRequestSchema, workspaceListValueSchema,
workspaceRenameRequestSchema, workspaceRenameValueSchema, workspaceViewSchema,
} from '../src/api/workspace.schema.ts'
import { hostFrameSchema, muxFrameSchema, askUserQuestionItemSchema } from '../src/api/events.schema.ts'
import { approvalRequestIdSchema, approvalResponsePayloadSchema } from '../src/api/approvals.schema.ts'
@@ -30,6 +32,13 @@ describe('RpcId', () => {
})
})
describe('transportError', () => {
it('folds Error and non-Error throws into the internal error branch', () => {
expect(transportError(new Error('wire down'))).toEqual({ ok: false, error: { code: 'internal', message: 'wire down', details: {} } })
expect(transportError('raw')).toMatchObject({ ok: false, error: { code: 'internal', message: 'raw' } })
})
})
describe('rpcErrorSchema', () => {
it('accepts every code branch with its required details', () => {
expect(rpcErrorSchema.parse({ code: 'bad-request', message: 'm', details: { issues: [] } }).code).toBe('bad-request')
@@ -40,6 +49,7 @@ describe('rpcErrorSchema', () => {
expect(rpcErrorSchema.parse({ code: 'workspace-not-found', message: 'm', details: { workspaceId: 'w' } }).code).toBe('workspace-not-found')
expect(rpcErrorSchema.parse({ code: 'workspace-invalid-path', message: 'm', details: { path: '/x' } }).code).toBe('workspace-invalid-path')
expect(rpcErrorSchema.parse({ code: 'workspace-name-conflict', message: 'm', details: { name: 'x' } }).code).toBe('workspace-name-conflict')
expect(rpcErrorSchema.parse({ code: 'workspace-move-invalid', message: 'm', details: { workspaceId: 'w', sessionId: 's' } }).code).toBe('workspace-move-invalid')
expect(rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: { reason: 'r' } }).code).toBe('agent-busy')
expect(rpcErrorSchema.parse({ code: 'internal', message: 'm', details: {} }).code).toBe('internal')
})
@@ -154,6 +164,19 @@ describe('workspace domain schemas', () => {
expect(workspaceCreateValueSchema.parse({ workspace: view, created: false }).created).toBe(false)
})
it('rename requires a non-blank title (both refine arms)', () => {
expect(workspaceRenameRequestSchema.parse({ workspaceId: 'w1', title: 'new' }).title).toBe('new')
expect(() => workspaceRenameRequestSchema.parse({ workspaceId: 'w1', title: ' ' })).toThrow(/non-blank/)
expect(workspaceRenameValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1')
})
it('insertSessionBefore accepts an anchored and an anchorless move', () => {
expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1', beforeSessionId: 's2' }).beforeSessionId).toBe('s2')
expect(workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1', sessionId: 's1' }).beforeSessionId).toBeUndefined()
expect(() => workspaceInsertSessionBeforeRequestSchema.parse({ workspaceId: 'w1' })).toThrow()
expect(workspaceInsertSessionBeforeValueSchema.parse({ workspace: view }).workspace.workspaceId).toBe('w1')
})
})
describe('events frame schemas', () => {

View File

@@ -41,8 +41,11 @@
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-llm-mock-server": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^",

View File

@@ -0,0 +1,234 @@
import { createServer } from 'node:http'
import type { AddressInfo } from 'node:net'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import type { MockLlmBehavior, MockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server'
import { SessionId } from '@deepseek-ai/dsh-session'
import * as Retry from '../src/index.ts'
let context: Context | undefined
const servers: MockLlmServer[] = []
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
await Promise.all(servers.splice(0).map(server => server.close()))
})
async function start(
sequence: readonly MockLlmBehavior[],
options: Omit<Parameters<typeof startMockLlmServer>[0], 'sequence'> = {},
): Promise<MockLlmServer> {
const server = await startMockLlmServer({ sequence, ...options })
servers.push(server)
return server
}
async function harness(
baseURL: string,
options: { streamIdleTimeoutMs?: number; initialDelayMs?: number } = {},
): Promise<Context> {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(LlmDeepSeek, {
apiKey: 'mock-key',
baseURL,
streamIdleTimeoutMs: options.streamIdleTimeoutMs ?? 1_000,
})
await ctx.plugin(Retry, {
maxTransientRetries: 2,
initialDelayMs: options.initialDelayMs ?? 10,
maxDelayMs: options.initialDelayMs ?? 10,
jitterRatio: 0,
})
await ctx.plugin(AgentLoop, { agents: [] })
return ctx
}
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject !== agent || status !== 'idle') return
dispose()
resolve()
})
})
}
function sendAndWait(ctx: Context, agent: Agent): Promise<void> {
const idle = waitForIdle(ctx, agent)
agent.followup([{ type: 'text', text: 'recover through the provider boundary' }])
return idle
}
function finalAssistantText(agent: Agent): string | undefined {
const message = agent.session.deriveMessages().at(-1)
if (message?.role !== 'assistant') return undefined
return message.content
.filter(block => block.type === 'text')
.map(block => block.text)
.join('')
}
async function unusedPort(): Promise<number> {
const server = createServer()
await new Promise<void>((resolve) => { server.listen(0, '127.0.0.1', resolve) })
const port = (server.address() as AddressInfo).port
await new Promise<void>((resolve) => { server.close(() => { resolve() }) })
return port
}
describe('bounded retry through the real DeepSeek HTTP/SSE adapter', () => {
it('recovers from a true refused connection after the endpoint starts during backoff', async () => {
const port = await unusedPort()
context = await harness(`http://127.0.0.1:${port}`, { initialDelayMs: 100 })
const agent = context.agentLoop.create(SessionId('wire-refused'), {
provider: 'deepseek',
model: 'mock-model',
})
let recoveryServer: Promise<MockLlmServer> | undefined
context.on('session/event', (session, event) => {
if (session !== agent.session || event.type !== 'llm/retry' || event.data.retry !== 1) return
recoveryServer = start(['success'], { port, apiKey: 'mock-key', successText: 'connected after retry' })
})
await sendAndWait(context, agent)
const server = await recoveryServer
expect(server).toBeDefined()
expect(server?.requests).toHaveLength(1)
expect(agent.session.events.filter(event => event.type === 'step/start').map(event => event.data.step))
.toEqual([1, 2])
expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code))
.toEqual(['TRANSPORT'])
expect(finalAssistantText(agent)).toBe('connected after retry')
})
it.each([
['stream_disconnect', 0] as const,
['partial_disconnect', 2] as const,
])('retries %s without committing failed chunks', async (behavior, failedChunkCount) => {
const server = await start([behavior, 'success'], {
apiKey: 'mock-key',
partialText: 'discard me',
chunkSize: 100,
disconnectDelayMs: 20,
successText: 'recovered response',
})
context = await harness(server.baseURL)
const agent = context.agentLoop.create(SessionId(`wire-${behavior}`), {
provider: 'deepseek',
model: 'mock-model',
})
await sendAndWait(context, agent)
expect(server.requests).toHaveLength(2)
expect(server.requests[0]?.body).toEqual(server.requests[1]?.body)
expect(agent.session.events.filter(event =>
event.type === 'assistant/chunk' && event.data.step === 1,
)).toHaveLength(failedChunkCount)
expect(agent.session.events.filter(event => event.type === 'assistant/message').map(event => event.data.step))
.toEqual([2])
expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code))
.toEqual(['TRANSPORT'])
expect(finalAssistantText(agent)).toBe('recovered response')
})
it('retries a wire-valid content-less completion without committing an empty message', async () => {
const server = await start(['empty', 'success'], {
apiKey: 'mock-key',
successText: 'recovered from empty',
})
context = await harness(server.baseURL)
const agent = context.agentLoop.create(SessionId('wire-empty'), {
provider: 'deepseek',
model: 'mock-model',
})
await sendAndWait(context, agent)
expect(server.requests).toHaveLength(2)
expect(server.requests[0]?.body).toEqual(server.requests[1]?.body)
expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code))
.toEqual(['EMPTY_RESPONSE'])
expect(agent.session.events.filter(event => event.type === 'assistant/message').map(event => event.data.step))
.toEqual([2])
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'completed' } },
})
expect(finalAssistantText(agent)).toBe('recovered from empty')
})
it('exposes a clean partial EOF as non-default-retryable STREAM_CLOSED', async () => {
const server = await start(['partial_eof', 'success'], {
apiKey: 'mock-key',
partialText: 'discarded clean eof',
chunkSize: 100,
})
context = await harness(server.baseURL)
const agent = context.agentLoop.create(SessionId('wire-partial-eof'), {
provider: 'deepseek',
model: 'mock-model',
})
await sendAndWait(context, agent)
expect(server.requests).toHaveLength(1)
expect(agent.session.events.filter(event =>
event.type === 'assistant/chunk' && event.data.step === 1,
)).toHaveLength(2)
expect(agent.session.events.some(event => event.type === 'assistant/message')).toBe(false)
expect(agent.session.events.some(event => event.type === 'llm/retry')).toBe(false)
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'error', failure: { code: 'STREAM_CLOSED' } } },
})
})
it('turns a stalled body into TIMEOUT and succeeds on the next request', async () => {
const server = await start(['stall', 'success'], {
apiKey: 'mock-key',
successText: 'recovered after timeout',
})
context = await harness(server.baseURL, { streamIdleTimeoutMs: 30 })
const agent = context.agentLoop.create(SessionId('wire-stall'), {
provider: 'deepseek',
model: 'mock-model',
})
await sendAndWait(context, agent)
expect(server.requests.map(record => record.behavior)).toEqual(['stall', 'success'])
expect(agent.session.events.filter(event => event.type === 'llm/retry').map(event => event.data.failure.code))
.toEqual(['TIMEOUT'])
expect(finalAssistantText(agent)).toBe('recovered after timeout')
})
it('stops after the configured transport retry budget is exhausted', async () => {
const server = await start(['connection_reset', 'connection_reset', 'connection_reset'], {
apiKey: 'mock-key',
})
context = await harness(server.baseURL)
const agent = context.agentLoop.create(SessionId('wire-exhausted'), {
provider: 'deepseek',
model: 'mock-model',
})
await sendAndWait(context, agent)
expect(server.requests).toHaveLength(3)
expect(agent.session.events.filter(event => event.type === 'step/start')).toHaveLength(3)
expect(agent.session.events.filter(event => event.type === 'llm/retry')).toHaveLength(2)
expect(agent.session.events.at(-1)).toMatchObject({
type: 'turn/end',
data: { reason: { kind: 'error', failure: { code: 'TRANSPORT' } } },
})
})
})

View File

@@ -6,4 +6,4 @@ Plan mode is one logged, per-agent collaboration state. It is a single **product
|---|---|---|
| `plan-mode/` | `plan/mode` vocabulary + fold, boundary-applied state, the `plan:policy` guidance section, `/plan [message]` entry and `/plan off` exit, and the model-facing `exit_plan_mode` review tool | `ctx.planMode` |
The active state is a pure function of the session log, so resume and fork restore it without extra machinery. The deployment supplies plan instructions through Cordis config, while `exit_plan_mode` stays registered when planning is inactive to keep the request tool catalog stable. Interactive adapters use the plugin-owned `/plan` command; sandbox mode and approval policy remain independent enforcement settings. Design: [plan-mode Agent Note](../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md) and [plan-specific state simplification](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md).
The active state is a pure function of the session log, so resume and fork restore it without extra machinery. The deployment supplies plan instructions through Cordis config, while `exit_plan_mode` stays registered when planning is inactive to keep the request tool catalog stable. Interactive adapters use the plugin-owned `/plan` command; sandbox mode and approval policy remain independent enforcement settings. Design: [plan-specific collaboration state](../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md).

View File

@@ -29,7 +29,7 @@ The TUI consumes the plugin-owned `/plan` command; other front doors may drive t
`section` is required and non-empty. Unknown keys fail at load. The package does not accept arbitrary named modes, tool filters, sandbox settings, or approval policy.
Design: [plan-mode Agent Note](../../../.agents/notes/implemented/feature/2026-07-07-plan-mode.md) and [plan-specific state simplification](../../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md).
Design: [plan-specific collaboration state](../../../.agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md).
## Model Experience

View File

@@ -15,8 +15,7 @@
* The exit tool remains registered while plan mode is inactive so crossing a
* boundary changes only the prompt section, not the request tool catalog.
*
* Agent Notes:
* - .agents/notes/implemented/feature/2026-07-07-plan-mode.md
* Agent Note:
* - .agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md
*
* @module @deepseek-ai/dsh-plan-mode

View File

@@ -6,7 +6,7 @@ Local Linux/macOS `node-pty` backend for `ctx.pty`; loading it on another platfo
The plugin injects `pty`, `sandbox`, and `sandboxPolicy`, then registers the configured backend type (`shell`). `danger-full-access` starts the shell directly; confined modes wrap the exact shell argv through `ctx.sandbox`. The effective session mode is resolved at spawn. A change to a different effective mode is rejected before its `sandbox/mode` event commits while that owner has an open PTY or a spawn in progress; the fence is attached to the exact owner and therefore outlives a local-provider reload that retains existing sessions. Wait for creation to settle and close the sessions before changing modes, so a terminal opened with wider access cannot survive a downgrade.
Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks; when bash prints the marker before the kernel publishes its return to the foreground process group, polling retains the candidate until bash ownership is observable or the ordinary silence bound expires. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason even when its foreground process group is not observable yet; if that close fails, `PtyBackendCleanupError` separately preserves the cleanup failure for registry disposal. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
Linux readiness combines a foreground-verified private bash prompt marker, foreground-process-group syscall inspection, silence fallback, and absolute timeout. macOS uses the verified prompt marker plus silence/timeout because it has no `/proc` syscall surface. A marker is not ready until printable prompt text arrives, including when the OSC marker and `PS1` are split across data callbacks; when bash prints the marker before the kernel publishes its return to the foreground process group, polling retains the candidate through one final poll after the ordinary silence bound so a coincident handoff can win. An interactive child that inherits `PROMPT_COMMAND` therefore cannot suppress inferred-idle readiness until the absolute timeout. Unrecognized or unreadable process state is never a positive exact-idle signal. During unpublished startup, a fallback requires observed output; zero-output silence cannot publish an empty session, and timeout rejects the spawn. Cancellation closes the unpublished shell and rejects with the caller's exact abort reason even when its foreground process group is not observable yet; if that close fails, `PtyBackendCleanupError` separately preserves the cleanup failure for registry disposal. Incomplete terminal-control sequences are bounded by `maxReadBytes` and discarded through their terminator after crossing that limit; a trailing carriage return is carried across callbacks so split CRLF becomes one newline.
Send cancellation resolves the current foreground process group and delivers a real `SIGINT`; it never emulates interruption by writing `\x03`, so raw-mode programs remain cancellable. Close sends `SIGTERM` to descendants, waits, then sends `SIGKILL` to the union of captured survivors and newly scanned descendants so reparenting cannot hide a process from teardown. It verifies that every retained identity is gone or, on Linux, a non-executing zombie before stopping the shell; zombie entries are quiescent and are reaped as the shell exits. A survivor failure does not cache a permanently rejected close; a later close retries the teardown.

View File

@@ -331,8 +331,11 @@ export class LocalPtySession implements PtyBackendSession {
// A prompt candidate can race bash's foreground handoff, but an interactive
// child also inherits PROMPT_COMMAND. Silence therefore remains the bound
// on waiting for shell ownership instead of letting a child marker suppress
// readiness until the absolute timeout.
if (startupHasOutput && Date.now() - this.lastOutputAt >= this.config.idleSilenceMs) {
// readiness until the absolute timeout. One final poll lets a foreground
// handoff coincident with that boundary win before the fallback settles.
const idleFor = Date.now() - this.lastOutputAt
const handoffGrace = this.promptSeen ? this.config.pollIntervalMs : 0
if (startupHasOutput && idleFor >= this.config.idleSilenceMs && idleFor - this.config.idleSilenceMs >= handoffGrace) {
this.settleActive('inferred_idle')
return
}

View File

@@ -298,7 +298,7 @@ describe('LocalPtySession readiness and output', () => {
void operation.done.then(() => { settled = true })
inspector.pgid = 789
terminal.emitData('\x1b]133;D;0\x07dsh> ')
await vi.advanceTimersByTimeAsync(40)
await vi.advanceTimersByTimeAsync(50)
expect(settled).toBe(false)
inspector.pgid = 456

View File

@@ -41,7 +41,7 @@ A root belongs to one encoding. Startup discovery and targeted lookup reject the
- **Crash recovery — preserve valid tail work.** `load` validates every complete compressed frame and scans their decompressed JSONL. If the last frame is structurally incomplete, the reader keeps its complete decoded records, truncates from that frame's start, and re-encodes those records with the synthetic tool, step, and turn closers required by the shared [persistence contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). Raw mode truncates from its first incomplete line. A checksum/decompression failure in a complete frame, or a defect at or before the last committed `turn/end`, is corruption and rejects.
- **Non-mutating inspection.** `inspect()` returns the detached valid prefix without truncating an incomplete tail or closing an interrupted turn, and leaves the lightweight revision unchanged.
- **Contiguous-seq.** `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type.
- **Lightweight revisions.** `listSnapshots()` identifies a log by its device, inode, size, and nanosecond timestamps, avoiding a full-log parse while changing after append, repair, replacement, or store changes.
- **Lightweight revisions.** `listSnapshots(signal?)` identifies a log by its device, inode, size, and nanosecond timestamps, avoiding a full-log parse while changing after append, repair, replacement, or store changes. It forwards the exact signal through artifact discovery and checks cancellation around every `stat`; because filesystem `stat` is not interruptible, cancellation waits for the active call to settle, then rejects without starting another.
## Write path

View File

@@ -131,8 +131,8 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
return this.coordinator.load(id)
}
inspect(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
return this.coordinator.inspect(id)
inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
return this.coordinator.inspect(id, signal)
}
// One method serves both public `list` and the backend hook; delegating it to
@@ -142,24 +142,33 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
// --- PersistenceBackend hooks (the file-bytes storage primitives) ---
/** Read a stored prefix by id across all project directories when cwd is unknown. */
async loadStored(id: SessionId): Promise<StoredPrefix<JsonlTornMarker> | undefined> {
async loadStored(id: SessionId, signal?: AbortSignal): Promise<StoredPrefix<JsonlTornMarker> | undefined> {
signal?.throwIfAborted()
await this.ensureRootEncoding()
const path = await this.findLog(id)
signal?.throwIfAborted()
const path = await this.findLog(id, signal)
if (path === undefined) return undefined
return this.readPrefix(path, id)
return this.readPrefix(path, id, signal)
}
/**
* Read a stored prefix and convert torn-tail state to the opaque marker the
* coordinator can round-trip without knowing the physical encoding.
*/
private async readPrefix(path: string, expectedId?: SessionId): Promise<StoredPrefix<JsonlTornMarker>> {
const buffer = await readFile(path)
private async readPrefix(
path: string,
expectedId?: SessionId,
signal?: AbortSignal,
): Promise<StoredPrefix<JsonlTornMarker>> {
const buffer = await readFile(path, { signal })
signal?.throwIfAborted()
let prefix: StoredPrefix<JsonlTornMarker>
if (this.compression === 'zstd') {
prefix = await this.readZstdPrefix(buffer)
prefix = await this.readZstdPrefix(buffer, signal)
} else {
signal?.throwIfAborted()
const { meta, events, committedBytes } = scanLog(buffer)
signal?.throwIfAborted()
prefix = {
meta,
events,
@@ -168,30 +177,46 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
: {},
}
}
await this.assertStoredIdentity(path, prefix.meta, expectedId)
signal?.throwIfAborted()
await this.assertStoredIdentity(path, prefix.meta, expectedId, signal)
signal?.throwIfAborted()
return prefix
}
/** Decode complete frames and retain complete JSONL records from a torn final frame. */
private async readZstdPrefix(buffer: Buffer): Promise<StoredPrefix<JsonlTornMarker>> {
private async readZstdPrefix(
buffer: Buffer,
signal?: AbortSignal,
): Promise<StoredPrefix<JsonlTornMarker>> {
signal?.throwIfAborted()
const { frames, tornStart } = scanZstdFrames(buffer)
signal?.throwIfAborted()
if (frames.length === 0) throw new Error('empty or header-less Zstandard session log')
const plaintextFrames: Buffer[] = []
for (const frame of frames) {
let plaintext: Buffer
try {
plaintextFrames.push(await decompressZstdFrame(buffer.subarray(frame.start, frame.end)))
signal?.throwIfAborted()
plaintext = await decompressZstdFrame(buffer.subarray(frame.start, frame.end))
} catch (error) {
/* v8 ignore next -- decoder failure plus concurrent abort is timing-dependent */
if (signal?.aborted) signal.throwIfAborted()
throw new Error(`corrupt Zstandard session log: frame at byte ${frame.start} failed validation`, { cause: error })
}
signal?.throwIfAborted()
plaintextFrames.push(plaintext)
}
const headerFrame = plaintextFrames[0]
if (headerFrame === undefined || headerFrame.length === 0 || headerFrame.indexOf(0x0A) !== headerFrame.length - 1) {
throw new Error('corrupt Zstandard session log: first frame is not exactly one header line')
}
signal?.throwIfAborted()
const completePlaintext = Buffer.concat(plaintextFrames)
signal?.throwIfAborted()
const completePrefix = scanLog(completePlaintext)
signal?.throwIfAborted()
if (completePrefix.committedBytes !== completePlaintext.length) {
throw new Error('corrupt Zstandard session log: complete frame contains a torn JSONL record')
}
@@ -201,12 +226,17 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
let recoveredPlaintext: Buffer = Buffer.alloc(0)
try {
signal?.throwIfAborted()
recoveredPlaintext = await decompressZstdFrame(buffer.subarray(tornStart))
} catch {
/* v8 ignore next -- decoder failure plus concurrent abort is timing-dependent */
if (signal?.aborted) signal.throwIfAborted()
// A structurally incomplete final frame may end before Node's decoder can
// emit any plaintext; the complete prior frames remain recoverable.
}
signal?.throwIfAborted()
const recoveredPrefix = scanLog(Buffer.concat([completePlaintext, recoveredPlaintext]))
signal?.throwIfAborted()
/* v8 ignore next 3 -- appending plaintext cannot shorten the already-scanned complete prefix */
if (recoveredPrefix.events.length < completePrefix.events.length) {
throw new Error('corrupt Zstandard session log: recovered prefix does not extend complete frames')
@@ -247,16 +277,18 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
}
/** List valid unique stored sessions' metadata (header line only — no full-log parse). */
async list(): Promise<SessionHeader[]> {
return (await this.listArtifacts()).map(artifact => artifact.header)
async list(signal?: AbortSignal): Promise<SessionHeader[]> {
return (await this.listArtifacts(signal)).map(artifact => artifact.header)
}
/** List metadata plus a stat-derived identity for each append-only log. */
async listSnapshots(): Promise<SessionPersistenceSnapshot[]> {
async listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot[]> {
const snapshots: SessionPersistenceSnapshot[] = []
for (const artifact of await this.listArtifacts()) {
for (const artifact of await this.listArtifacts(signal)) {
signal?.throwIfAborted()
try {
const identity = await stat(artifact.path, { bigint: true })
signal?.throwIfAborted()
snapshots.push({
header: artifact.header,
revision: SessionPersistenceRevision([
@@ -268,30 +300,42 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
].join(':')),
})
} catch (error: unknown) {
signal?.throwIfAborted()
if (!isENOENT(error)) throw error
}
}
signal?.throwIfAborted()
return snapshots
}
private async listArtifacts(): Promise<Array<{ header: SessionHeader; path: string }>> {
private async listArtifacts(signal?: AbortSignal): Promise<Array<{ header: SessionHeader; path: string }>> {
signal?.throwIfAborted()
await this.ensureRootEncoding()
signal?.throwIfAborted()
const artifacts: Array<{ header: SessionHeader; path: string }> = []
const ids = new Set<SessionId>()
for (const project of await this.listProjectDirs()) {
for (const dir of await this.listSessionDirs(project)) {
for (const project of await this.listProjectDirs(signal)) {
signal?.throwIfAborted()
for (const dir of await this.listSessionDirs(project, signal)) {
signal?.throwIfAborted()
const opposite = join(dir, `session${logSuffix(this.oppositeCompression())}`)
if (await this.exists(opposite)) throw this.encodingMismatch(opposite)
const oppositeExists = await this.exists(opposite)
signal?.throwIfAborted()
if (oppositeExists) throw this.encodingMismatch(opposite)
const path = join(dir, `session${logSuffix(this.compression)}`)
if (!await this.exists(path)) continue
const pathExists = await this.exists(path)
signal?.throwIfAborted()
if (!pathExists) continue
// Read only headers so listing scales with session count, not log size.
const first = this.compression === 'zstd'
? await this.readFirstZstdLine(path)
: await this.readFirstLine(path)
? await this.readFirstZstdLine(path, signal)
: await this.readFirstLine(path, signal)
signal?.throwIfAborted()
if (first === undefined) continue // empty/half-written file
const meta = parseHeaderMeta(first)
if (meta === undefined) continue // not a session header
await this.assertStoredIdentity(path, meta)
await this.assertStoredIdentity(path, meta, undefined, signal)
signal?.throwIfAborted()
if (ids.has(meta.id)) {
throw new Error(`duplicate JSONL session id "${meta.id}" appears in multiple project directories`)
}
@@ -299,6 +343,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
artifacts.push({ header: meta, path })
}
}
signal?.throwIfAborted()
return artifacts
}
@@ -501,18 +546,23 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
* file. Returns undefined if the file is empty or has no complete first line.
* Reads in bounded chunks so a huge log costs only the header read.
*/
private async readFirstLine(path: string): Promise<string | undefined> {
private async readFirstLine(path: string, signal?: AbortSignal): Promise<string | undefined> {
signal?.throwIfAborted()
const handle = await open(path, 'r')
try {
signal?.throwIfAborted()
const chunks: Buffer[] = []
const buf = Buffer.alloc(8192)
for (;;) {
signal?.throwIfAborted()
const { bytesRead } = await handle.read(buf, 0, buf.length, null)
signal?.throwIfAborted()
if (bytesRead === 0) return undefined // EOF with no newline → no complete line
const slice = buf.subarray(0, bytesRead)
const nl = slice.indexOf(0x0a)
if (nl !== -1) {
chunks.push(slice.subarray(0, nl))
signal?.throwIfAborted()
return Buffer.concat(chunks).toString('utf8')
}
chunks.push(Buffer.from(slice))
@@ -523,23 +573,34 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
}
/** Read and validate only the independently compressed header frame. */
private async readFirstZstdLine(path: string): Promise<string | undefined> {
private async readFirstZstdLine(path: string, signal?: AbortSignal): Promise<string | undefined> {
signal?.throwIfAborted()
const handle = await open(path, 'r')
try {
signal?.throwIfAborted()
let content = Buffer.alloc(0)
const chunk = Buffer.alloc(8192)
for (;;) {
signal?.throwIfAborted()
const { bytesRead } = await handle.read(chunk, 0, chunk.length, null)
signal?.throwIfAborted()
if (bytesRead === 0) return undefined
signal?.throwIfAborted()
content = Buffer.concat([content, chunk.subarray(0, bytesRead)])
signal?.throwIfAborted()
const first = scanZstdFrames(content, 1).frames[0]
signal?.throwIfAborted()
if (first === undefined) continue
let plaintext: Buffer
try {
signal?.throwIfAborted()
plaintext = await decompressZstdFrame(content.subarray(first.start, first.end))
} catch (error) {
/* v8 ignore next -- decoder failure plus concurrent abort is timing-dependent */
if (signal?.aborted) signal.throwIfAborted()
throw new Error('corrupt Zstandard session log: header frame failed validation', { cause: error })
}
signal?.throwIfAborted()
if (plaintext.length === 0 || plaintext.indexOf(0x0A) !== plaintext.length - 1) {
throw new Error('corrupt Zstandard session log: first frame is not exactly one header line')
}
@@ -551,19 +612,26 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
}
/** Find the unique physical log for an id across every project directory. */
private async findLog(id: SessionId): Promise<string | undefined> {
private async findLog(id: SessionId, signal?: AbortSignal): Promise<string | undefined> {
const matches: string[] = []
for (const project of await this.listProjectDirs()) {
await this.rejectLegacyFlatArtifact(project, id)
for (const project of await this.listProjectDirs(signal)) {
signal?.throwIfAborted()
await this.rejectLegacyFlatArtifact(project, id, signal)
signal?.throwIfAborted()
const dir = join(project, encodeSegment(id))
const path = join(dir, `session${logSuffix(this.compression)}`)
const opposite = join(dir, `session${logSuffix(this.oppositeCompression())}`)
if (await this.exists(opposite)) throw this.encodingMismatch(opposite)
if (await this.exists(path)) matches.push(path)
const oppositeExists = await this.exists(opposite)
signal?.throwIfAborted()
if (oppositeExists) throw this.encodingMismatch(opposite)
const pathExists = await this.exists(path)
signal?.throwIfAborted()
if (pathExists) matches.push(path)
}
if (matches.length > 1) {
throw new Error(`duplicate JSONL session id "${id}" appears in multiple project directories`)
}
signal?.throwIfAborted()
return matches[0]
}
@@ -578,7 +646,13 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
}
/** Reject metadata that does not identify the selected physical log. */
private async assertStoredIdentity(path: string, meta: SessionHeader, expectedId?: SessionId): Promise<void> {
private async assertStoredIdentity(
path: string,
meta: SessionHeader,
expectedId?: SessionId,
signal?: AbortSignal,
): Promise<void> {
signal?.throwIfAborted()
if (expectedId !== undefined && meta.id !== expectedId) {
throw new Error(`corrupt session log "${path}": requested id "${expectedId}" does not match header id "${meta.id}"`)
}
@@ -588,9 +662,10 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
} catch (error) {
throw new Error(`corrupt session log "${path}": header id cannot name a storage path`, { cause: error })
}
if (path !== expectedPath && !await this.sameFile(path, expectedPath)) {
if (path !== expectedPath && !await this.sameFile(path, expectedPath, signal)) {
throw new Error(`corrupt session log "${path}": header id "${meta.id}" and cwd identify "${expectedPath}"`)
}
signal?.throwIfAborted()
}
/**
@@ -598,11 +673,14 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
* case aliases on case-insensitive filesystems without weakening identity
* checks on case-sensitive stores.
*/
private async sameFile(path: string, expectedPath: string): Promise<boolean> {
private async sameFile(path: string, expectedPath: string, signal?: AbortSignal): Promise<boolean> {
signal?.throwIfAborted()
try {
const [actual, expected] = await Promise.all([realpath(path), realpath(expectedPath)])
signal?.throwIfAborted()
return actual === expected
} catch (error) {
signal?.throwIfAborted()
/* v8 ignore else -- non-ENOENT realpath failures require an external permission or I/O fault */
if (isENOENT(error)) return false
/* v8 ignore next -- non-ENOENT realpath failures are external I/O faults, propagated unchanged */
@@ -611,9 +689,11 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
}
/** The human-readable project directories under the configured root. */
private async listProjectDirs(): Promise<string[]> {
private async listProjectDirs(signal?: AbortSignal): Promise<string[]> {
try {
signal?.throwIfAborted()
const entries = await readdir(this.root, { withFileTypes: true })
signal?.throwIfAborted()
return entries.filter(e => e.isDirectory()).map(e => join(this.root, e.name))
} catch (error) {
// Only an absent root means no sessions; rethrow every other I/O failure.
@@ -623,8 +703,10 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
}
/** List session-owned directories and reject the obsolete flat-file layout. */
private async listSessionDirs(project: string): Promise<string[]> {
private async listSessionDirs(project: string, signal?: AbortSignal): Promise<string[]> {
signal?.throwIfAborted()
const entries = await readdir(project, { withFileTypes: true })
signal?.throwIfAborted()
const legacy = entries.find(entry =>
entry.isFile() && (entry.name.endsWith('.jsonl') || entry.name.endsWith('.jsonl.zstd')))
if (legacy !== undefined) throw this.legacyLayout(join(project, legacy.name))
@@ -646,11 +728,18 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
}
}
private async rejectLegacyFlatArtifact(project: string, id: SessionId): Promise<void> {
private async rejectLegacyFlatArtifact(
project: string,
id: SessionId,
signal?: AbortSignal,
): Promise<void> {
signal?.throwIfAborted()
const encoded = encodeSegment(id)
for (const compression of ['zstd', 'none'] as const) {
const path = join(project, encoded + logSuffix(compression))
if (await this.exists(path)) throw this.legacyLayout(path)
const artifactExists = await this.exists(path)
signal?.throwIfAborted()
if (artifactExists) throw this.legacyLayout(path)
}
}

View File

@@ -275,6 +275,56 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
discovery.mockRestore()
})
it('forwards snapshot-list cancellation and awaits in-flight discovery cleanup', async () => {
const persistence = ctx.sessionPersistence as unknown as {
listArtifacts(signal?: AbortSignal): Promise<Array<{ header: SessionHeader; path: string }>>
}
const started = Promise.withResolvers<AbortSignal>()
const cleanup = Promise.withResolvers<undefined>()
vi.spyOn(persistence, 'listArtifacts').mockImplementation(async (signal) => {
if (signal === undefined) throw new Error('expected snapshot-list signal')
started.resolve(signal)
await cleanup.promise
return []
})
const reason = new Error('JSONL snapshot discovery cancelled')
const controller = new AbortController()
const pending = ctx.sessionPersistence.listSnapshots(controller.signal)
expect(await started.promise).toBe(controller.signal)
let settled = false
void pending.then(
() => { settled = true },
() => { settled = true },
)
controller.abort(reason)
await Promise.resolve()
expect(settled).toBe(false)
cleanup.resolve(undefined)
await expect(pending).rejects.toBe(reason)
})
it('checks cancellation after an uncancellable snapshot stat settles', async () => {
const m = meta('snapshot-stat-cancellation')
await ctx.sessionPersistence.create(m)
await ctx.sessionPersistence.append(m.id, oneTurnLog())
const persistence = ctx.sessionPersistence as unknown as {
listArtifacts(signal?: AbortSignal): Promise<Array<{ header: SessionHeader; path: string }>>
}
const discovery = vi.spyOn(persistence, 'listArtifacts').mockResolvedValue([{
header: m,
path: rawLogPath(root, m.cwd, m.id),
}])
const reason = new Error('JSONL snapshot stat cancelled')
const controller = new AbortController()
const pending = ctx.sessionPersistence.listSnapshots(controller.signal)
queueMicrotask(() => { controller.abort(reason) })
await expect(pending).rejects.toBe(reason)
expect(discovery).toHaveBeenCalledWith(controller.signal)
})
it('rejects a stored v0 log containing a legacy request/header-delta event', async () => {
const m = meta('legacy-header-delta', '/legacy')
const path = rawLogPath(root, m.cwd, m.id)

View File

@@ -16,6 +16,18 @@ const MAGIC = Buffer.from([0x28, 0xB5, 0x2F, 0xFD])
const roots: string[] = []
const contexts: Context[] = []
interface ZstdReaderInternals {
readZstdPrefix(buffer: Buffer, signal?: AbortSignal): Promise<unknown>
}
type HeaderRead = (
this: FileHandle,
buffer: Buffer,
offset: number,
length: number,
position: number | null,
) => Promise<{ bytesRead: number; buffer: Buffer }>
async function freshRoot(prefix = 'dsh-jsonl-zstd-'): Promise<string> {
const root = await mkdtemp(join(tmpdir(), prefix))
roots.push(root)
@@ -275,6 +287,65 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => {
await expect(ctx.sessionPersistence.load(header.id)).rejects.toThrow(/frame at byte .* failed validation/)
})
it('stops multi-frame inspection after cancellation interrupts the active decode', async () => {
const root = await freshRoot()
const ctx = await mount(root)
const header = meta('cancel-zstd-frames')
const headerFrame = await compressZstdFrame(`${JSON.stringify(toHeaderLine(header))}\n`)
const eventFrame = await compressZstdFrame(`${JSON.stringify(oneTurnLog()[0])}\n`)
const laterFrame = await compressZstdFrame(`${JSON.stringify(oneTurnLog()[1])}\n`)
const stream = Buffer.concat([headerFrame, eventFrame, laterFrame])
expect(scanZstdFrames(stream).frames).toHaveLength(3)
const controller = new AbortController()
const reason = new Error('cancel after Zstandard decode starts')
const reader = ctx.sessionPersistence as unknown as ZstdReaderInternals
const zstdModule = await import('../src/zstd.ts')
const decode = vi.spyOn(zstdModule, 'decompressZstdFrame')
// readZstdPrefix reaches its first asynchronous decompression before it
// returns this promise. The microtask abort therefore occurs after decode
// starts and must prevent every later frame from reaching the decoder.
const pending = reader.readZstdPrefix(stream, controller.signal)
queueMicrotask(() => { controller.abort(reason) })
await expect(pending).rejects.toBe(reason)
expect(decode).toHaveBeenCalledTimes(1)
expect(decode).toHaveBeenCalledWith(headerFrame)
})
it.each(['none', 'zstd'] as const)(
'observes cancellation after each async %s header read during listing',
async (compression) => {
const root = await freshRoot()
const ctx = await mount(root, compression)
const header = meta(`cancel-${compression}-header-read`, '/work')
await ctx.sessionPersistence.create(header)
await ctx.sessionPersistence.append(header.id, oneTurnLog())
await ctx.sessionPersistence.list()
const path = logPath(root, header.cwd, header.id, compression)
const probe = await open(path, 'r')
const prototype = Object.getPrototypeOf(probe) as { read: HeaderRead }
const originalRead = prototype.read
await probe.close()
const controller = new AbortController()
const reason = new Error(`cancel ${compression} header read`)
const read = vi.spyOn(prototype, 'read').mockImplementation(async function (
this: FileHandle,
buffer: Buffer,
offset: number,
length: number,
position: number | null,
) {
const result = await originalRead.call(this, buffer, offset, length, position)
controller.abort(reason)
return result
})
await expect(ctx.sessionPersistence.list(controller.signal)).rejects.toBe(reason)
expect(read).toHaveBeenCalledTimes(1)
},
)
it('preserves complete records from a torn frame and re-encodes them with crash closers', async () => {
const root = await freshRoot()
const ctx = await mount(root)

View File

@@ -20,7 +20,7 @@ On filesystems with POSIX modes, the backend requests mode `0700` for missing di
- **Lazy materialization.** `create()` records intent in memory only — no row is written until the first `append`. A created-but-never-appended session has no `sessions` row, so it is absent from `list()` (which reports exactly the sessions that have a row).
- **Interrupted-turn close on load.** `load()` implements the shared [crash-recovery contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md): preserve the valid interrupted turn, append its synthetic closing events in one transaction, and remove only a torn tail row. Committed parse errors or sequence gaps make the session unloadable. Because recovery mutates stored rows, the next append starts from a balanced log and accurate cursor.
- **Non-mutating inspection.** `inspect()` returns the detached valid row prefix without deleting a torn tail row or appending recovery closers, and leaves the lightweight revision unchanged.
- **Lightweight revisions.** `listSnapshots()` combines the immutable store and database-file identity, a per-materialization incarnation id, and a per-session counter incremented in each mutating transaction. This keeps unchanged observations stable without parsing event rows and distinguishes independent stores and recreated same-id logs.
- **Lightweight revisions.** `listSnapshots(signal?)` combines the immutable store and database-file identity, a per-materialization incarnation id, and a per-session counter incremented in each mutating transaction. This keeps unchanged observations stable without parsing event rows and distinguishes independent stores and recreated same-id logs. It checks cancellation before and after shared readiness and the synchronous metadata query; the query itself is non-preemptible.
## Configuration (schemastery)

View File

@@ -157,8 +157,8 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
return this.coordinator.load(id)
}
inspect(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
return this.coordinator.inspect(id)
inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
return this.coordinator.inspect(id, signal)
}
// One method serves both public `list` and the backend hook; delegating it to
@@ -167,8 +167,8 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
// --- PersistenceBackend hooks (the SQLite storage primitives) ---
/** Read a stored prefix by id (ids are globally unique — no scope to scan). */
loadStored(id: SessionId): Promise<StoredPrefix<number> | undefined> {
return this.readPrefix(id)
loadStored(id: SessionId, signal?: AbortSignal): Promise<StoredPrefix<number> | undefined> {
return this.readPrefix(id, signal)
}
/**
@@ -176,14 +176,17 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
* torn-tail marker is the seq from which a never-committed tail must be deleted
* (`scanRows` already returns it as `number | undefined`).
*/
private async readPrefix(id: SessionId): Promise<StoredPrefix<number> | undefined> {
private async readPrefix(id: SessionId, signal?: AbortSignal): Promise<StoredPrefix<number> | undefined> {
signal?.throwIfAborted()
await this.ready
signal?.throwIfAborted()
const row = this.rowFor(id)
if (row === undefined) return undefined
const meta = rowToMeta(row)
const eventRows = this.db
.prepare('SELECT seq, type, time, data, source_event_seqs, surface_op FROM events WHERE session_id = ? ORDER BY seq')
.all(id) as unknown as EventRow[]
signal?.throwIfAborted()
const { preserved, tornFrom } = scanRows(eventRows)
return { meta, events: preserved, ...tornFrom !== undefined ? { tornMarker: tornFrom } : {} }
}
@@ -251,18 +254,24 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers
}
/** List all materialized sessions' metadata (every row is a materialized session). */
async list(): Promise<SessionHeader[]> {
async list(signal?: AbortSignal): Promise<SessionHeader[]> {
signal?.throwIfAborted()
await this.ready
signal?.throwIfAborted()
const rows = this.db
.prepare('SELECT * FROM sessions')
.all() as unknown as SessionRow[]
signal?.throwIfAborted()
return rows.map(rowToMeta)
}
/** List metadata with a source-qualified monotonic revision per session. */
async listSnapshots(): Promise<SessionPersistenceSnapshot[]> {
async listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot[]> {
signal?.throwIfAborted()
await this.ready
signal?.throwIfAborted()
const rows = this.db.prepare('SELECT * FROM sessions').all() as unknown as SessionRow[]
signal?.throwIfAborted()
return rows.map(row => ({
header: rowToMeta(row),
revision: SessionPersistenceRevision(

View File

@@ -580,6 +580,31 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => {
await second.dispose()
})
it('awaits in-flight readiness before surfacing snapshot-list cancellation', async () => {
const b = await backend()
const internals = b.ctx.sessionPersistence as unknown as { ready: Promise<void> }
const originalReady = internals.ready
const readiness = Promise.withResolvers<undefined>()
internals.ready = readiness.promise
const reason = new Error('SQLite snapshot readiness cancelled')
const controller = new AbortController()
const pending = b.ctx.sessionPersistence.listSnapshots(controller.signal)
let settled = false
void pending.then(
() => { settled = true },
() => { settled = true },
)
controller.abort(reason)
await Promise.resolve()
expect(settled).toBe(false)
readiness.resolve(undefined)
await expect(pending).rejects.toBe(reason)
internals.ready = originalReady
await b.dispose()
})
it('exposes the schema version constant', () => {
expect(SCHEMA_VERSION).toBe(10)
})

View File

@@ -12,9 +12,9 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
| `create(meta): Promise<void>` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). |
| `append(id, events): Promise<void>` | Durably persist a batch. Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. |
| `load(id): Promise<{ meta; events }>` | Return a stored header plus a balanced contiguous log. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption and unknown `version` reject. |
| `inspect(id): Promise<{ meta; events }>` | Return a detached valid stored prefix without truncating a torn tail, synthesizing recovery closers, or publishing coordinator state. Serialized with same-id writes; intended for read models and other observers that must never recover a log. |
| `list(): Promise<SessionHeader[]>` | Lightweight listing from metadata, no full-log parse. A zero-event lazily-materialized session is absent from `list`. |
| `listSnapshots(): Promise<SessionPersistenceSnapshot[]>` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. |
| `inspect(id, signal?): Promise<{ meta; events }>` | Return a detached valid stored prefix without truncating a torn tail, synthesizing recovery closers, or publishing coordinator state. Serialized with same-id writes; the optional signal promptly rejects a queued caller, prevents that queued backend read from starting, and cancels active backend read work. Intended for read models and other observers that must never recover a log. |
| `list(signal?): Promise<SessionHeader[]>` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event lazily-materialized session is absent from `list`. |
| `listSnapshots(signal?): Promise<SessionPersistenceSnapshot[]>` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. The optional signal requests cancellation of backend discovery work; first-party backends settle any started listing work before rejecting so an awaited call is quiescent. |
## Invariants every backend must honor
@@ -33,17 +33,17 @@ Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative
When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state owned by that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, awaits per-id operations, and only then closes the storage handle.
The side-effect-free `locate` and lightweight `listSnapshots` queries remain backend-owned because they describe storage topology and revision identity rather than write orchestration.
The side-effect-free `locate` and lightweight `listSnapshots` queries remain backend-owned because they describe storage topology and revision identity rather than write orchestration. `listSnapshots(signal?)` passes the caller's exact signal into backend discovery so observers can cancel that work without detaching it.
The `PersistenceBackend<TornMarker>` hooks (the only seam between the coordinator and storage):
| Hook | Role |
|---|---|
| `name` | Backend label for the dispose-failure `AggregateError`. |
| `loadStored(id)` | Read a stored prefix by id across every storage scope. Used by resume/load, non-mutating inspect, live adoption, and the create-collision probe. Returned metadata identifies `id`; an opaque `tornMarker` is present iff a torn tail must be truncated. |
| `loadStored(id, signal?)` | Read a stored prefix by id across every storage scope. Used by resume/load, non-mutating inspect, live adoption, and the create-collision probe. The optional signal belongs to observation-only reads. Returned metadata identifies `id`; an opaque `tornMarker` is present iff a torn tail must be truncated. |
| `appendBatch(meta, events, isMaterialized)` | Durably append a contiguous batch, lazily materializing ATOMICALLY when not yet materialized. |
| `commitRepair(meta, tornMarker, closers)` | Make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined` — a marker may be falsy, e.g. seq/offset `0`) and append `closers`. NOT required to be atomic. Used by load (truncate + closers) and live-adoption (truncate only). |
| `list()` | List all stored metadata. |
| `list(signal?)` | List all stored metadata, observing optional cancellation. |
| `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. |
The coordinator asserts the stored id and compares stored/live cwd before repair or live adoption. Its `inspect()` path validates and clones the prefix without calling `commitRepair` or publishing write state. The `tornMarker` is fully OPAQUE: the coordinator only tests `!== undefined` and round-trips it to `commitRepair`, never inspecting its value (the JSONL backend uses the byte offset to truncate to, the SQLite backend the seq to delete from). A third-party backend MAY implement the abstract service directly without the coordinator, but it must provide the same non-mutating inspection and trustworthy lightweight snapshot revisions. See [the write-coordinator Agent Note](../../../.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md).

View File

@@ -40,8 +40,10 @@ export interface PersistenceBackend<TornMarker = unknown> {
* `id` before repair or state publication. Used by resume/load, live adoption,
* and — via `!== undefined` — the create-collision probe. The returned
* `tornMarker` is present iff there is a torn tail to truncate.
* @param id - persisted session id to resolve.
* @param signal - optional cancellation for backend read work.
*/
loadStored(id: SessionId): Promise<StoredPrefix<TornMarker> | undefined>
loadStored(id: SessionId, signal?: AbortSignal): Promise<StoredPrefix<TornMarker> | undefined>
/**
* Durably append a CONTIGUOUS batch, lazily materializing the session first
@@ -60,8 +62,11 @@ export interface PersistenceBackend<TornMarker = unknown> {
*/
commitRepair(meta: SessionHeader, tornMarker: TornMarker | undefined, closers: readonly SessionEvent[]): Promise<void>
/** List all stored (materialized) sessions' metadata. */
list(): Promise<SessionHeader[]>
/**
* List all stored (materialized) sessions' metadata.
* @param signal - optional cancellation for backend listing work.
*/
list(signal?: AbortSignal): Promise<SessionHeader[]>
/**
* Optional lifecycle teardown (e.g. close a database handle). Awaited by the
@@ -270,14 +275,26 @@ export class PersistenceCoordinator<TornMarker = unknown> {
* Read a detached valid stored prefix without recovery mutations or
* coordinator-state publication.
* @param id - persisted session to inspect.
* @param signal - optional cancellation for queued and backend read work.
* @returns stored header and events before any synthetic recovery closers.
*/
inspect(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
return this.serialize(id, () => this.inspectCore(id))
inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
return this.serialize(id, () => this.inspectCore(id, signal), signal)
}
private async inspectCore(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
const stored = await this.backend.loadStored(id)
private async inspectCore(
id: SessionId,
signal?: AbortSignal,
): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
signal?.throwIfAborted()
let stored: StoredPrefix<TornMarker> | undefined
try {
stored = await this.backend.loadStored(id, signal)
} catch (error: unknown) {
if (signal?.aborted) signal.throwIfAborted()
throw error
}
signal?.throwIfAborted()
if (stored === undefined) throw new Error(`session "${id}" not found`)
this.assertStoredId(id, stored.meta)
this.assertVersion(stored.meta)
@@ -334,9 +351,19 @@ export class PersistenceCoordinator<TornMarker = unknown> {
* public methods must NOT call each other (deadlock); they call the unserialized
* `*Core` helpers instead.
*/
private serialize<T>(id: SessionId, op: () => Promise<T> | T): Promise<T> {
private serialize<T>(
id: SessionId,
op: () => Promise<T> | T,
signal?: AbortSignal,
): Promise<T> {
const prior = this.chains.get(id) ?? Promise.resolve()
const next = prior.then(op, op)
let started = false
const run = (): Promise<T> | T => {
signal?.throwIfAborted()
started = true
return op()
}
const next = prior.then(run, run)
// Keep the chain alive but swallow this op's rejection for the NEXT waiter
// (the caller still sees the real rejection via `next`).
const tail = next.then(() => undefined, () => undefined)
@@ -346,7 +373,7 @@ export class PersistenceCoordinator<TornMarker = unknown> {
void tail.then(() => {
if (this.chains.get(id) === tail) this.chains.delete(id)
})
return next
return signal === undefined ? next : observeQueuedAbort(next, signal, () => started)
}
/** Build a state for a session discovered in storage but not yet in memory. */
@@ -618,3 +645,50 @@ export class PersistenceCoordinator<TornMarker = unknown> {
live.pending.splice(0, batch.length)
}
}
/**
* Give an observation caller a prompt cancellation view of queued work.
*
* The serialized `operation` remains in the same-id chain and checks the signal
* before invoking backend work. Observing its settlement here therefore cannot
* detach a storage read or let a later operation overtake its predecessor.
*/
function observeQueuedAbort<T>(
operation: Promise<T>,
signal: AbortSignal,
started: () => boolean,
): Promise<T> {
return new Promise<T>((resolve, reject) => {
let settled = false
const finish = (callback: () => void): void => {
if (settled) return
settled = true
signal.removeEventListener('abort', onAbort)
callback()
}
const onAbort = (): void => {
if (started()) return
finish(() => {
try {
signal.throwIfAborted()
} catch (reason: unknown) {
rejectObservation(reject, reason)
return
}
/* v8 ignore next -- a native AbortSignal emits abort only after becoming aborted */
reject(new Error('persistence observation abort event lacked an aborted signal'))
})
}
signal.addEventListener('abort', onAbort, { once: true })
operation.then(
(value) => { finish(() => { resolve(value) }) },
(reason: unknown) => { finish(() => { rejectObservation(reject, reason) }) },
)
if (signal.aborted) onAbort()
})
}
/** Preserve an exact provider or AbortSignal reason, including legacy non-Error values. */
function rejectObservation(reject: (reason?: unknown) => void, reason: unknown): void {
reject(reason)
}

View File

@@ -103,15 +103,17 @@ export abstract class SessionPersistence extends Service {
* This read is serialized with writes for the same id and returns detached
* values, so observers cannot mutate backend-owned state.
* @param id - the persisted session to inspect.
* @param signal - optional cancellation for queued and backend read work.
* @returns the header and valid stored event prefix exactly as observed.
*/
abstract inspect(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }>
abstract inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }>
/**
* Lightweight listing from metadata, without a full-log parse.
* @param signal - optional cancellation for backend listing work.
* @returns one header per materialized session.
*/
abstract list(): Promise<SessionHeader[]>
abstract list(signal?: AbortSignal): Promise<SessionHeader[]>
/**
* List materialized sessions with cheap per-log change tokens.
@@ -120,9 +122,10 @@ export abstract class SessionPersistence extends Service {
* successful mutating {@link load} repair changes the next listed revision.
* Revisions also distinguish independently backed stores so backend-local
* counters cannot compare equal across different persistence sources.
* @param signal - optional cancellation for backend snapshot-listing work.
* @returns one header and opaque revision per materialized session without loading full logs.
*/
abstract listSnapshots(): Promise<SessionPersistenceSnapshot[]>
abstract listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot[]>
}
export default SessionPersistence

View File

@@ -238,6 +238,23 @@ export function runPersistenceContract(name: string, make: () => Promise<Contrac
}
})
it('rejects pre-aborted observation reads with the exact cancellation reason', async () => {
const { persistence, dispose } = await make()
try {
const reason = new Error('persistence observation cancelled')
const controller = new AbortController()
await expect(persistence.listSnapshots(controller.signal)).resolves.toEqual([])
controller.abort(reason)
await expect(persistence.list(controller.signal)).rejects.toBe(reason)
await expect(persistence.listSnapshots(controller.signal)).rejects.toBe(reason)
await expect(persistence.inspect(SessionId('cancelled-inspect'), controller.signal))
.rejects.toBe(reason)
} finally {
await dispose()
}
})
it('lists stable lightweight revisions that change after an append', async () => {
const { persistence, dispose } = await make()
try {

View File

@@ -94,8 +94,8 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend
return this.coordinator.load(id)
}
inspect(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
return this.coordinator.inspect(id)
inspect(id: SessionId, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
return this.coordinator.inspect(id, signal)
}
// --- PersistenceBackend hooks (the Map storage primitives) ---
@@ -132,11 +132,13 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend
if (closers.length > 0) entry.events.push(...structuredClone(closers) as SessionEvent[])
}
async list(): Promise<SessionHeader[]> {
async list(signal?: AbortSignal): Promise<SessionHeader[]> {
signal?.throwIfAborted()
return [...this.store.values()].map(e => structuredClone(e.meta))
}
async listSnapshots(): Promise<SessionPersistenceSnapshot[]> {
async listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot[]> {
signal?.throwIfAborted()
return [...this.store.values()].map(entry => ({
header: structuredClone(entry.meta),
revision: SessionPersistenceRevision(`events:${entry.events.length}`),
@@ -153,10 +155,10 @@ class ControlledBackend implements PersistenceBackend<never> {
loadAttempts = 0
repairAttempts = 0
beforeAppend?: (attempt: number) => Promise<void>
beforeLoadStored?: (attempt: number) => Promise<void>
beforeLoadStored?: (attempt: number, signal?: AbortSignal) => Promise<void>
async loadStored(id: SessionId): Promise<StoredPrefix<never> | undefined> {
await this.beforeLoadStored?.(++this.loadAttempts)
async loadStored(id: SessionId, signal?: AbortSignal): Promise<StoredPrefix<never> | undefined> {
await this.beforeLoadStored?.(++this.loadAttempts, signal)
const entry = this.store.get(id)
if (entry === undefined) return undefined
return { meta: structuredClone(entry.meta), events: structuredClone(entry.events) }
@@ -348,6 +350,109 @@ describe('PersistenceCoordinator stored identity', () => {
})
})
describe('PersistenceCoordinator observation cancellation', () => {
it('promptly rejects a queued inspect without invoking it and keeps the same-id chain healthy', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const backend = new ControlledBackend()
const id = SessionId('queued-inspect-cancellation')
backend.store.set(id, { meta: meta(id), events: oneTurnLog() })
const loadGate = Promise.withResolvers<boolean>()
backend.beforeLoadStored = async (attempt) => {
if (attempt === 1) await loadGate.promise
}
let coordinator!: PersistenceCoordinator<never>
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
coordinator = new PersistenceCoordinator(inner, backend)
}, { inject: ['sessions'] }))
try {
const prior = coordinator.inspect(id)
await vi.waitFor(() => { expect(backend.loadAttempts).toBe(1) })
const controller = new AbortController()
const reason = new Error('queued inspect cancelled')
const queued = coordinator.inspect(id, controller.signal)
let observedReason: unknown
const observedAbort = queued.catch((error: unknown) => {
observedReason = error
})
controller.abort(reason)
await vi.waitFor(() => { expect(observedReason).toBe(reason) })
expect(backend.loadAttempts).toBe(1)
const subsequent = coordinator.inspect(id)
expect(backend.loadAttempts).toBe(1)
loadGate.resolve(true)
await expect(prior).resolves.toMatchObject({ meta: { id } })
await observedAbort
await expect(subsequent).resolves.toMatchObject({ meta: { id } })
expect(backend.loadAttempts).toBe(2)
await vi.waitFor(() => {
expect((coordinator as unknown as CoordinatorInternals).chains.size).toBe(0)
})
} finally {
loadGate.resolve(true)
await fiber.dispose()
await ctx.fiber.dispose()
}
})
it('waits for active cooperative inspection cleanup before rejecting cancellation', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const backend = new ControlledBackend()
const id = SessionId('active-inspect-cancellation')
backend.store.set(id, { meta: meta(id), events: oneTurnLog() })
const cleanupGate = Promise.withResolvers<boolean>()
let cleanupComplete = false
backend.beforeLoadStored = async (_attempt, signal) => {
await new Promise<void>((resolve) => {
signal?.addEventListener('abort', () => {
void cleanupGate.promise.then(() => {
cleanupComplete = true
resolve()
})
}, { once: true })
})
throw new Error('backend cancellation after cleanup')
}
let coordinator!: PersistenceCoordinator<never>
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
coordinator = new PersistenceCoordinator(inner, backend)
}, { inject: ['sessions'] }))
try {
const controller = new AbortController()
const reason = new Error('active inspect cancelled')
const pending = coordinator.inspect(id, controller.signal)
let observedReason: unknown
const observed = pending.catch((error: unknown) => {
observedReason = error
})
await vi.waitFor(() => { expect(backend.loadAttempts).toBe(1) })
controller.abort(reason)
await Promise.resolve()
expect(observedReason).toBeUndefined()
expect(cleanupComplete).toBe(false)
cleanupGate.resolve(true)
await observed
expect(cleanupComplete).toBe(true)
expect(observedReason).toBe(reason)
const backendFailure = new Error('later inspection failure')
backend.beforeLoadStored = () => Promise.reject(backendFailure)
await expect(coordinator.inspect(id)).rejects.toBe(backendFailure)
} finally {
cleanupGate.resolve(true)
await fiber.dispose()
await ctx.fiber.dispose()
}
})
})
describe('PersistenceCoordinator retirement', () => {
it('a retiring unmaterialized owner without buffered events releases its id', async () => {
const ctx = new Context()

View File

@@ -6,5 +6,6 @@ Trusted exact reads, relationship traces, provider-independent semantic filterin
|---|---|---|
| [`session-query/`](session-query/README.md) | Combined service contract with concrete logical-corpus reads, traces, and semantic filters plus abstract full-text methods | `ctx.sessionQuery` |
| [`session-query-sqlite/`](session-query-sqlite/README.md) | Concrete service backend with SQLite FTS5 persistent bases and live overlays | `ctx.sessionQuery` |
| [`tool-session-query/`](tool-session-query/README.md) | Workspace-authorized model-facing search, lineage, relationship, and exact event tools | — |
The family is independent of compaction: it reads canonical lineage, surface operations, logged provenance, and semantic event text but does not participate in compaction policy or execution. One abstract service combines every query operation, and one concrete backend owns the full-text lifecycle without a provider registry or coordinator.
The query service is independent of compaction: it reads canonical lineage, surface operations, logged provenance, and semantic event text but does not participate in compaction policy or execution. One abstract service combines every query operation, one concrete backend owns the full-text lifecycle without a provider registry or coordinator, and the consumer leaves oversized plain-text results to the generic post-execute spill policy.

View File

@@ -28,12 +28,13 @@ The database is disposable but reset is guarded: every recognized schema version
| `maxLimit` | `100` | Largest accepted request page size; at most `Number.MAX_SAFE_INTEGER - 1`. |
| `snippetChars` | `240` | Maximum snippet length in Unicode code points. |
| `readWindowMax` | `50` | Maximum `before` or `after` raw-event count for inherited `readEvent()`. |
| `persistedInspectConcurrency` | `4` | Maximum concurrent persisted-log inspections for inherited batch reads; must be a positive safe integer. |
## Tokenizer and limits
The index uses FTS5 `unicode61`. In the implementation experiment it supported the two-character query `AI` and produced an index about 2.1× smaller than the trigram alternative. The trade-off is token/phrase recall rather than arbitrary substring recall: `AI` does not match the token `BRAID`. Use `ctx.sessionQuery.filterEvents()` with a `text` clause when a literal whitespace-flexible substring scan is required. NUL is rejected in queries; reserved highlight markers and NUL in documents are normalized before indexing so presentation markers cannot collide with source text.
Abort signals stop queued work and caller waits around asynchronous source observation. Node's synchronous `DatabaseSync` API cannot interrupt a MATCH statement already executing on the JavaScript thread; the signal is checked immediately before and after the serialized observation/reconciliation boundary.
Abort signals stop queued work and flow unchanged through snapshot listing and non-mutating inspection. Once source work starts, the serialized state machine awaits that backend promise itself—even when a backend ignores cancellation—then checks the signal before starting any further listing, inspection, reconciliation, or query work. The caller therefore observes cancellation only after started backend work is quiescent, and a later search cannot enter the serializer while that cleanup is pending. Node's synchronous `DatabaseSync` API cannot interrupt a metadata or MATCH statement already executing on the JavaScript thread; signals are checked immediately before and after those non-preemptible calls.
## Model Experience

View File

@@ -15,6 +15,7 @@ import type {
SessionPersistenceSnapshot,
} from '@deepseek-ai/dsh-session-persistence'
import SessionQueryService, {
SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY,
SESSION_QUERY_READ_WINDOW_MAX,
SessionQueryError,
SessionSearchCursor,
@@ -25,6 +26,7 @@ import type {
Config as SessionQueryConfig,
SessionEventSearchDocument,
SessionEventSearchHit,
SessionEventSearchPage,
SessionEventSearchRequest,
SessionSearchExecContext,
SessionSearchHit,
@@ -86,6 +88,8 @@ export interface Config extends SessionQueryConfig {
maxLimit?: number
/** Maximum snippet length in Unicode code points. Defaults to 240. */
snippetChars?: number
/** Maximum concurrent persisted-log inspections in one inherited batch read. Defaults to 4. */
persistedInspectConcurrency?: number
}
interface ResolvedConfig {
@@ -95,6 +99,7 @@ interface ResolvedConfig {
maxLimit: number
snippetChars: number
readWindowMax: number
persistedInspectConcurrency: number
}
interface ObservedSession {
@@ -133,7 +138,7 @@ interface IndexedLiveRow {
generation: number
}
interface SearchRow {
interface SessionHeaderRow {
session_id: string
version: number
created_at: number
@@ -141,6 +146,9 @@ interface SearchRow {
parent_session: string | null
seed_length: number | null
delegation_depth: number | null
}
interface SearchRow extends SessionHeaderRow {
live: number
persisted: number
seq: number
@@ -172,6 +180,11 @@ export class SessionQuerySqlite extends SessionQueryService {
maxLimit: z.number().step(1).min(1).max(SQLITE_MAX_PAGE_LIMIT).default(SESSION_QUERY_SQLITE_MAX_LIMIT),
snippetChars: z.number().step(1).min(1).default(SESSION_QUERY_SQLITE_SNIPPET_CHARS),
readWindowMax: z.number().step(1).min(0).default(SESSION_QUERY_READ_WINDOW_MAX),
persistedInspectConcurrency: z.number()
.step(1)
.min(1)
.max(Number.MAX_SAFE_INTEGER)
.default(SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY),
})
/** Validated and defaulted backend configuration. */
@@ -247,27 +260,30 @@ export class SessionQuerySqlite extends SessionQueryService {
override async searchEvents(
request: SessionEventSearchRequest,
exec?: SessionSearchExecContext,
): Promise<SessionSearchPage<SessionEventSearchHit>> {
): Promise<SessionEventSearchPage> {
const normalized = normalizeEventRequest(request, this.config)
const signal = exec?.signal
return this._serialized(signal, async () => {
await this._ensureReady(signal)
const persistenceBinding = await this._reconcile(signal)
assertNotAborted(signal)
const generation = this._targetGeneration(normalized.sessionId, persistenceBinding)
const target = this._targetObservation(normalized.sessionId, persistenceBinding)
const fingerprint = requestFingerprint(normalized)
const offset = normalized.cursor === undefined
? 0
: decodeCursor(normalized.cursor, this._instance, 'events', fingerprint, generation)
: decodeCursor(normalized.cursor, this._instance, 'events', fingerprint, target.generation)
const rows = this._queryEvents(normalized, offset, persistenceBinding)
return page(rows, normalized.limit, row => this._eventHit(row), cursorOffset => encodeCursor({
version: 1,
instance: this._instance,
scope: 'events',
fingerprint,
generation,
offset: cursorOffset,
}), offset)
return {
session: target.header,
...page(rows, normalized.limit, row => this._eventHit(row), cursorOffset => encodeCursor({
version: 1,
instance: this._instance,
scope: 'events',
fingerprint,
generation: target.generation,
offset: cursorOffset,
}), offset),
}
})
}
@@ -336,6 +352,7 @@ export class SessionQuerySqlite extends SessionQueryService {
}
private async _reconcile(signal: AbortSignal | undefined): Promise<PersistenceBinding> {
assertNotAborted(signal)
const db = this._requireDb()
const persistedRows = db.prepare(
'SELECT id, revision, generation FROM persisted_sessions',
@@ -436,7 +453,8 @@ export class SessionQuerySqlite extends SessionQueryService {
try {
const canReuseIndexed = this._lastPersistenceIdentity === undefined
|| this._lastPersistenceIdentity === persistenceBinding.identity
const before = await waitWithAbort(persistence.listSnapshots(), signal)
const before = await persistence.listSnapshots(signal)
assertNotAborted(signal)
persisted = materializePersistenceSnapshots(before)
for (const entry of persisted.values()) {
if (canReuseIndexed && indexed.get(entry.header.id)?.revision === entry.revision) continue
@@ -445,13 +463,16 @@ export class SessionQuerySqlite extends SessionQueryService {
// crash-repair side effects; the live-membership retry below makes
// the returned observation live-preferred.
if (initiallyLive.has(entry.header.id) || this.ctx.sessions.get(entry.header.id) !== undefined) continue
const loaded = await waitWithAbort(persistence.inspect(entry.header.id), signal)
assertNotAborted(signal)
const loaded = await persistence.inspect(entry.header.id, signal)
assertNotAborted(signal)
assertSessionHeadersCompatible(entry.header, loaded.meta)
entry.loaded = observeSession(loaded.meta, loaded.events)
}
const after = materializePersistenceSnapshots(
await waitWithAbort(persistence.listSnapshots(), signal),
)
assertNotAborted(signal)
const afterSnapshots = await persistence.listSnapshots(signal)
assertNotAborted(signal)
const after = materializePersistenceSnapshots(afterSnapshots)
if (!samePersistenceSnapshots(persisted, after)) continue
if (this._persistenceBinding !== persistenceBinding) continue
} catch (error: unknown) {
@@ -643,17 +664,33 @@ export class SessionQuerySqlite extends SessionQueryService {
`).all(...bindings) as unknown as SearchRow[]
}
private _targetGeneration(sessionId: SessionId, persistenceBinding: PersistenceBinding): string {
private _targetObservation(
sessionId: SessionId,
persistenceBinding: PersistenceBinding,
): { header: SessionHeader; generation: string } {
const db = this._requireDb()
const live = db.prepare(
'SELECT generation FROM temp.live_sessions WHERE id = ?',
).get(sessionId) as { generation: number } | undefined
if (live !== undefined) return `live:${live.generation}`
`SELECT
id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, generation
FROM temp.live_sessions
WHERE id = ?`,
).get(sessionId) as (SessionHeaderRow & { generation: number }) | undefined
if (live !== undefined) {
return { header: rowHeader(live), generation: `live:${live.generation}` }
}
if (persistenceBinding.service !== undefined) {
const persisted = db.prepare(
'SELECT generation FROM persisted_sessions WHERE id = ?',
).get(sessionId) as { generation: number } | undefined
if (persisted !== undefined) return `persisted:${this._persistenceEpoch}:${persisted.generation}`
`SELECT
id AS session_id, version, created_at, cwd, parent_session, seed_length, delegation_depth, generation
FROM persisted_sessions
WHERE id = ?`,
).get(sessionId) as (SessionHeaderRow & { generation: number }) | undefined
if (persisted !== undefined) {
return {
header: rowHeader(persisted),
generation: `persisted:${this._persistenceEpoch}:${persisted.generation}`,
}
}
}
throw new SessionQueryError(
`session "${sessionId}" not found`,
@@ -835,7 +872,7 @@ function sameHeader(a: SessionHeader, b: SessionHeader): boolean {
&& (a.delegationDepth ?? 0) === (b.delegationDepth ?? 0)
}
function rowHeader(row: SearchRow): SessionHeader {
function rowHeader(row: SessionHeaderRow): SessionHeader {
return {
version: row.version,
id: row.session_id as SessionId,
@@ -914,6 +951,8 @@ function resolveConfig(config: Config): ResolvedConfig {
maxLimit: config.maxLimit ?? SESSION_QUERY_SQLITE_MAX_LIMIT,
snippetChars: config.snippetChars ?? SESSION_QUERY_SQLITE_SNIPPET_CHARS,
readWindowMax: config.readWindowMax ?? SESSION_QUERY_READ_WINDOW_MAX,
persistedInspectConcurrency: config.persistedInspectConcurrency
?? SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY,
}
if (typeof resolved.path !== 'string' || resolved.path.trim().length === 0) {
throw invalidConfig('path must not be blank')
@@ -924,6 +963,12 @@ function resolveConfig(config: Config): ResolvedConfig {
if (!Number.isInteger(resolved.readWindowMax) || resolved.readWindowMax < 0) {
throw invalidConfig('readWindowMax must be a non-negative integer')
}
if (
!Number.isSafeInteger(resolved.persistedInspectConcurrency)
|| resolved.persistedInspectConcurrency < 1
) {
throw invalidConfig('persistedInspectConcurrency must be a positive safe integer')
}
if (resolved.defaultLimit > resolved.maxLimit) {
throw invalidConfig('defaultLimit must be less than or equal to maxLimit')
}

View File

@@ -13,6 +13,7 @@ import SessionQuerySqlite, {
SESSION_QUERY_SQLITE_SCHEMA_VERSION,
} from '@deepseek-ai/dsh-session-query-sqlite'
import {
SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY,
SessionQueryError,
SessionSearchCursor,
type SessionAvailability,
@@ -68,11 +69,16 @@ class TestPersistence extends SessionPersistence {
static nextRevision = 0
static loads = new Map<SessionIdType, number>()
static inspections = new Map<SessionIdType, number>()
static inspectSignals: Array<AbortSignal | undefined> = []
static snapshotSignals: Array<AbortSignal | undefined> = []
static loadEffect: ((entry: { meta: SessionHeader; events: SessionEvent[] }) => void) | undefined
static inspectEffect: ((entry: { meta: SessionHeader; events: SessionEvent[] }) => void | Promise<void>) | undefined
static inspectEffect: ((
entry: { meta: SessionHeader; events: SessionEvent[] },
signal?: AbortSignal,
) => void | Promise<void>) | undefined
static listGate: Promise<void> | undefined
static listStarted: (() => void) | undefined
static snapshotEffect: (() => void | Promise<void>) | undefined
static snapshotEffect: ((signal?: AbortSignal) => void | Promise<void>) | undefined
static snapshotOverride: (() => SessionPersistenceSnapshot[]) | undefined
static failure: unknown
@@ -85,6 +91,8 @@ class TestPersistence extends SessionPersistence {
this.revisions = new Map()
this.loads = new Map()
this.inspections = new Map()
this.inspectSignals = []
this.snapshotSignals = []
this.loadEffect = undefined
this.inspectEffect = undefined
for (const entry of entries) this.set(entry)
@@ -127,12 +135,13 @@ class TestPersistence extends SessionPersistence {
return structuredClone(entry)
}
async inspect(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
async inspect(id: SessionIdType, signal?: AbortSignal): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
TestPersistence.inspections.set(id, (TestPersistence.inspections.get(id) ?? 0) + 1)
TestPersistence.inspectSignals.push(signal)
if (TestPersistence.failure !== undefined) throw TestPersistence.failure
const entry = TestPersistence.entries.get(id)
if (entry === undefined) throw new Error('missing test session')
await TestPersistence.inspectEffect?.(entry)
await TestPersistence.inspectEffect?.(entry, signal)
TestPersistence.inspectEffect = undefined
return structuredClone(entry)
}
@@ -145,7 +154,8 @@ class TestPersistence extends SessionPersistence {
}
async listSnapshots(): Promise<SessionPersistenceSnapshot[]> {
async listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot[]> {
TestPersistence.snapshotSignals.push(signal)
TestPersistence.listStarted?.()
await TestPersistence.listGate
if (TestPersistence.failure !== undefined) throw TestPersistence.failure
@@ -154,7 +164,7 @@ class TestPersistence extends SessionPersistence {
header: structuredClone(entry.meta),
revision: SessionPersistenceRevision(`test:${TestPersistence.revisions.get(entry.meta.id)}`),
}))
await TestPersistence.snapshotEffect?.()
await TestPersistence.snapshotEffect?.(signal)
return snapshots
}
}
@@ -167,6 +177,29 @@ async function liveContext(config: ConstructorParameters<typeof SessionQuerySqli
}
describe('SQLite session search', () => {
it('defaults and validates persisted inspection concurrency through its Cordis config', async () => {
const defaultCtx = await liveContext()
expect((defaultCtx.sessionQuery as SessionQuerySqlite).config.persistedInspectConcurrency)
.toBe(SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY)
const configuredValue = 2
const configured = new SessionQuerySqlite.Config({
path: ':memory:',
persistedInspectConcurrency: configuredValue,
})
expect(configured.persistedInspectConcurrency).toBe(configuredValue)
const configuredCtx = await liveContext(configured)
expect((configuredCtx.sessionQuery as SessionQuerySqlite).config.persistedInspectConcurrency)
.toBe(configuredValue)
for (const persistedInspectConcurrency of [0, Number.MAX_SAFE_INTEGER + 1]) {
expect(() => new SessionQuerySqlite.Config({
path: ':memory:',
persistedInspectConcurrency,
})).toThrow()
}
})
it('searches two-character Unicode61 tokens in live-only sessions', async () => {
const ctx = await liveContext({ path: ':memory:', snippetChars: 20 })
const session = ctx.sessions.create(SessionId('live'), {
@@ -179,7 +212,10 @@ describe('SQLite session search', () => {
)
await expect(ctx.sessionQuery.searchEvents({ sessionId: session.id, query: 'AI' }))
.resolves.toMatchObject({ items: [{ sessionId: session.id, seq: 0, snippet: 'An AI helper' }] })
.resolves.toMatchObject({
session: { ...session.header, seedLength: 1 },
items: [{ sessionId: session.id, seq: 0, snippet: 'An AI helper' }],
})
await expect(ctx.sessionQuery.searchSessions({ query: 'AI' }))
.resolves.toMatchObject({ items: [{ header: { ...session.header, seedLength: 1 }, live: true, persisted: false }] })
})
@@ -483,6 +519,8 @@ describe('SQLite session search', () => {
{ path: ':memory:', maxLimit: 1e100 },
{ path: ':memory:', snippetChars: 0 },
{ path: ':memory:', readWindowMax: -1 },
{ path: ':memory:', persistedInspectConcurrency: 0 },
{ path: ':memory:', persistedInspectConcurrency: Number.MAX_SAFE_INTEGER + 1 },
{ path: ':memory:', defaultLimit: 3, maxLimit: 2 },
{ path: ':memory:', journalMode: 'memory' },
]) {
@@ -1200,6 +1238,167 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
}
})
it.each(['sessions', 'events'] as const)(
'forwards one exact reconciliation signal through both snapshot lists and persisted inspection for %s search',
async (scope) => {
const durable = header(`signal-${scope}`)
TestPersistence.reset([{ meta: durable, events: messageEvents('signal needle') }])
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
const controller = new AbortController()
const result = scope === 'sessions'
? await ctx.sessionQuery.searchSessions({ query: 'needle' }, { signal: controller.signal })
: await ctx.sessionQuery.searchEvents(
{ sessionId: durable.id, query: 'needle' },
{ signal: controller.signal },
)
expect(result.items).toHaveLength(1)
expect(TestPersistence.snapshotSignals).toEqual([controller.signal, controller.signal])
expect(TestPersistence.inspectSignals).toEqual([controller.signal])
},
)
it.each(['sessions', 'events'] as const)(
'starts no persistence observation for a pre-aborted %s search',
async (scope) => {
const durable = header(`pre-aborted-${scope}`)
TestPersistence.reset([{ meta: durable, events: messageEvents('needle') }])
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
const controller = new AbortController()
controller.abort(new Error(`pre-aborted ${scope}`))
const pending = scope === 'sessions'
? ctx.sessionQuery.searchSessions({ query: 'needle' }, { signal: controller.signal })
: ctx.sessionQuery.searchEvents(
{ sessionId: durable.id, query: 'needle' },
{ signal: controller.signal },
)
await expect(pending).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED'))
expect(TestPersistence.snapshotSignals).toEqual([])
expect(TestPersistence.inspectSignals).toEqual([])
},
)
it('awaits cooperative snapshot-list cancellation cleanup without starting another observation step', async () => {
const durable = header('cooperative-list-abort')
TestPersistence.reset([{ meta: durable, events: messageEvents('needle') }])
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
const started = Promise.withResolvers<AbortSignal>()
const abortObserved = Promise.withResolvers<undefined>()
const cleanup = Promise.withResolvers<undefined>()
TestPersistence.snapshotEffect = async (signal) => {
TestPersistence.snapshotEffect = undefined
if (signal === undefined) throw new Error('expected reconciliation signal')
started.resolve(signal)
await new Promise<void>((resolve) => {
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
abortObserved.resolve(undefined)
await cleanup.promise
signal.throwIfAborted()
}
const controller = new AbortController()
const pending = ctx.sessionQuery.searchSessions({ query: 'needle' }, { signal: controller.signal })
expect(await started.promise).toBe(controller.signal)
let settled = false
void pending.then(
() => { settled = true },
() => { settled = true },
)
controller.abort(new Error('cooperative list cancellation'))
await abortObserved.promise
expect(settled).toBe(false)
expect(TestPersistence.snapshotSignals).toEqual([controller.signal])
expect(TestPersistence.inspectSignals).toEqual([])
cleanup.resolve(undefined)
await expect(pending).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED'))
})
it('keeps a second search serialized while an abort-ignoring snapshot list finishes', async () => {
const durable = header('serialized-list-abort')
TestPersistence.reset([{ meta: durable, events: messageEvents('needle') }])
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
const cleanup = Promise.withResolvers<undefined>()
const started = Promise.withResolvers<undefined>()
TestPersistence.listGate = cleanup.promise
TestPersistence.listStarted = () => {
TestPersistence.listStarted = undefined
started.resolve(undefined)
}
const controller = new AbortController()
const first = ctx.sessionQuery.searchSessions({ query: 'needle' }, { signal: controller.signal })
await started.promise
let firstSettled = false
let secondSettled = false
void first.then(
() => { firstSettled = true },
() => { firstSettled = true },
)
controller.abort(new Error('ignored list cancellation'))
const second = ctx.sessionQuery.searchEvents({ sessionId: durable.id, query: 'needle' })
void second.then(
() => { secondSettled = true },
() => { secondSettled = true },
)
await Promise.resolve()
expect(firstSettled).toBe(false)
expect(secondSettled).toBe(false)
expect(TestPersistence.snapshotSignals).toEqual([controller.signal])
expect(TestPersistence.inspectSignals).toEqual([])
cleanup.resolve(undefined)
await expect(first).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED'))
await expect(second).resolves.toMatchObject({ items: [{ sessionId: durable.id }] })
})
it('awaits an abort-ignoring inspection and starts neither another inspection nor the after-list', async () => {
const first = header('ignored-inspect-first')
const second = header('ignored-inspect-second')
TestPersistence.reset([
{ meta: first, events: messageEvents('first needle') },
{ meta: second, events: messageEvents('second needle') },
])
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
const started = Promise.withResolvers<AbortSignal>()
const cleanup = Promise.withResolvers<undefined>()
TestPersistence.inspectEffect = async (_entry, signal) => {
TestPersistence.inspectEffect = undefined
if (signal === undefined) throw new Error('expected reconciliation signal')
started.resolve(signal)
await cleanup.promise
}
const controller = new AbortController()
const pending = ctx.sessionQuery.searchSessions({ query: 'needle' }, { signal: controller.signal })
expect(await started.promise).toBe(controller.signal)
let settled = false
void pending.then(
() => { settled = true },
() => { settled = true },
)
controller.abort(new Error('ignored inspect cancellation'))
await Promise.resolve()
expect(settled).toBe(false)
expect(TestPersistence.snapshotSignals).toEqual([controller.signal])
expect(TestPersistence.inspections.get(first.id)).toBe(1)
expect(TestPersistence.inspections.get(second.id)).toBeUndefined()
cleanup.resolve(undefined)
await expect(pending).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED'))
expect(TestPersistence.snapshotSignals).toEqual([controller.signal])
expect(TestPersistence.inspections.get(second.id)).toBeUndefined()
})
it('cancels both queued and in-flight source waits without committing them', async () => {
TestPersistence.reset()
const ctx = await liveContext()
@@ -1253,8 +1452,15 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
const active = ctx.sessionQuery.searchSessions({ query: 'needle' }, { signal: activeController.signal })
await activeStarted
activeController.abort()
await expect(active).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED'))
let activeSettled = false
void active.then(
() => { activeSettled = true },
() => { activeSettled = true },
)
await Promise.resolve()
expect(activeSettled).toBe(false)
releaseActive()
await expect(active).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED'))
const db = (ctx.sessionQuery as unknown as { _db: DatabaseSync })._db
expect(db.prepare('SELECT COUNT(*) AS count FROM persisted_sessions').get()).toEqual({ count: 0 })
@@ -1262,6 +1468,57 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
.resolves.toMatchObject({ items: [{ header: { id: SessionId('uncommitted') } }] })
})
it.each([
[new Error('ready error'), 'ready error'],
['non-error ready failure', 'session-search dependency rejected with a non-Error value'],
])('normalizes a rejected readiness wait before mapping it to an index error', async (failure, detail) => {
TestPersistence.reset()
const ctx = await liveContext()
const internals = ctx.sessionQuery as unknown as {
_ready: Promise<void>
_ensureReady(signal: AbortSignal): Promise<void>
}
internals._ready = Promise.resolve().then(() => {
throw failure
})
await expect(internals._ensureReady(new AbortController().signal))
.rejects.toThrow(`session-search SQLite index failed to open: ${detail}`)
})
it('checks cancellation after readiness before reconciliation accesses SQLite', async () => {
TestPersistence.reset()
const ctx = await liveContext()
const internals = ctx.sessionQuery as unknown as {
_db: DatabaseSync
_ready: Promise<void>
_ensureReady(signal: AbortSignal | undefined): Promise<void>
}
const readiness = Promise.withResolvers<undefined>()
internals._ready = readiness.promise
const readyWaitStarted = Promise.withResolvers<undefined>()
const ensureReady = internals._ensureReady.bind(internals)
vi.spyOn(internals, '_ensureReady').mockImplementation(async (signal) => {
const pending = ensureReady(signal)
readyWaitStarted.resolve(undefined)
return pending
})
const prepare = vi.spyOn(internals._db, 'prepare')
const reason = new Error('cancelled after readiness')
const controller = new AbortController()
const pending = ctx.sessionQuery.searchSessions({ query: 'needle' }, { signal: controller.signal })
await readyWaitStarted.promise
const queueBoundaryAbort = readiness.promise.then(() => {
queueMicrotask(() => { controller.abort(reason) })
})
readiness.resolve(undefined)
await queueBoundaryAbort
await expect(pending).rejects.toThrow(expectCode('SESSION_QUERY_ABORTED'))
expect(prepare).not.toHaveBeenCalled()
})
it('rejects queued and future work when close waits for an accepted operation', async () => {
TestPersistence.reset()
let release!: () => void
@@ -1327,7 +1584,7 @@ describe('SQLite schema, cancellation, and real persistence integration', () =>
await expect(ctx.sessionQuery.searchSessions({ query: 'SQLite needle' }))
.resolves.toMatchObject({ items: [{ header: meta, persisted: true, live: false }] })
await expect(ctx.sessionQuery.searchEvents({ sessionId: meta.id, query: 'SQLite needle' }))
.resolves.toMatchObject({ items: [{ sessionId: meta.id, seq: 0 }] })
.resolves.toMatchObject({ session: meta, items: [{ sessionId: meta.id, seq: 0 }] })
await expect(ctx.sessionQuery.searchEvents({ sessionId: SessionId('absent'), query: 'needle' }))
.rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND'))
await search.dispose()

View File

@@ -4,18 +4,18 @@
## Reads
- `listSessions()` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order.
- `listSessions(signal?)` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order.
- `readSession(sessionId)` returns one complete detached raw log after the same core replay validation used by resume; it never enters the session into the live store.
- `filterSessions(filters)` applies provider-independent session metadata and availability predicates to that same cloned logical corpus.
- `filterSessions(filters, signal?)` applies provider-independent session metadata and availability predicates to that same cloned logical corpus.
- `filterEvents(sessionId, filters)` extracts first-party semantic documents and applies provider-independent metadata and literal-text predicates in ascending seq order.
- `readTitle(sessionId)` loads one live-preferred or persisted log and folds its latest `session/title` event into a `SessionTitleSnapshot`; it returns `undefined` when the known session has no title.
- `readTitleSnapshots(sessionIds, signal?)` resolves unique ids from one live-preferred corpus observation, passes cancellation through persisted listing and inspection, and returns ordered per-session settlements so one missing or malformed title source does not discard its peers. Each live source is folded directly, and each persisted worker folds to a detached header/title result and releases the full log before dequeuing another id. Cancellation rejects the whole batch. `readTitleSnapshot(sessionId, signal?)` is the one-observation view; `readTitle(sessionId, signal?)` returns only its optional folded `session/title`.
- `listEvents(sessionId)` loads the live-preferred raw log and classifies each event as `current`, `shadowed`, or `log-only` with the shared `dsh-session` surface fold.
- `readSurface(sessionId)` returns one cloned header, raw-log capture boundary, and the complete folded current surface in model-history order. A live session wins over persistence; compaction is observed before or after its replacement append, never as a synthetic mixture.
- `readEvent(request)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`.
- `traceSession(sessionId)` reads the corpus once and returns immediate-to-outward ancestors plus deterministic recursive descendant trees. `complete: false` identifies the first missing parent; a target-connected cycle fails with `SESSION_QUERY_INVALID_LINEAGE`.
- `traceEvent(request)` loads the logical log once and returns direct positional replacements and direct logged provenance. `replacementChain` follows positional replacers to the final replacement; provenance links remain non-transitive.
- `readEvent(request, signal?)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`.
- `traceSession(sessionId, signal?)` reads the corpus once and returns immediate-to-outward ancestors plus deterministic recursive descendant trees. `complete: false` identifies the first missing parent; a target-connected cycle fails with `SESSION_QUERY_INVALID_LINEAGE`.
- `traceEvent(request, signal?)` loads the logical log once and returns its cloned source header with direct positional replacements and direct logged provenance. `replacementChain` follows positional replacers to the final replacement; provenance links remain non-transitive.
Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A title, event read, or trace targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. `listSessions()` remains lightweight and does not load logs or index titles.
Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A title read, event trace, or event read targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. Lineage-trace cancellation is passed to persisted listing; event-trace and event-read cancellation is passed to persisted listing and inspection. Each waits for the started backend call to settle, then rejects with the signal's exact reason even when the backend ignored that signal. A pre-aborted known-live title read, event trace, or event read rejects before folding or snapshotting without consulting persistence. A batch title observation performs one metadata listing, inspects its unique persisted ids with at most `persistedInspectConcurrency` workers, and preserves each title's own observed header for downstream authorization. Cancellation starts no queued inspections and rejects only after already-started workers settle. `listSessions()` remains lightweight and does not load logs or index titles.
## Filtering and extraction
@@ -25,7 +25,7 @@ The text clause is deliberately independent of FTS providers: caller text is esc
## Full-text methods
`SessionQueryService.searchSessions(request, exec?)` groups the logical corpus by strongest matching event; `searchEvents(request, exec?)` searches one logical session. These are the service's only abstract methods. Both return pages whose continuation is an owned branded `SessionSearchCursor`, accept optional cancellation, and expose snippets without provider-specific numeric scores. Search requests accept only metadata event filters, because literal-text filtering is the scan path described above.
`SessionQueryService.searchSessions(request, exec?)` groups the logical corpus by strongest matching event; `searchEvents(request, exec?)` searches one logical session. These are the service's only abstract methods. Both return pages whose continuation is an owned branded `SessionSearchCursor`, accept optional cancellation, and expose snippets without provider-specific numeric scores. An event-search page also carries the cloned target header from the same indexed generation as its hits, allowing authorization consumers to bind policy to the payload observation. Search requests accept only metadata event filters, because literal-text filtering is the scan path described above.
The package has no provider coordinator, fallback implementation, or standalone concrete plugin. A concrete service backend inherits the implemented reads, filters, and traces while owning full-text observation, reconciliation, ranking, cursor generations, and query execution; the first implementation is [`@deepseek-ai/dsh-session-query-sqlite`](../session-query-sqlite/README.md).
@@ -38,6 +38,7 @@ The package has no provider coordinator, fallback implementation, or standalone
| Key | Default | Contract |
|---|---:|---|
| `readWindowMax` | `50` | Maximum `before` or `after` raw-event count. |
| `persistedInspectConcurrency` | `4` | Maximum concurrent persisted-log inspections in one batch read; must be a positive safe integer. |
## Model Experience

View File

@@ -5,10 +5,15 @@ import { HarnessError } from '@deepseek-ai/dsh-llm'
/** Default maximum `before`/`after` raw-event window. */
export const SESSION_QUERY_READ_WINDOW_MAX = 50
/** Default maximum number of concurrent persisted-log inspections in one batch read. */
export const SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY = 4
/** Backend-independent configuration inherited by every session-query implementation. */
export interface Config {
/** Maximum accepted raw read context on either side. Defaults to 50. */
readWindowMax?: number
/** Maximum concurrent persisted-log inspections in one batch read. Defaults to 4. */
persistedInspectConcurrency?: number
}
/** Stable machine-routable failure taxonomy for session reads, traces, and search. */

View File

@@ -15,12 +15,28 @@ export interface LogicalSession {
events: SessionEvent[]
}
/** Borrowed source visible only during one synchronous batch projection. */
export interface LogicalSessionSource {
/** Header selected with `events`; callers must clone retained output. */
readonly header: SessionHeader
/** Raw events selected with `header`; valid only for the projection call. */
readonly events: readonly SessionEvent[]
}
/** One source-projection result in a batch logical-corpus observation. */
export type LogicalProjectionResult<Value> =
| { sessionId: SessionId; status: 'fulfilled'; value: Value }
| { sessionId: SessionId; status: 'rejected'; reason: unknown }
/** Resolves a live-preferred corpus against the persistence service mounted now. */
export class SessionCorpus {
private _persistence: SessionPersistence | undefined
private readonly _optionalPersistenceFiber: Fiber
constructor(private readonly _ctx: Context) {
constructor(
private readonly _ctx: Context,
private readonly _persistedInspectConcurrency: number,
) {
this._optionalPersistenceFiber = _ctx.inject(['sessionPersistence'], (childCtx: Context) => {
const service = childCtx.sessionPersistence
this._persistence = service
@@ -36,11 +52,14 @@ export class SessionCorpus {
/**
* List the complete logical corpus with live precedence and cloned headers.
* @param signal - optional cancellation for persistence listing.
* @returns records in deterministic newest-first order.
*/
async listSessions(): Promise<SessionRecord[]> {
async listSessions(signal?: AbortSignal): Promise<SessionRecord[]> {
signal?.throwIfAborted()
const persistence = this._persistence
const persisted = persistence === undefined ? [] : await listPersisted(persistence)
const persisted = persistence === undefined ? [] : await listPersisted(persistence, signal)
signal?.throwIfAborted()
const records = new Map<SessionId, SessionRecord>()
for (const header of persisted) {
records.set(header.id, { header: structuredClone(header), live: false, persisted: true })
@@ -63,39 +82,181 @@ export class SessionCorpus {
* A known live target never consults persistence, so an optional backend's
* failure cannot make current in-memory history unreadable.
* @param sessionId - session to resolve.
* @param signal - optional cancellation for persisted source resolution.
* @returns detached live-preferred header and events.
*/
async load(sessionId: SessionId): Promise<LogicalSession> {
async load(sessionId: SessionId, signal?: AbortSignal): Promise<LogicalSession> {
signal?.throwIfAborted()
const live = this._ctx.sessions.get(sessionId)
if (live !== undefined) return snapshotLive(live)
if (live !== undefined) {
const snapshot = snapshotLive(live)
signal?.throwIfAborted()
return snapshot
}
const persistence = this._persistence
if (persistence === undefined) throw notFound(sessionId)
const listed = (await listPersisted(persistence)).find(header => header.id === sessionId)
const listed = (await listPersisted(persistence, signal)).find(header => header.id === sessionId)
signal?.throwIfAborted()
if (listed === undefined) throw notFound(sessionId)
let loaded: Awaited<ReturnType<SessionPersistence['inspect']>>
try {
loaded = await persistence.inspect(sessionId)
} catch (error: unknown) {
throw new SessionQueryError(
`failed to inspect session "${sessionId}": ${errorMessage(error)}`,
'SESSION_QUERY_PERSISTENCE_FAILED',
{ cause: error },
)
}
const loaded = await inspectPersisted(persistence, sessionId, signal)
signal?.throwIfAborted()
const attached = this._ctx.sessions.get(sessionId)
if (attached !== undefined) return snapshotLive(attached)
if (attached !== undefined) {
const snapshot = snapshotLive(attached)
signal?.throwIfAborted()
return snapshot
}
assertSessionHeadersCompatible(loaded.meta, listed)
return {
const snapshot = {
header: structuredClone(loaded.meta),
events: loaded.events.map(event => structuredClone(event)),
}
signal?.throwIfAborted()
return snapshot
}
/**
* Project unique logical sources immediately from one persistence listing.
*
* The synchronous projector runs before a persisted worker claims its next id.
* Full logs are borrowed only for that call and never retained by the batch.
* @param sessionIds - sessions to resolve in first-occurrence order.
* @param project - synchronous fold that owns/clones every retained value.
* @param signal - cancellation shared by listing and every persisted inspection.
* @returns one fulfilled or rejected projected result per unique requested id.
*/
async projectMany<Value>(
sessionIds: readonly SessionId[],
project: (source: LogicalSessionSource) => Value,
signal?: AbortSignal,
): Promise<LogicalProjectionResult<Value>[]> {
const ids = [...new Set(sessionIds)]
signal?.throwIfAborted()
const resolved = new Map<SessionId, LogicalProjectionResult<Value>>()
const unresolved: SessionId[] = []
for (const id of ids) {
const session = this._ctx.sessions.get(id)
if (session === undefined) {
unresolved.push(id)
} else {
resolved.set(id, projectSource(id, sourceLive(session), project, signal))
}
}
if (unresolved.length === 0) return orderedResults(ids, resolved)
const persistence = this._persistence
if (persistence === undefined) {
for (const sessionId of unresolved) {
resolved.set(sessionId, { sessionId, status: 'rejected', reason: notFound(sessionId) })
}
return orderedResults(ids, resolved)
}
let persisted: SessionHeader[]
try {
persisted = await listPersisted(persistence, signal)
signal?.throwIfAborted()
} catch (error: unknown) {
if (signal?.aborted) signal.throwIfAborted()
for (const sessionId of unresolved) {
resolved.set(sessionId, { sessionId, status: 'rejected', reason: error })
}
return orderedResults(ids, resolved)
}
const persistedById = new Map(persisted.map(header => [header.id, header]))
const resolvePersisted = async (sessionId: SessionId): Promise<void> => {
const listed = persistedById.get(sessionId)
if (listed === undefined) {
const attached = this._ctx.sessions.get(sessionId)
resolved.set(sessionId, attached === undefined
? { sessionId, status: 'rejected', reason: notFound(sessionId) }
: projectSource(sessionId, sourceLive(attached), project, signal))
return
}
try {
signal?.throwIfAborted()
const loaded = await inspectPersisted(persistence, sessionId, signal)
signal?.throwIfAborted()
const attached = this._ctx.sessions.get(sessionId)
if (attached !== undefined) {
resolved.set(sessionId, projectSource(sessionId, sourceLive(attached), project, signal))
return
}
assertSessionHeadersCompatible(loaded.meta, listed)
resolved.set(sessionId, projectSource(sessionId, {
header: loaded.meta,
events: loaded.events,
}, project, signal))
} catch (error: unknown) {
if (signal?.aborted) signal.throwIfAborted()
resolved.set(sessionId, { sessionId, status: 'rejected', reason: error })
}
}
let cursor = 0
const worker = async (): Promise<void> => {
for (;;) {
signal?.throwIfAborted()
const index = cursor
if (index >= unresolved.length) return
cursor += 1
await resolvePersisted(unresolved[index] as SessionId)
}
}
const workerCount = Math.min(this._persistedInspectConcurrency, unresolved.length)
const settlements = await Promise.allSettled(
Array.from({ length: workerCount }, () => worker()),
)
if (signal?.aborted) signal.throwIfAborted()
/* v8 ignore start -- per-id failures settle inside resolvePersisted; workers reject only on abort above */
for (const settlement of settlements) {
if (settlement.status === 'rejected') {
const reason: unknown = settlement.reason
throw reason
}
}
/* v8 ignore stop */
signal?.throwIfAborted()
return orderedResults(ids, resolved)
}
}
async function listPersisted(persistence: SessionPersistence): Promise<SessionHeader[]> {
function projectSource<Value>(
sessionId: SessionId,
source: LogicalSessionSource,
project: (source: LogicalSessionSource) => Value,
signal?: AbortSignal,
): LogicalProjectionResult<Value> {
try {
return await persistence.list()
signal?.throwIfAborted()
const value = project(source)
signal?.throwIfAborted()
return { sessionId, status: 'fulfilled', value }
} catch (reason: unknown) {
/* v8 ignore next -- the synchronous projector has no external cancellation yield */
if (signal?.aborted) signal.throwIfAborted()
return { sessionId, status: 'rejected', reason }
}
}
function sourceLive(session: Session): LogicalSessionSource {
return { header: session.header, events: session.events }
}
function orderedResults<Value>(
ids: readonly SessionId[],
resolved: ReadonlyMap<SessionId, LogicalProjectionResult<Value>>,
): LogicalProjectionResult<Value>[] {
return ids.map(sessionId => resolved.get(sessionId) as LogicalProjectionResult<Value>)
}
async function listPersisted(
persistence: SessionPersistence,
signal?: AbortSignal,
): Promise<SessionHeader[]> {
try {
return await persistence.list(signal)
} catch (error: unknown) {
if (signal?.aborted) signal.throwIfAborted()
throw new SessionQueryError(
`session persistence listing failed: ${errorMessage(error)}`,
'SESSION_QUERY_PERSISTENCE_FAILED',
@@ -104,6 +265,23 @@ async function listPersisted(persistence: SessionPersistence): Promise<SessionHe
}
}
async function inspectPersisted(
persistence: SessionPersistence,
sessionId: SessionId,
signal?: AbortSignal,
): Promise<Awaited<ReturnType<SessionPersistence['inspect']>>> {
try {
return await persistence.inspect(sessionId, signal)
} catch (error: unknown) {
if (signal?.aborted) signal.throwIfAborted()
throw new SessionQueryError(
`failed to inspect session "${sessionId}": ${errorMessage(error)}`,
'SESSION_QUERY_PERSISTENCE_FAILED',
{ cause: error },
)
}
}
function snapshotLive(session: Session): LogicalSession {
return {
header: structuredClone(session.header),

View File

@@ -10,12 +10,12 @@ import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
import type { SessionTitleSnapshot } from '@deepseek-ai/dsh-session-title'
import type {
SessionEventResultFilter,
SessionEventSearchPage,
SessionEventReadRequest,
SessionEventRecord,
SessionEventSearchHit,
SessionEventSearchDocument,
SessionEventSearchRequest,
SessionEventTrace,
SessionEventTraceObservation,
SessionEventTraceRequest,
SessionEventWindow,
SessionLineageTrace,
@@ -27,8 +27,11 @@ import type {
SessionSearchPage,
SessionSearchRequest,
SessionSurfaceSnapshot,
SessionTitleObservation,
SessionTitleObservationResult,
} from './types.ts'
import {
SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY,
SESSION_QUERY_READ_WINDOW_MAX,
SessionQueryError,
type Config,
@@ -46,7 +49,11 @@ import * as tracing from './tracing.ts'
export type * from './types.ts'
export { SessionSearchCursor } from './cursor.ts'
export type { Config, SessionQueryErrorCode } from './config.ts'
export { SESSION_QUERY_READ_WINDOW_MAX, SessionQueryError } from './config.ts'
export {
SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY,
SESSION_QUERY_READ_WINDOW_MAX,
SessionQueryError,
} from './config.ts'
export { extractSessionEventText } from './extraction.ts'
export { buildSessionEventRecords, buildSessionEventSearchDocuments } from './documents.ts'
export {
@@ -86,7 +93,15 @@ export abstract class SessionQueryService extends Service {
'SESSION_QUERY_INVALID_CONFIG',
)
}
this._corpus = new SessionCorpus(ctx)
const persistedInspectConcurrency = config.persistedInspectConcurrency
?? SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY
if (!Number.isSafeInteger(persistedInspectConcurrency) || persistedInspectConcurrency < 1) {
throw new SessionQueryError(
'session-query: persistedInspectConcurrency must be a positive safe integer',
'SESSION_QUERY_INVALID_CONFIG',
)
}
this._corpus = new SessionCorpus(ctx, persistedInspectConcurrency)
}
/**
@@ -104,19 +119,20 @@ export abstract class SessionQueryService extends Service {
* Search events within one live-preferred logical session.
* @param request - target session, query text, filters, page size, and cursor.
* @param exec - optional cancellation control.
* @returns matching event hits in deterministic relevance order.
* @returns matching event hits and their target header from one indexed generation.
*/
abstract searchEvents(
request: SessionEventSearchRequest,
exec?: SessionSearchExecContext,
): Promise<SessionSearchPage<SessionEventSearchHit>>
): Promise<SessionEventSearchPage>
/**
* List the complete logical corpus using live-preferred records.
* @param signal - optional cancellation for persistence listing.
* @returns deterministic newest-first cloned session records.
*/
listSessions(): Promise<SessionRecord[]> {
return this._corpus.listSessions()
listSessions(signal?: AbortSignal): Promise<SessionRecord[]> {
return this._corpus.listSessions(signal)
}
/**
@@ -137,21 +153,65 @@ export abstract class SessionQueryService extends Service {
/**
* Filter the complete logical corpus with provider-independent predicates.
* @param filters - ANDed session metadata and availability clauses.
* @param signal - optional cancellation for persistence listing.
* @returns matching cloned records in deterministic newest-first order.
*/
async filterSessions(filters: readonly SessionResultFilter[]): Promise<SessionRecord[]> {
async filterSessions(
filters: readonly SessionResultFilter[],
signal?: AbortSignal,
): Promise<SessionRecord[]> {
const ownedFilters = materializeSessionResultFilters(filters)
return this._filterSessions(ownedFilters)
return this._filterSessions(ownedFilters, signal)
}
/**
* Fold the latest log-backed title from one live-preferred logical session.
* @param sessionId - live or persisted session id to read.
* @param signal - optional cancellation for source resolution and title folding.
* @returns latest title snapshot, or `undefined` when the log has no title event.
*/
async readTitle(sessionId: SessionId): Promise<SessionTitleSnapshot | undefined> {
const loaded = await this._corpus.load(sessionId)
return foldSessionTitle(loaded.events)
async readTitle(
sessionId: SessionId,
signal?: AbortSignal,
): Promise<SessionTitleSnapshot | undefined> {
return (await this.readTitleSnapshot(sessionId, signal)).title
}
/**
* Fold the latest title and return its source header from one corpus observation.
* @param sessionId - live or persisted session id to read.
* @param signal - optional cancellation for source resolution and title folding.
* @returns cloned source header and optional latest title snapshot.
*/
async readTitleSnapshot(
sessionId: SessionId,
signal?: AbortSignal,
): Promise<SessionTitleObservation> {
const result = (await this.readTitleSnapshots([sessionId], signal))[0] as SessionTitleObservationResult
if (result.status === 'rejected') throw result.reason
return result.value
}
/**
* Fold titles for unique sessions from one cancellable corpus observation.
*
* Results preserve first-occurrence input order. Operational failures stay
* isolated per session, while cancellation rejects the complete operation.
* @param sessionIds - live or persisted session ids to observe.
* @param signal - optional cancellation shared by all source reads.
* @returns one fulfilled or rejected result per unique requested id.
*/
async readTitleSnapshots(
sessionIds: readonly SessionId[],
signal?: AbortSignal,
): Promise<SessionTitleObservationResult[]> {
return this._corpus.projectMany(sessionIds, (source): SessionTitleObservation => {
const title = foldSessionTitle(source.events)
return {
session: structuredClone(source.header),
...title === undefined ? {} : { title },
}
}, signal)
}
/**
@@ -178,8 +238,11 @@ export abstract class SessionQueryService extends Service {
return this._filterEvents(sessionId, ownedFilters)
}
private async _filterSessions(filters: readonly SessionResultFilter[]): Promise<SessionRecord[]> {
return filterSessionResults(await this._corpus.listSessions(), filters)
private async _filterSessions(
filters: readonly SessionResultFilter[],
signal?: AbortSignal,
): Promise<SessionRecord[]> {
return filterSessionResults(await this._corpus.listSessions(signal), filters)
}
private async _filterEvents(
@@ -209,36 +272,44 @@ export abstract class SessionQueryService extends Service {
/**
* Trace known ancestry and descendants from one corpus observation.
* @param sessionId - logical session id to trace.
* @param signal - optional cancellation for persistence listing.
* @returns a complete lineage or an explicit unresolved parent boundary.
* @throws when corpus resolution fails, the target is absent, or its known ancestry cycles.
*/
async traceSession(sessionId: SessionId): Promise<SessionLineageTrace> {
const records = await this._corpus.listSessions()
async traceSession(sessionId: SessionId, signal?: AbortSignal): Promise<SessionLineageTrace> {
const records = await this._corpus.listSessions(signal)
signal?.throwIfAborted()
return tracing.traceSession(records, sessionId)
}
/**
* Trace one event's direct positional and provenance relationships.
* @param request - target session id and event seq.
* @returns direct links plus the target's positional replacement chain.
* @param signal - optional cancellation for persisted source resolution.
* @returns source header, direct links, and the target's positional replacement chain.
* @throws when source resolution fails, the target is absent, or surface/provenance validation fails.
*/
async traceEvent(request: SessionEventTraceRequest): Promise<SessionEventTrace> {
const loaded = await this._corpus.load(request.sessionId)
return tracing.traceEvent(request.sessionId, loaded.events, request.seq)
async traceEvent(request: SessionEventTraceRequest, signal?: AbortSignal): Promise<SessionEventTraceObservation> {
const loaded = await this._corpus.load(request.sessionId, signal)
signal?.throwIfAborted()
return {
session: loaded.header,
...tracing.traceEvent(request.sessionId, loaded.events, request.seq),
}
}
/**
* Read one full event plus a bounded raw-log context window.
* @param request - target session/seq and context sizes.
* @param signal - optional cancellation for persisted source resolution.
* @returns cloned target and neighboring events.
*/
async readEvent(request: SessionEventReadRequest): Promise<SessionEventWindow> {
async readEvent(request: SessionEventReadRequest, signal?: AbortSignal): Promise<SessionEventWindow> {
const before = this._readWindow('before', request.before)
const after = this._readWindow('after', request.after)
const sessionId = request.sessionId
const seq = request.seq
return this._readEvent(sessionId, seq, before, after)
return this._readEvent(sessionId, seq, before, after, signal)
}
private async _readEvent(
@@ -246,8 +317,10 @@ export abstract class SessionQueryService extends Service {
seq: number,
before: number,
after: number,
signal?: AbortSignal,
): Promise<SessionEventWindow> {
const loaded = await this._corpus.load(sessionId)
const loaded = await this._corpus.load(sessionId, signal)
signal?.throwIfAborted()
const target = loaded.events[seq]
if (target === undefined || target.seq !== seq) {
throw new SessionQueryError(

View File

@@ -12,6 +12,7 @@ import type {
SessionId,
SurfaceEvent,
} from '@deepseek-ai/dsh-session'
import type { SessionTitleSnapshot } from '@deepseek-ai/dsh-session-title'
import type { SessionSearchCursor } from './cursor.ts'
export type { SessionSearchCursor } from './cursor.ts'
@@ -116,6 +117,12 @@ export interface SessionEventTrace {
derivedEventSeqs: number[]
}
/** Event relationships bound to the same session-header observation. */
export interface SessionEventTraceObservation extends SessionEventTrace {
/** Cloned header selected with the event log used for the trace. */
session: SessionHeader
}
/** Request for one event plus raw neighboring log context. */
export interface SessionEventReadRequest {
/** Session that owns the target event. */
@@ -142,6 +149,33 @@ export interface SessionEventWindow {
endSeq: number
}
/** Latest folded title bound to the same session-header observation. */
export interface SessionTitleObservation {
/** Cloned header selected with the event log used for the title fold. */
session: SessionHeader
/** Latest title snapshot, absent when the observed log has no title. */
title?: SessionTitleSnapshot
}
/** One ordered result from a batch title observation. */
export type SessionTitleObservationResult =
| {
/** Requested session id. */
sessionId: SessionId
/** Successful atomic header/title observation. */
status: 'fulfilled'
/** Header and optional latest title from one logical source. */
value: SessionTitleObservation
}
| {
/** Requested session id. */
sessionId: SessionId
/** Operational failure isolated to this session. */
status: 'rejected'
/** Original failure from logical-source resolution or title folding. */
reason: unknown
}
/** Inclusive numeric interval used by time and sequence filters. */
export interface SessionResultRange {
/** Inclusive lower bound. */
@@ -192,6 +226,12 @@ export interface SessionSearchPage<T> {
nextCursor?: SessionSearchCursor
}
/** Event-search results bound to the indexed target-session observation. */
export interface SessionEventSearchPage extends SessionSearchPage<SessionEventSearchHit> {
/** Cloned target header from the same indexed generation as `items`. */
session: SessionHeader
}
/** Controls shared by cross-session and within-session search calls. */
export interface SessionSearchExecContext {
/** Abort caller waiting and interrupt provider work where supported. */

View File

@@ -213,8 +213,10 @@ it('registers exact and abstract search behavior under one ctx key', async () =>
const ctx = new Context()
await ctx.plugin(SessionStore)
const fiber = await ctx.plugin(TestSessionQueryService)
const session = ctx.sessions.create(id)
await expect(ctx.sessionQuery.searchSessions({ query: 'AI' })).resolves.toEqual({ items: [] })
await expect(ctx.sessionQuery.searchEvents({ sessionId: id, query: 'AI' })).resolves.toEqual({ items: [] })
await expect(ctx.sessionQuery.searchEvents({ sessionId: id, query: 'AI' }))
.resolves.toEqual({ session: session.header, items: [] })
await fiber.dispose()
expect(ctx.sessionQuery).toBeUndefined()
})

View File

@@ -1,9 +1,10 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context, type Fiber } from 'cordis'
import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session'
import SessionPersistence, { SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence'
import SessionQueryService, {
SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY,
type SessionEventSurface,
type SessionQueryErrorCode,
} from '@deepseek-ai/dsh-session-query'
@@ -27,16 +28,31 @@ function eventLog(text = 'hello'): SessionEvent[] {
class TestPersistence extends SessionPersistence {
static entries = new Map<SessionIdType, { meta: SessionHeader; events: SessionEvent[] }>()
static listFailure: unknown
static listOverride: ((signal?: AbortSignal) => Promise<SessionHeader[]>) | undefined
static inspectFailure: unknown
static inspectEffect: (() => void) | undefined
static inspectOverride: ((
id: SessionIdType,
signal?: AbortSignal,
) => Promise<{ meta: SessionHeader; events: SessionEvent[] }>) | undefined
static afterList: (() => void) | undefined
static listCalls = 0
static inspectCalls: SessionIdType[] = []
static listSignals: Array<AbortSignal | undefined> = []
static inspectSignals: Array<AbortSignal | undefined> = []
static reset(entries: readonly { meta: SessionHeader; events: SessionEvent[] }[] = []): void {
this.entries = new Map(entries.map(entry => [entry.meta.id, structuredClone(entry)]))
this.listFailure = undefined
this.listOverride = undefined
this.inspectFailure = undefined
this.inspectEffect = undefined
this.inspectOverride = undefined
this.afterList = undefined
this.listCalls = 0
this.inspectCalls = []
this.listSignals = []
this.inspectSignals = []
}
locate(_meta: SessionHeader): undefined {
@@ -59,7 +75,15 @@ class TestPersistence extends SessionPersistence {
return this.inspect(id)
}
inspect(id: SessionIdType): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
inspect(
id: SessionIdType,
signal?: AbortSignal,
): Promise<{ meta: SessionHeader; events: SessionEvent[] }> {
TestPersistence.inspectCalls.push(id)
TestPersistence.inspectSignals.push(signal)
if (TestPersistence.inspectOverride !== undefined) {
return TestPersistence.inspectOverride(id, signal)
}
if (TestPersistence.inspectFailure !== undefined) return rejectUnknown(TestPersistence.inspectFailure)
const entry = TestPersistence.entries.get(id)
if (entry === undefined) return Promise.reject(new Error('missing test session'))
@@ -69,7 +93,10 @@ class TestPersistence extends SessionPersistence {
return Promise.resolve(result)
}
list(): Promise<SessionHeader[]> {
list(signal?: AbortSignal): Promise<SessionHeader[]> {
TestPersistence.listCalls += 1
TestPersistence.listSignals.push(signal)
if (TestPersistence.listOverride !== undefined) return TestPersistence.listOverride(signal)
if (TestPersistence.listFailure !== undefined) return rejectUnknown(TestPersistence.listFailure)
const headers = [...TestPersistence.entries.values()].map(entry => structuredClone(entry.meta))
TestPersistence.afterList?.()
@@ -104,6 +131,287 @@ function rejectUnknown<T>(reason: unknown): Promise<T> {
})
}
const cancellableSessionListings = [
{
name: 'listSessions',
run: (ctx: Context, signal: AbortSignal) => ctx.sessionQuery.listSessions(signal),
},
{
name: 'filterSessions',
run: (ctx: Context, signal: AbortSignal) => ctx.sessionQuery.filterSessions([], signal),
},
] as const
interface CancellableExactRead {
readonly name: 'traceSession' | 'traceEvent' | 'readEvent'
readonly inspects: boolean
readonly run: (
ctx: Context,
sessionId: SessionIdType,
signal: AbortSignal,
) => Promise<unknown>
}
const cancellableExactReads: readonly CancellableExactRead[] = [
{
name: 'traceSession',
inspects: false,
run: (ctx, sessionId, signal) => ctx.sessionQuery.traceSession(sessionId, signal),
},
{
name: 'traceEvent',
inspects: true,
run: (ctx, sessionId, signal) => ctx.sessionQuery.traceEvent({ sessionId, seq: 0 }, signal),
},
{
name: 'readEvent',
inspects: true,
run: (ctx, sessionId, signal) => ctx.sessionQuery.readEvent({ sessionId, seq: 0 }, signal),
},
] as const
describe.each(cancellableSessionListings)('$name cancellation', ({ run }) => {
it('preserves an exact pre-abort reason without entering persistence', async () => {
TestPersistence.reset()
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
const controller = new AbortController()
const reason = new Error('session listing cancelled before start')
controller.abort(reason)
await expect(run(ctx, controller.signal)).rejects.toBe(reason)
expect(TestPersistence.listCalls).toBe(0)
expect(TestPersistence.listSignals).toEqual([])
})
it('forwards in-flight cancellation and waits for persistence cleanup before rejecting', async () => {
TestPersistence.reset()
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
const controller = new AbortController()
const reason = new Error('session listing cancelled in flight')
const started = Promise.withResolvers<undefined>()
const abortObserved = Promise.withResolvers<undefined>()
const cleanup = Promise.withResolvers<undefined>()
let active = false
TestPersistence.listOverride = async (signal) => {
if (signal === undefined) throw new Error('expected persistence listing signal')
active = true
const aborted = new Promise<void>((resolve) => {
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
started.resolve(undefined)
await aborted
abortObserved.resolve(undefined)
await cleanup.promise
active = false
signal.throwIfAborted()
return []
}
const pending = run(ctx, controller.signal)
let settled = false
void pending.then(
() => { settled = true },
() => { settled = true },
)
await started.promise
controller.abort(reason)
await abortObserved.promise
expect(settled).toBe(false)
expect(active).toBe(true)
expect(TestPersistence.listSignals).toEqual([controller.signal])
cleanup.resolve(undefined)
await expect(pending).rejects.toBe(reason)
expect(active).toBe(false)
})
it('preserves cancellation after a persistence implementation ignores the signal', async () => {
TestPersistence.reset()
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
const controller = new AbortController()
const reason = new Error('session listing cancelled before persistence returned')
const started = Promise.withResolvers<undefined>()
const listing = Promise.withResolvers<SessionHeader[]>()
TestPersistence.listOverride = (_signal) => {
started.resolve(undefined)
return listing.promise
}
const pending = run(ctx, controller.signal)
await started.promise
controller.abort(reason)
listing.resolve([])
await expect(pending).rejects.toBe(reason)
expect(TestPersistence.listSignals).toEqual([controller.signal])
})
})
describe.each(cancellableExactReads)('$name cancellation', ({ inspects, run }) => {
it('preserves an exact pre-abort reason without entering persistence', async () => {
const persisted = header('pre-aborted-exact-read')
TestPersistence.reset([{ meta: persisted, events: eventLog() }])
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
const controller = new AbortController()
const reason = new Error('exact read cancelled before start')
controller.abort(reason)
await expect(run(ctx, persisted.id, controller.signal)).rejects.toBe(reason)
expect(TestPersistence.listCalls).toBe(0)
expect(TestPersistence.inspectCalls).toEqual([])
})
it('forwards in-flight list cancellation and waits for cleanup before rejecting', async () => {
const persisted = header('cancelled-exact-list')
TestPersistence.reset([{ meta: persisted, events: eventLog() }])
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
const controller = new AbortController()
const reason = new Error('exact read list cancelled in flight')
const started = Promise.withResolvers<undefined>()
const abortObserved = Promise.withResolvers<undefined>()
const cleanup = Promise.withResolvers<undefined>()
let active = false
TestPersistence.listOverride = async (signal) => {
if (signal === undefined) throw new Error('expected exact-read listing signal')
active = true
const aborted = new Promise<void>((resolve) => {
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
started.resolve(undefined)
await aborted
abortObserved.resolve(undefined)
await cleanup.promise
active = false
signal.throwIfAborted()
return []
}
const pending = run(ctx, persisted.id, controller.signal)
let settled = false
void pending.then(
() => { settled = true },
() => { settled = true },
)
await started.promise
controller.abort(reason)
await abortObserved.promise
expect(settled).toBe(false)
expect(active).toBe(true)
expect(TestPersistence.listSignals).toEqual([controller.signal])
expect(TestPersistence.inspectCalls).toEqual([])
cleanup.resolve(undefined)
await expect(pending).rejects.toBe(reason)
expect(active).toBe(false)
})
it('waits for an ignoring backend to return before preserving the abort reason', async () => {
const persisted = header('ignored-exact-signal')
const entry = { meta: persisted, events: eventLog() }
TestPersistence.reset([entry])
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
const controller = new AbortController()
const reason = new Error('exact read cancelled while backend ignored signal')
const started = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
let active = false
if (inspects) {
TestPersistence.inspectOverride = async () => {
active = true
started.resolve(undefined)
await release.promise
active = false
return structuredClone(entry)
}
} else {
TestPersistence.listOverride = async () => {
active = true
started.resolve(undefined)
await release.promise
active = false
return [structuredClone(persisted)]
}
}
const pending = run(ctx, persisted.id, controller.signal)
let settled = false
void pending.then(
() => { settled = true },
() => { settled = true },
)
await started.promise
controller.abort(reason)
expect(settled).toBe(false)
expect(active).toBe(true)
expect(TestPersistence.listSignals).toEqual([controller.signal])
expect(TestPersistence.inspectSignals).toEqual(inspects ? [controller.signal] : [])
release.resolve(undefined)
await expect(pending).rejects.toBe(reason)
expect(active).toBe(false)
})
})
describe.each(cancellableExactReads.filter(read => read.inspects))(
'$name persisted inspection cancellation',
({ run }) => {
it('forwards cancellation and waits for inspection cleanup before rejecting', async () => {
const persisted = header('cancelled-exact-inspect')
TestPersistence.reset([{ meta: persisted, events: eventLog() }])
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
const controller = new AbortController()
const reason = new Error('exact read inspection cancelled in flight')
const started = Promise.withResolvers<undefined>()
const abortObserved = Promise.withResolvers<undefined>()
const cleanup = Promise.withResolvers<undefined>()
let active = false
TestPersistence.inspectOverride = async (_sessionId, signal) => {
if (signal === undefined) throw new Error('expected exact-read inspection signal')
active = true
const aborted = new Promise<void>((resolve) => {
signal.addEventListener('abort', () => { resolve() }, { once: true })
})
started.resolve(undefined)
await aborted
abortObserved.resolve(undefined)
await cleanup.promise
active = false
signal.throwIfAborted()
throw new Error('unreachable after exact-read cancellation')
}
const pending = run(ctx, persisted.id, controller.signal)
let settled = false
void pending.then(
() => { settled = true },
() => { settled = true },
)
await started.promise
controller.abort(reason)
await abortObserved.promise
expect(settled).toBe(false)
expect(active).toBe(true)
expect(TestPersistence.listSignals).toEqual([controller.signal])
expect(TestPersistence.inspectSignals).toEqual([controller.signal])
cleanup.resolve(undefined)
await expect(pending).rejects.toBe(reason)
expect(active).toBe(false)
})
},
)
describe('session-query exact reads', () => {
it('returns a detached replay-valid full log and rejects a corrupt persisted seed', async () => {
const valid = header('valid-log', 2)
@@ -192,6 +500,341 @@ describe('session-query exact reads', () => {
expect(Object.keys((await ctx.sessionQuery.listSessions())[0]!)).toEqual(['header', 'live', 'persisted'])
})
it('batches unique persisted title observations through one cancellable corpus scan', async () => {
const first = header('batch-title-first', 1)
const second = header('batch-title-second', 2)
const titleEvent = (title: string, time: number): SessionEvent => ({
type: 'session/title',
seq: 0,
time,
data: {
title,
messageSeqs: [],
source: { kind: 'fallback' },
},
})
TestPersistence.reset([
{ meta: first, events: [titleEvent('First title', 10)] },
{ meta: second, events: [titleEvent('Second title', 20)] },
])
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
const signal = new AbortController().signal
const missing = SessionId('batch-title-missing')
const results = await ctx.sessionQuery.readTitleSnapshots(
[second.id, first.id, second.id, missing],
signal,
)
expect(results.map(result => [result.sessionId, result.status])).toEqual([
[second.id, 'fulfilled'],
[first.id, 'fulfilled'],
[missing, 'rejected'],
])
expect(results[0]).toMatchObject({ value: { session: second, title: { title: 'Second title' } } })
expect(results[1]).toMatchObject({ value: { session: first, title: { title: 'First title' } } })
expect(TestPersistence.listCalls).toBe(1)
expect(TestPersistence.inspectCalls).toEqual([second.id, first.id])
expect(TestPersistence.listSignals).toEqual([signal])
expect(TestPersistence.inspectSignals).toEqual([signal, signal])
})
it('bounds persisted title inspection concurrency while preserving ordered results', async () => {
const entries = Array.from({ length: 12 }, (_, index) => {
const meta = header(`bounded-title-${index}`, index)
return { meta, events: eventLog(`title-${index}`) }
})
TestPersistence.reset(entries)
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
let active = 0
let maximum = 0
TestPersistence.inspectOverride = async (id) => {
active += 1
maximum = Math.max(maximum, active)
await new Promise<void>(resolve => setImmediate(resolve))
active -= 1
const entry = TestPersistence.entries.get(id)
if (entry === undefined) throw new Error('missing bounded test session')
return structuredClone(entry)
}
const results = await ctx.sessionQuery.readTitleSnapshots(entries.map(entry => entry.meta.id))
expect(maximum).toBe(SESSION_QUERY_DEFAULT_PERSISTED_INSPECT_CONCURRENCY)
expect(TestPersistence.listCalls).toBe(1)
expect(TestPersistence.inspectCalls).toEqual(entries.map(entry => entry.meta.id))
expect(results.map(result => result.sessionId)).toEqual(entries.map(entry => entry.meta.id))
expect(results.every(result => result.status === 'fulfilled')).toBe(true)
})
it('folds and discards each completed log before its worker dequeues another inspection', async () => {
const entries = Array.from({ length: 5 }, (_, index) => ({
meta: header(`project-title-${index}`, index),
events: [],
}))
TestPersistence.reset(entries)
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
const timeline: string[] = []
const releases = new Map<SessionIdType, () => void>()
TestPersistence.inspectOverride = id => new Promise((resolve) => {
timeline.push(`inspect:${id}`)
releases.set(id, () => {
const marker = `full-log-marker:${id}`
const titleEvent = {
type: 'session/title',
seq: 1,
time: 20,
data: {
title: `Projected ${id}`,
get messageSeqs() {
timeline.push(`project:${id}`)
return []
},
source: { kind: 'fallback' },
},
} as unknown as SessionEvent
resolve({
meta: entries.find(entry => entry.meta.id === id)!.meta,
events: [...eventLog(marker), titleEvent],
})
})
})
const release = (id: SessionIdType): void => {
const settle = releases.get(id)
if (settle === undefined) throw new Error(`inspection ${id} has not started`)
settle()
}
const ids = entries.map(entry => entry.meta.id)
const pending = ctx.sessionQuery.readTitleSnapshots(ids)
await vi.waitFor(() => { expect(TestPersistence.inspectCalls).toHaveLength(4) })
release(ids[0]!)
await vi.waitFor(() => { expect(TestPersistence.inspectCalls).toHaveLength(5) })
// Heap-retention assertions would depend on nondeterministic GC. This ordering
// is the deterministic guard: a retain-all implementation cannot touch the
// observable title getter until every inspection has completed.
expect(timeline.indexOf(`project:${ids[0]}`))
.toBeLessThan(timeline.indexOf(`inspect:${ids[4]}`))
for (const id of ids.slice(1)) release(id)
const results = await pending
expect(results.map(result => result.sessionId)).toEqual(ids)
expect(JSON.stringify(results)).not.toContain('full-log-marker:')
expect(results.every(result => result.status === 'fulfilled')).toBe(true)
})
it('passes cancellation into a stalled persisted title batch and rejects with its reason', async () => {
const persisted = header('stalled-title', 1)
TestPersistence.reset([{ meta: persisted, events: [] }])
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
const controller = new AbortController()
const reason = new Error('title deadline')
let started!: () => void
const inspectStarted = new Promise<void>((resolve) => { started = resolve })
TestPersistence.inspectOverride = (_id, signal) => new Promise((_resolve, reject) => {
started()
signal?.addEventListener('abort', () => { reject(reason) }, { once: true })
})
const pending = ctx.sessionQuery.readTitleSnapshots([persisted.id], controller.signal)
await inspectStarted
controller.abort(reason)
await expect(pending).rejects.toBe(reason)
expect(TestPersistence.listSignals).toEqual([controller.signal])
expect(TestPersistence.inspectSignals).toEqual([controller.signal])
})
it('drains started title inspections after cancellation without starting queued ids', async () => {
const entries = Array.from({ length: 8 }, (_, index) => ({
meta: header(`cancel-queued-title-${index}`, index),
events: eventLog(`queued-${index}`),
}))
TestPersistence.reset(entries)
const persistedInspectConcurrency = 2
const ctx = await liveContext({ persistedInspectConcurrency })
await ctx.plugin(TestPersistence)
const controller = new AbortController()
const reason = new Error('cancel queued title batch')
const releases: Array<() => void> = []
let abortsObserved = 0
let inspectionsSettled = 0
TestPersistence.inspectOverride = (_id, signal) => new Promise((_resolve, reject) => {
signal?.addEventListener('abort', () => { abortsObserved += 1 }, { once: true })
releases.push(() => {
inspectionsSettled += 1
reject(reason)
})
})
const pending = ctx.sessionQuery.readTitleSnapshots(
entries.map(entry => entry.meta.id),
controller.signal,
)
let batchSettled = false
void pending.then(
() => { batchSettled = true },
() => { batchSettled = true },
)
await vi.waitFor(() => {
expect(TestPersistence.inspectCalls).toHaveLength(persistedInspectConcurrency)
})
controller.abort(reason)
await vi.waitFor(() => { expect(abortsObserved).toBe(persistedInspectConcurrency) })
expect(batchSettled).toBe(false)
expect(TestPersistence.inspectCalls)
.toEqual(entries.slice(0, persistedInspectConcurrency).map(entry => entry.meta.id))
for (const release of releases) release()
await expect(pending).rejects.toBe(reason)
expect(inspectionsSettled).toBe(persistedInspectConcurrency)
expect(TestPersistence.inspectCalls)
.toEqual(entries.slice(0, persistedInspectConcurrency).map(entry => entry.meta.id))
})
it('passes cancellation into a stalled persisted title listing and rejects with its reason', async () => {
const persisted = header('stalled-title-list', 1)
TestPersistence.reset([{ meta: persisted, events: [] }])
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
const controller = new AbortController()
const reason = new Error('title listing deadline')
let started!: () => void
const listStarted = new Promise<void>((resolve) => { started = resolve })
TestPersistence.listOverride = signal => new Promise((_resolve, reject) => {
started()
signal?.addEventListener('abort', () => { reject(reason) }, { once: true })
})
const pending = ctx.sessionQuery.readTitleSnapshots([persisted.id], controller.signal)
await listStarted
controller.abort(reason)
await expect(pending).rejects.toBe(reason)
expect(TestPersistence.listSignals).toEqual([controller.signal])
expect(TestPersistence.inspectCalls).toEqual([])
})
it('isolates title read and fold failures while preferring a live owner attached during inspection', async () => {
const attached = header('batch-title-attached', 1)
const failed = header('batch-title-failed', 2)
const malformed = header('batch-title-malformed', 3)
const inspectFailure = new Error('one title inspect failed')
const malformedTitle = {
type: 'session/title',
seq: 0,
time: 30,
data: {
title: 'malformed',
source: { kind: 'fallback' },
},
} as unknown as SessionEvent
TestPersistence.reset([
{ meta: attached, events: eventLog('stale persisted') },
{ meta: failed, events: [] },
{ meta: malformed, events: [malformedTitle] },
])
const ctx = await liveContext()
await ctx.plugin(TestPersistence)
TestPersistence.inspectOverride = (id) => {
if (id === failed.id) return Promise.reject(inspectFailure)
const entry = TestPersistence.entries.get(id)
if (entry === undefined) return Promise.reject(new Error('missing test session'))
if (id === attached.id) {
const session = ctx.sessions.create(attached.id, { meta: { createdAt: attached.createdAt } })
session.append('session/title', {
title: 'Attached live title',
messageSeqs: [],
source: { kind: 'fallback' },
})
}
return Promise.resolve(structuredClone(entry))
}
const results = await ctx.sessionQuery.readTitleSnapshots([
attached.id,
failed.id,
malformed.id,
])
expect(results[0]).toMatchObject({
status: 'fulfilled',
value: { session: attached, title: { title: 'Attached live title' } },
})
expect(results[1]).toMatchObject({
sessionId: failed.id,
status: 'rejected',
reason: {
code: 'SESSION_QUERY_PERSISTENCE_FAILED',
cause: inspectFailure,
},
})
expect(results[2]).toMatchObject({ sessionId: malformed.id, status: 'rejected' })
if (results[2]?.status !== 'rejected') throw new Error('expected malformed title rejection')
expect(results[2].reason).toBeInstanceOf(TypeError)
})
it('preserves live batch results across missing persistence, listing failure, and late attachment', async () => {
const liveOnly = await liveContext()
const live = liveOnly.sessions.create(SessionId('batch-title-live'))
const missing = SessionId('batch-title-no-persistence')
await expect(liveOnly.sessionQuery.readTitleSnapshots([live.id, live.id])).resolves.toEqual([{
sessionId: live.id,
status: 'fulfilled',
value: { session: live.header },
}])
await expect(liveOnly.sessionQuery.readTitleSnapshots([live.id, missing])).resolves.toMatchObject([
{ sessionId: live.id, status: 'fulfilled' },
{ sessionId: missing, status: 'rejected' },
])
await expect(liveOnly.sessionQuery.readTitleSnapshot(missing))
.rejects.toThrow(expectCode('SESSION_QUERY_SESSION_NOT_FOUND'))
const persisted = header('batch-title-persisted', 1)
const late = header('batch-title-late', 2)
TestPersistence.reset([{ meta: persisted, events: [] }])
const mixed = await liveContext()
const mixedLive = mixed.sessions.create(SessionId('batch-title-mixed-live'))
await mixed.plugin(TestPersistence)
TestPersistence.afterList = () => {
mixed.sessions.create(late.id, { meta: { createdAt: late.createdAt } })
TestPersistence.afterList = undefined
}
await expect(mixed.sessionQuery.readTitleSnapshots([
mixedLive.id,
persisted.id,
late.id,
])).resolves.toMatchObject([
{ sessionId: mixedLive.id, status: 'fulfilled' },
{ sessionId: persisted.id, status: 'fulfilled' },
{ sessionId: late.id, status: 'fulfilled' },
])
TestPersistence.reset()
TestPersistence.listFailure = new Error('title listing failed')
const failedList = await liveContext()
const survivingLive = failedList.sessions.create(SessionId('batch-title-list-live'))
await failedList.plugin(TestPersistence)
await expect(failedList.sessionQuery.readTitleSnapshots([survivingLive.id, missing]))
.resolves.toMatchObject([
{ sessionId: survivingLive.id, status: 'fulfilled' },
{
sessionId: missing,
status: 'rejected',
reason: expectCode('SESSION_QUERY_PERSISTENCE_FAILED'),
},
])
})
it('lists live sessions deterministically and returns detached headers', async () => {
const ctx = await liveContext()
const older = ctx.sessions.create(SessionId('older'), { meta: { createdAt: 1 } })
@@ -408,9 +1051,15 @@ describe('session-query exact reads', () => {
await ctx.plugin(TestPersistence)
TestPersistence.listFailure = new Error('list unavailable')
TestPersistence.inspectFailure = new Error('inspect unavailable')
const signal = new AbortController().signal
await expect(ctx.sessionQuery.listEvents(live.id)).resolves.toHaveLength(2)
await expect(ctx.sessionQuery.readEvent({ sessionId: live.id, seq: 1 })).resolves.toMatchObject({ target: { seq: 1 } })
await expect(ctx.sessionQuery.traceEvent({ sessionId: live.id, seq: 1 }, signal))
.resolves.toMatchObject({ session: { id: live.id }, target: { seq: 1 } })
await expect(ctx.sessionQuery.readEvent({ sessionId: live.id, seq: 1 }, signal))
.resolves.toMatchObject({ target: { seq: 1 } })
expect(TestPersistence.listSignals).toEqual([])
expect(TestPersistence.inspectSignals).toEqual([])
await expect(ctx.sessionQuery.listSessions()).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
await expect(ctx.sessionQuery.listEvents(SessionId('durable'))).rejects.toThrow(expectCode('SESSION_QUERY_PERSISTENCE_FAILED'))
})
@@ -459,10 +1108,16 @@ describe('session-query exact reads', () => {
const direct = new Context()
await direct.plugin(SessionStore)
expect(new TestSessionQueryService(direct)).toBeInstanceOf(SessionQueryService)
const invalid = new Context()
await invalid.plugin(SessionStore)
expect(() => new TestSessionQueryService(invalid, { readWindowMax: -1 }))
.toThrow(expectCode('SESSION_QUERY_INVALID_CONFIG'))
for (const config of [
{ readWindowMax: -1 },
{ persistedInspectConcurrency: 0 },
{ persistedInspectConcurrency: Number.MAX_SAFE_INTEGER + 1 },
]) {
const invalid = new Context()
await invalid.plugin(SessionStore)
expect(() => new TestSessionQueryService(invalid, config))
.toThrow(expectCode('SESSION_QUERY_INVALID_CONFIG'))
}
})
it('leaves the optional persistence dependency optional', async () => {

View File

@@ -1,6 +1,6 @@
import SessionQueryService from '@deepseek-ai/dsh-session-query'
import type {
SessionEventSearchHit,
SessionEventSearchPage,
SessionEventSearchRequest,
SessionSearchExecContext,
SessionSearchHit,
@@ -17,10 +17,13 @@ export class TestSessionQueryService extends SessionQueryService {
return Promise.resolve({ items: [] })
}
override searchEvents(
_request: SessionEventSearchRequest,
override async searchEvents(
request: SessionEventSearchRequest,
_exec?: SessionSearchExecContext,
): Promise<SessionSearchPage<SessionEventSearchHit>> {
return Promise.resolve({ items: [] })
): Promise<SessionEventSearchPage> {
return {
session: (await this.readSurface(request.sessionId)).session,
items: [],
}
}
}

View File

@@ -0,0 +1,74 @@
# @deepseek-ai/dsh-tool-session-query
Workspace-authorized model tools over `ctx.sessionQuery`. The opt-in package depends only on the unified interface and registers `session_search`, `session_event_search`, `session_trace`, `session_event_trace`, and `session_event_read`; shipped host compositions do not mount it by default.
## Configuration
| Key | Default | Meaning |
|---|---:|---|
| `maxSearchResults` | `100` | Maximum authorized non-self hits collected across internal provider pages |
| `searchTimeoutMs` | `30000` | Cooperative deadline attached to both full-text search tools |
The caller comes exclusively from `ToolExecution.exec.agent`. Cross-session access requires exact equality between the target and caller session `cwd` values; a caller without `cwd` can inspect only itself. Search never exposes provider cursors, offsets, page sizes, or a model-controlled limit. Because one search consumes generation-bound provider cursors internally, both search tools execute exclusively with sibling tool calls; the three exact trace/read tools opt into parallel execution. Every exact executor passes its unchanged execution signal through authorization and the service trace/read, so cancellation waits for cooperative persistence cleanup and retains the signal's exact reason. Timestamps at the tool boundary require an explicit `Z` or numeric offset and become inclusive epoch-millisecond filters.
`session_search` always omits the caller session. Requested parent ids are deduplicated and checked against caller-workspace authority before FTS; only authorized ids reach the provider, while missing and cross-workspace guesses behave identically and the root marker remains independently ORed. A current-session `session_event_search` stops immediately before the step that invoked it, so the active assistant output and logged tool call cannot match themselves. Direct targets are authorized before trace, event, or title reads. Lineage output replaces unauthorized ancestor and descendant boundaries with markers that contain no hidden session id.
Every trusted `ctx.sessionQuery` call crosses one model-boundary sanitizer. Caller cancellation is checked first and preserved exactly. Available corpus and provider diagnostics, including safely inspectable nested causes, are logged internally on a best-effort basis; unprintable failures use a fixed log placeholder. Diagnostic formatting and error classification are independently guarded, so an unprintable cause cannot escape or prevent a safely classified outer error, while unsafe classification or logging falls back to the fixed `SESSION_QUERY_TOOL_FAILED` code and message. Local argument-validation and authorization errors retain their precise tool-owned messages.
The package deliberately performs no byte or character truncation and does not import a spill backend. Deployments that need bounded inline output mount `@deepseek-ai/dsh-spill-policy`, which can replace the rendered text after execution while retaining the complete result.
## Model Experience
### System prompt
#### What the model sees
The model receives one fixed prior-history guidance section.
##### Prior-history guidance
```markdown
Use session_search to find relevant work from prior sessions, or session_event_search to search earlier events in one session. Search results are cursor-free and workspace-scoped. Follow a useful hit with session_trace, session_event_trace, or session_event_read when you need lineage, relationships, or exact data.
```
#### Token effect
One fixed concise section is present on each request while the plugin is mounted.
#### KV Cache effect
Prefix-stable while the plugin and guidance text are unchanged.
### Tool schemas
#### What the model sees
The model sees the generated [`session_search`, `session_event_search`, `session_trace`, `session_event_trace`, and `session_event_read` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-session-query). Search filters add fixed schema tokens, while cursors, workspace paths, output pagination, and model-controlled result limits remain absent.
#### Token effect
Five fixed read-only schemas are sent on each request while visible.
#### KV Cache effect
Prefix-stable while tool visibility and definitions are unchanged.
### Tool results
#### What the model sees
Each successful call emits one plain-text block. Search results include titles and best-match excerpts; traces include all authorized relationships; event reads include unabridged target JSON. The generic spill policy may replace oversized inline text with its preview, opaque locator, and retrieval hint.
#### Token effect
Results are data-dependent and remain in logged tool history until compaction; `maxSearchResults` bounds search-hit count.
#### KV Cache effect
Append-only result text follows the reusable request prefix and does not invalidate earlier cache entries.
## Known Limitations and Deferred Work
- Search returns at most the deployment cap and asks the model to narrow its query when more matches exist; it offers no continuation token.
- Workspace identity is conservative exact-string `cwd` equality, so symlink-equivalent paths do not share authority.
- Custom compositions without the generic spill policy accept complete trace and event payloads inline.

View File

@@ -0,0 +1,58 @@
{
"name": "@deepseek-ai/dsh-tool-session-query",
"description": "Workspace-authorized model-facing session history search, trace, and event read tools",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-query": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-query": "workspace:^",
"@deepseek-ai/dsh-session-query-sqlite": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-timeout-policy": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

Some files were not shown because too many files have changed in this diff Show More