Merge branch 'master' into worktree-webheadless
This commit is contained in:
@@ -1,10 +1,7 @@
|
||||
/**
|
||||
* Browser half of the wire consumer layer (contract: api-contracts v3
|
||||
* section 3; export inventory = v3 §3.2). The wire is this package's client
|
||||
* half in its entirety — apply mounts ctx.connection: the shared api client
|
||||
* plus the connection controller handle. Mode selection (?fixture) happens
|
||||
* here so the rest of the client tree is mode-blind; the controller's sinks
|
||||
* are wired by the runtime plugin (object layer), which injects this service.
|
||||
* Browser wire client. The plugin selects fixture or HTTP transport, provides
|
||||
* the shared API client, and lets the runtime object layer start the stream
|
||||
* controller with its sinks.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { IApiClient } from './api.ts'
|
||||
@@ -23,9 +20,8 @@ export type {
|
||||
} from './api.ts'
|
||||
export { RpcId, AbstractApiClient, transportError } from './api.ts'
|
||||
|
||||
// ---- Connection loop types (part of the ConnectionHandle.start contract;
|
||||
// the controller class itself stays package-internal — apply owns the loop,
|
||||
// tests reach it via src) ----
|
||||
// Connection loop types are public through ConnectionHandle.start; the
|
||||
// controller remains package-internal.
|
||||
export type { ConnectionConfig, ConnectionSinks, ConnectionState }
|
||||
|
||||
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
/**
|
||||
* Connection plugin, node half: the host end of the web transport. Registers
|
||||
* the /api prefix route on the web server and bridges node:http requests to
|
||||
* the transport-agnostic fetch-shaped api handler. The wire consumer layer
|
||||
* lives in the client half (src/client/ — contract: api-contracts v3
|
||||
* section 3); consumers import the /client subpath.
|
||||
*/
|
||||
/** Host HTTP bridge for browser-client RPC. */
|
||||
import type { Context } from 'cordis'
|
||||
// Type-only route import; it also carries the httpServer Context merge.
|
||||
// Activates the httpServer Context merge used below.
|
||||
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import { API_PATH } from './api-path.ts'
|
||||
@@ -14,16 +8,15 @@ import { bridge } from './http-bridge.ts'
|
||||
|
||||
export { API_PATH } from './api-path.ts'
|
||||
|
||||
/** Cordis plugin name. */
|
||||
/** Stable Cordis plugin name. */
|
||||
export const name = 'client-connection'
|
||||
|
||||
/** Required services: the route registry and the api gateway. */
|
||||
/** Services required before mounting the route. */
|
||||
export const inject = ['httpServer', 'apiProxy']
|
||||
|
||||
/**
|
||||
* Mount the /api transport: wrap the api gateway into a fetch handler and
|
||||
* serve it under the /api prefix.
|
||||
* @param ctx - host plugin context carrying httpServer and apiProxy.
|
||||
* Mounts the API gateway under the browser transport prefix.
|
||||
* @param ctx - Host plugin context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const apiHandler = toFetchHandler(ctx.apiProxy)
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
/**
|
||||
* i18n plugin, browser half: namespace x locale dictionary registry with a
|
||||
* bound translate function whose reference is stable (safe for inject
|
||||
* surfaces). Mounts ctx.i18n and seeds the zh/en base dictionaries.
|
||||
* Contract: api-contracts v3 section 8.
|
||||
* Browser-side locale registry. Bound translation functions retain stable
|
||||
* identity for injected consumers.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
// The snapshot-store engine lives in runtime (store relocation): framework
|
||||
// data stores like this locale cell use it directly. The store carries no
|
||||
// hook — a React consumer binds a selector hook via web-react's
|
||||
// bindSnapshotSelector at its own seam (none exists today; the current
|
||||
// consumers are translate() reads and test-side subscribe/set).
|
||||
// Snapshot stores are framework-neutral; React consumers bind hooks at their
|
||||
// rendering boundary.
|
||||
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { en } from '../locales/en.ts'
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
/**
|
||||
* i18n plugin, node half. Pure UI plugin: the empty apply exists so the
|
||||
* plugin appears in the host cordis.yml / Loader (load and lifecycle follow
|
||||
* the host; the browser half ships via exports["./client"], discovered
|
||||
* through the package.json dshClient declaration). Everything else —
|
||||
* I18nService, Translate, LocaleDict — lives in the client half; consumers
|
||||
* import the /client subpath. Contract: api-contracts v3 section 8.
|
||||
*/
|
||||
/** Host loader entry for the browser implementation exported from `./client`. */
|
||||
|
||||
/** Host plugin body — no host-side behavior for the i18n plugin. */
|
||||
export function apply(): void {}
|
||||
|
||||
@@ -161,11 +161,7 @@ function deepFreeze(value: unknown): void {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- defineStore shell (slot terminal design §4) ----
|
||||
// The type authority is ui-slots' store family (create(scopeKey?) and
|
||||
// clearPersisted() included); this module houses only the engine-backed
|
||||
// implementation. The one engine-side widening left: instances expose the
|
||||
// raw engine store for framework/test surfaces.
|
||||
// ui-slots owns the contract; this module supplies the engine implementation.
|
||||
|
||||
/** A live engine instance: the contract instance plus the raw engine store. */
|
||||
export interface EngineStoreInstance<T, A extends ActionsDecl<T>> extends StoreInstance<T, A> {
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
/**
|
||||
* Browser half: the whole runtime contract surface (api-contracts v3 §4) —
|
||||
* SlotsService (declaration ledger + renderer seam + store axis, built-in
|
||||
* 'root'), SessionsService (list store + current selection + scope tree +
|
||||
* object layer), and the cordis Context/Events merges. apply mounts
|
||||
* ctx.slots + ctx.sessions and wires the connection stream loop into the
|
||||
* object layer. A static-arrival entry: the web shell bundles this module
|
||||
* and mounts it through the host graph (module loading lives in
|
||||
* @deepseek-ai/dsh-client-modules, entry governance in the vendored Loader).
|
||||
* Browser runtime services for slots, sessions, and connection-stream
|
||||
* delivery. The web shell mounts this static client entry through the host
|
||||
* plugin graph.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -17,15 +12,11 @@ import type { SessionListState } from './sessions/service.ts'
|
||||
import type { ConversationSnapshot, RunningToolCall, ToolResultNode } from './sessions/conversation.ts'
|
||||
|
||||
export { SlotsService } from './slots.ts'
|
||||
// RootOwnerProps rides the 'root' SlotMap row (both migrated here from
|
||||
// ui-layout: the framework slot is declared by the framework package).
|
||||
export type { RootOwnerProps } from './slots.ts'
|
||||
export { SessionsService, scopeOf } from './sessions/service.ts'
|
||||
export type { Session } from './sessions/session.ts'
|
||||
export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts'
|
||||
// The snapshot-store engine lives here since the store migration (the data
|
||||
// layer owns its substrate; web-react is React glue only). The './client'
|
||||
// main export is the single serving door — no store subpath.
|
||||
// Runtime owns the snapshot store; web-react only binds it to React.
|
||||
export { createSnapshotStore, defineStore, shallowEqual } from './contract/store.ts'
|
||||
export type {
|
||||
EngineStoreHandle, EngineStoreInstance, ObservableSnapshot, SnapshotStore,
|
||||
@@ -35,21 +26,11 @@ export type {
|
||||
RunningToolCall, SteeringMessageNode,
|
||||
ToolResultNode, UnknownSurfaceNode, UserMessageNode,
|
||||
} from './sessions/conversation.ts'
|
||||
// PendingWait is a value export: tests construct fixture waits directly.
|
||||
export { PendingWait } from './sessions/pending.ts'
|
||||
export type { PendingInteraction, PendingKind, PendingPayloads } from './sessions/pending.ts'
|
||||
export type { SessionId } from '@deepseek-ai/dsh-client-connection/client'
|
||||
|
||||
// ---- Narrowed aliases (the single narrowing point of the slot type chain:
|
||||
// ui-slots/web-react stay generic and dependency-inverted; the client-tree
|
||||
// concrete types live here, where their subjects live) ----
|
||||
|
||||
/**
|
||||
* The client cordis context face: the base Context plus the service keys
|
||||
* this package's declaration merge contributes (slots/sessions/loader) and
|
||||
* every later plugin's merge. A plain alias — the merges land on Context
|
||||
* itself inside the client program; the name marks intent at consumer seams.
|
||||
*/
|
||||
/** Client-side Cordis context after declaration merging. */
|
||||
export type ClientContext = Context
|
||||
|
||||
/** The conversation-snapshot selector hook (ConvViewProps/ToolRowProps take this). */
|
||||
@@ -69,14 +50,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* every session-scope slot component receives these from the framework.
|
||||
*/
|
||||
interface SessionStandardProps {
|
||||
/** Selector hook over this session's conversation snapshot. */
|
||||
useSession: SnapshotSelectorHook<ConversationSnapshot>
|
||||
/** The framework-resolved session id (owners never pass it). */
|
||||
sessionId: SessionId
|
||||
}
|
||||
/** Global standard kit, real members: the session-list hook every slot component receives. */
|
||||
/** Props injected into every global slot component. */
|
||||
interface GlobalStandardProps {
|
||||
/** Selector hook over the session list snapshot (`current` included — the arbitrated selection seat). */
|
||||
useSessions: SnapshotSelectorHook<SessionListState>
|
||||
}
|
||||
}
|
||||
@@ -99,9 +78,8 @@ declare module 'cordis' {
|
||||
/** Required services: the wire handle mounted by the connection plugin. */
|
||||
export const inject = ['connection']
|
||||
|
||||
/**
|
||||
* Client plugin body: mount slots + sessions, start the stream loop.
|
||||
* @param ctx - client cordis context.
|
||||
/** Mounts the browser runtime services and connection stream.
|
||||
* @param ctx - Client Cordis context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.plugin(SlotsService)
|
||||
|
||||
@@ -24,10 +24,10 @@ export interface CallIndexEntry {
|
||||
callView: ToolCallView | null
|
||||
}
|
||||
|
||||
/** Non-surface-eligible sentinel event (safely skipped by surfaceOpOf's undefined branch).
|
||||
* 'noop/padding' is not a real event type on purpose: a genuine type with fake data would
|
||||
* surface as garbage the day anyone adds handling for it (design §D.1; the cast is the one
|
||||
* place a synthetic event enters the window). */
|
||||
/** Non-surface sentinel used to preserve paged-window sequence offsets.
|
||||
* `noop/padding` is deliberately not a real event type, so it cannot acquire
|
||||
* surface behavior; this cast is the only synthetic event entry point.
|
||||
*/
|
||||
function paddingEvent(seq: number): SessionEvent {
|
||||
return { type: 'noop/padding', seq, time: 0, data: {} } as unknown as SessionEvent
|
||||
}
|
||||
|
||||
@@ -291,8 +291,7 @@ export class SessionsService {
|
||||
fiber,
|
||||
ctx,
|
||||
binding: { sessionId: id, session, ctx },
|
||||
// Bare source form (store migration): the Session object IS the
|
||||
// observable; the React side binds the useSession hook per cell.
|
||||
// Session is the observable; React binds a selector hook at its own seam.
|
||||
cell: { sessionId: id, session },
|
||||
}
|
||||
this.scopes.set(id, record)
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
// Session: wraps every contract call that needs a sessionId + all conversation state for this
|
||||
// session (design §A.2/§A.9/§D.2/§D.3). Instances are resident (ruling 2): never destroyed once
|
||||
// created, they keep consuming mux frames in the background; React connects directly via
|
||||
// subscribe/getSnapshot.
|
||||
// Sessions remain resident after creation so they continue consuming mux frames off-screen.
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session/types'
|
||||
@@ -22,14 +19,12 @@ import { FoldAdapter } from './fold-adapter.ts'
|
||||
import { Notifier } from './notifier.ts'
|
||||
import { PartialAccumulator } from './partial.ts'
|
||||
|
||||
/** Messages per page (F.4 ledger: promote to Config at graduation; every call site references this constant). */
|
||||
/** Messages requested per history page. */
|
||||
export const PAGE_MESSAGES = 50
|
||||
|
||||
/**
|
||||
* Per-session state owner: event window + fold + partial, snapshot out via
|
||||
* subscribe/getSnapshot (see the web client architecture RFC). Bare source
|
||||
* only (store migration): the React machinery binds the per-cell useSession
|
||||
* hook at its own seam — no selector hook member lives on the data layer.
|
||||
* Owns a session's event window, derived conversation state, and observable
|
||||
* snapshot. React bindings remain outside this data layer.
|
||||
*/
|
||||
export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
// ---- Window and derived state (all private; the snapshot is the only read surface) ----
|
||||
@@ -54,8 +49,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
* Derived from window events (turn/end sweep) — rebuilt by rebuildDerivedFromWindow like partial/openCalls. */
|
||||
private frozenNodes: ConversationNode[] = []
|
||||
private pending = new Map<string, PendingInteraction>()
|
||||
// Revision counters + caches backing the snapshot's reference-stability contract (§A.9.4/§C.2,
|
||||
// audit S5): buildSnapshot reuses the previous array when the revision is unchanged, so
|
||||
// Revision counters preserve array identity when derived content is unchanged, so
|
||||
// React.memo children survive unrelated snapshot swaps (chunk storms must not re-render every
|
||||
// tool card and pending card). Mutation sites bump the matching revision. partial needs no
|
||||
// counter — PartialAccumulator.toPartial already returns a cached reference when unchanged.
|
||||
@@ -69,9 +63,9 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
private removed = false
|
||||
private promptError: PromptError | null = null
|
||||
private lastAgentError: string | null = null
|
||||
/** Buffer for live events arriving while open/resync is in flight (stitched by seq once history lands, §D.3). */
|
||||
/** Live events buffered during open/resync and stitched by sequence once history lands. */
|
||||
private liveBuffer: { event: SessionEvent; view: ToolEventView | undefined }[] = []
|
||||
/** Gap-repair (resync-lite) in flight: acceptLiveEvent detours to liveBuffer until the tail page lands (audit S3). */
|
||||
/** Gap repair in flight; live events detour to the buffer until the tail page lands. */
|
||||
private stitching = false
|
||||
/** subscribed.lastSeq baseline (gap detection; null when no subscribed frame arrived — degrade to the liveBuffer dedup path). */
|
||||
private subscribedLastSeq: number | null = null
|
||||
@@ -292,8 +286,7 @@ export class Session implements ObservableSnapshot<ConversationSnapshot> {
|
||||
this.notifier.markDirty()
|
||||
}
|
||||
|
||||
/** Instance-eviction hook, reserved no-op (design §F.6): resident instances are never destroyed
|
||||
* in v1; an eviction policy lands here (unsubscribe, drop buffers) without touching call sites. */
|
||||
/** No-op because session instances remain resident. */
|
||||
dispose(): void {}
|
||||
|
||||
// ---- 私有 ----
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
/**
|
||||
* Runtime plugin, node half. The implementation lives entirely in the client
|
||||
* half (src/client/ — SlotsService, SessionsService + object layer, and the
|
||||
* shell-held ClientLoader under ./loader); consumers import the /client or
|
||||
* /loader subpaths. The empty apply exists so the plugin appears in the host
|
||||
* Loader (lifecycle governance + dshClient discovery). Contract:
|
||||
* api-contracts v3 section 4.
|
||||
*/
|
||||
/** Host loader entry for the browser runtime exported from `./client` and `./loader`. */
|
||||
|
||||
/** Host plugin body — no host-side behavior for the runtime plugin. */
|
||||
export function apply(_ctx: unknown): void {}
|
||||
|
||||
@@ -187,8 +187,7 @@ describe('cell (render-layer session kit)', () => {
|
||||
const cell = b.svc.cell('s1')
|
||||
expect(cell).toBeDefined()
|
||||
expect(cell?.sessionId).toBe('s1')
|
||||
// Bare-source form (store migration): the cell carries the Session
|
||||
// observable itself; hook binding happens in the React machinery.
|
||||
// Hook binding happens in React; the cell carries the observable itself.
|
||||
expect(cell?.session).toBe(b.svc.manager.get(sid('s1')))
|
||||
expect(b.svc.cell('s1')).toBe(cell)
|
||||
expect(b.svc.cell('ghost')).toBeUndefined()
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
/**
|
||||
* Client plugin body: register the conversation/details slot occupants and
|
||||
* the no-session empty state, contribute the chat entry into the
|
||||
* 'conversation.view' ring that the conversation registration declares, then
|
||||
* mount the conversation service (class plugin) and the bash toolview sample.
|
||||
* Assembly only — components receive everything through props: the framework
|
||||
* standard kit and store faces arrive automatically from the declarations
|
||||
* below; the inject factories contribute the plain-data-and-callbacks
|
||||
* business face (design §5). Tool rows are ordinary keyed-slot registrations
|
||||
* into 'conversation.chat.toolview' — no dedicated registry exists.
|
||||
*/
|
||||
/** Registers the conversation components, shared store, and service callbacks. */
|
||||
import type { Context } from 'cordis'
|
||||
import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { SessionId, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
@@ -25,7 +15,7 @@ import { ConversationRoot } from './skeleton/ConversationRoot.tsx'
|
||||
import { DetailsPanel } from './skeleton/DetailsPanel.tsx'
|
||||
import { EmptyState } from './skeleton/EmptyState.tsx'
|
||||
|
||||
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */
|
||||
/** Services required by the conversation plugin. */
|
||||
export const inject = ['slots', 'layout', 'sessions']
|
||||
|
||||
/** Resolve the session-scoped conversation service (scope-addressed send/cancel), failing loud. */
|
||||
@@ -37,24 +27,17 @@ function scopedConversation(sessions: SessionsService, id: SessionId): Conversat
|
||||
return conversation
|
||||
}
|
||||
|
||||
/**
|
||||
* Client plugin body.
|
||||
* @param ctx - client root context.
|
||||
/** Mounts the conversation plugin.
|
||||
* @param ctx - Client root context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
const sessions = ctx.sessions
|
||||
const layout = ctx.layout
|
||||
const slots = ctx.slots
|
||||
|
||||
// Shared store handle, constructed here so its identity lives and dies with
|
||||
// this fiber (a module-level handle would be a de-facto singleton). The
|
||||
// conversation, chat-view, and details registrations all declare it; same
|
||||
// scope key = same instance, so chat-view selection writes and details
|
||||
// reads meet in one store.
|
||||
// Apply-time construction keeps store identity bound to this fiber.
|
||||
const chatStore = createChatStore()
|
||||
|
||||
// Tab projection over the view ring's ledger (list entries carry id/order/
|
||||
// label as registration options; the ledger keeps them order-sorted).
|
||||
const viewTabs = (): ViewTab[] => {
|
||||
const tabs: ViewTab[] = []
|
||||
for (const entry of slots.entries('conversation.view')) {
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
// StatsLine: the session stats row (figma 122:11212 "cache hit 92% · 1,284
|
||||
// tokens · 45.2s · 5 turns · 32 steps"), rendered by ChatView under the flow
|
||||
// (part of the chat view body — the chrome attachment mechanism retired with
|
||||
// the view ring). Duration has no data source in P-I (ledger). Subscribes to
|
||||
// `nodes` only: chunk batches never swap that reference, so the row renders
|
||||
// zero times during streaming (the RFC performance model's acceptance row).
|
||||
// Settled-node identity prevents stream-delta updates from rerendering this row.
|
||||
|
||||
import { memo, useMemo } from 'react'
|
||||
import type { ConversationSnapshot } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
@@ -1,15 +1,4 @@
|
||||
/**
|
||||
* Slot-ring contract for the conversation package: the 'conversation.view'
|
||||
* slot this package declares (the view ring — one list entry per conversation
|
||||
* view tab), the chat view's per-tool row hole ('conversation.chat.toolview',
|
||||
* keyed on the wire tool name), and the composed props shapes its registrants
|
||||
* mount into the layout-owned slots (conversation / details /
|
||||
* conversation.empty) plus its own slots. Terminal slot design (§3): full
|
||||
* component props are the automatic shares — PropsRuntime<K> (framework
|
||||
* standard kit) & PropsRenderSlots<S> (declared children) & PropsStore<H>
|
||||
* (declared store's read/write faces) & the injected business face declared
|
||||
* here.
|
||||
*/
|
||||
/** Conversation slot declarations and their composed component props. */
|
||||
import type { PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { PendingInteraction, SessionId, ToolCallBlock } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { createChatStore } from '../stores.ts'
|
||||
@@ -93,15 +82,9 @@ export type ConvViewProps = PropsRuntime<'conversation.view'>
|
||||
/** The shared chat store handle type (apply constructs one; the conversation, details, and chat-view registrations all declare it). */
|
||||
export type ChatStore = ReturnType<typeof createChatStore>
|
||||
|
||||
/**
|
||||
* Injected share of the conversation slot: plain data and callbacks only
|
||||
* (design §5 — hooks are framework-made). The store lines that used to ride
|
||||
* here live in the declared {@link ChatStore}; ancestry derives from the
|
||||
* standard useSessions hook in-component; views render through the declared
|
||||
* 'conversation.view' child slot, with this face projecting the tab strip.
|
||||
*/
|
||||
/** Business callbacks injected into the conversation slot. */
|
||||
export interface ConversationInjected {
|
||||
/** View tab read face (uSES triple over the 'conversation.view' slot ledger). */
|
||||
/** Views projected from the `conversation.view` slot ledger. */
|
||||
views: {
|
||||
list(): readonly ViewTab[]
|
||||
subscribe(fn: () => void): () => void
|
||||
@@ -111,7 +94,6 @@ export interface ConversationInjected {
|
||||
send(text: string, mode: 'queue' | 'steer'): void
|
||||
/** Cancel the in-flight turn (failure surfaces via snapshot.promptError). */
|
||||
stop(): void
|
||||
/** Navigate to another session (breadcrumb ancestors). */
|
||||
open(id: SessionId): void
|
||||
}
|
||||
|
||||
@@ -123,7 +105,6 @@ export interface ConversationInjected {
|
||||
* with zero owner changes.
|
||||
*/
|
||||
export interface ComposerChainProps {
|
||||
/** The session's live pending waits, in arrival order (snapshot reference). */
|
||||
interactions: readonly PendingInteraction[]
|
||||
}
|
||||
|
||||
@@ -139,7 +120,6 @@ export type ConversationSlotProps =
|
||||
export interface ChatViewInjected {
|
||||
/** Selection write + details panel opening in one gesture (store action + layout orchestration). */
|
||||
openDetails(target: SelectionTarget): void
|
||||
/** Pull one older history page. */
|
||||
loadOlder(): void
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,4 @@
|
||||
/**
|
||||
* Shared conversation contract primitives: the view tab projection (slot
|
||||
* entries in 'conversation.view' surface as tabs), the chat store state
|
||||
* shared through the declared store, and the selection primitives every
|
||||
* domain consumes. Shared face between the skeleton domain (tab strip +
|
||||
* view outlet) and the chat domain; domain implementation files import this,
|
||||
* never each other. The view ring itself IS the 'conversation.view' slot
|
||||
* (contract in slots.ts) — the package-local view registry is retired, and
|
||||
* so is the hand-threaded translate channel (framework-level per-slot i18n
|
||||
* injection is the planned replacement).
|
||||
*/
|
||||
/** Shared conversation view, selection, and store-state contracts. */
|
||||
|
||||
/** Tool call identity as carried on the wire (branded upstream in connection). */
|
||||
export type CallId = string
|
||||
@@ -23,11 +13,8 @@ export interface SelectionTarget { turnSeq: number; stepSeq?: number; callId?: C
|
||||
export interface ViewTab { id: string; label: string }
|
||||
|
||||
/**
|
||||
* Chat store state (slot terminal design §4): the per-session store shared by
|
||||
* the conversation, chat-view, and details registrations. `createChatStore`
|
||||
* implements this shape. `view` may carry a stale persisted id after a view
|
||||
* plugin unloads — the slot ledger is the runtime validator (unknown ids fall
|
||||
* back to the first registered view).
|
||||
* Per-session state shared by conversation, chat-view, and details slots.
|
||||
* Unknown persisted view ids fall back to the first registered view.
|
||||
*/
|
||||
export interface ChatStoreState {
|
||||
/** Details-linkage channel (conversation writes, details reads). */
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
/**
|
||||
* Conversation domain plugin, browser half: skeleton (header/tabs/composer),
|
||||
* the 'conversation.view' slot ring (chat entry here; other plugins
|
||||
* contribute view tabs through ctx.slots), the chat view's keyed
|
||||
* 'conversation.chat.toolview' row hole, scope-addressed ConversationService,
|
||||
* minimal details panel. Contract: api-contracts v3 section 7. Thin shell:
|
||||
* type surfaces live in contract/, assembly in apply.ts; the implementation
|
||||
* domains (skeleton/chat) never import each other — contract/ is their only
|
||||
* shared face.
|
||||
* Browser conversation plugin. `contract/` is the shared type boundary
|
||||
* between the independently implemented skeleton and chat domains; `apply.ts`
|
||||
* owns their slot assembly.
|
||||
*/
|
||||
import type { ConversationService } from './service.ts'
|
||||
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
/**
|
||||
* ConversationService implementation: scope-addressed send/cancel and the
|
||||
* empty-state startSession chain. Contract: api-contracts v3 section 7.
|
||||
* Selection/draft state moved to the declared chat store (slot terminal
|
||||
* design §4); the view registry moved to the 'conversation.view' slot (slot
|
||||
* ledger owns registration, ordering, and disposal) — what remains is the
|
||||
* send/stop orchestration face.
|
||||
* Scope-addressed conversation send, cancel, and empty-state session startup.
|
||||
*
|
||||
* Scope addressing rides the cordis Service tracker: property access through
|
||||
* `ctx.conversation` rebinds `this.ctx` to the caller's context, so methods
|
||||
* read the session tag with scopeOf (same mechanism as the host tool
|
||||
* registry). Mutable state lives in plain objects reached by one property
|
||||
* read — field assignment through the tracker's shadow proxy is off-limits,
|
||||
* as are `#` hard-private fields.
|
||||
* read the session tag with `scopeOf`. Mutable state must remain reachable
|
||||
* through one property read; assignment through the tracker proxy and `#`
|
||||
* private fields bypass that rebinding.
|
||||
*/
|
||||
import { Service } from 'cordis'
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
// InputBar: the one composer input (figma Input_Bottom). The same component
|
||||
// serves the empty state (variant='hero': centered launch card) and the
|
||||
// resident composer (variant='composer') — the empty→content transition is a
|
||||
// position move of this component, never a swap (layout ruling). Running
|
||||
// LOCKS the input: textarea disabled with the draft visible, stop is the only
|
||||
// action; the turn ending re-enables and refocuses.
|
||||
//
|
||||
// Bottom chrome (attach / Plan / Read-only / model) is visual-only for now —
|
||||
// local native <select> state, no host wiring.
|
||||
// Shared empty-state and resident composer. Running retains the draft, locks
|
||||
// the textarea, and exposes only Stop. Bottom controls are local visual state.
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react'
|
||||
@@ -25,10 +18,8 @@ export interface InputBarProps {
|
||||
running: boolean
|
||||
disabled: boolean
|
||||
error: InputBarError | null
|
||||
/** Hero = empty-state centered card; composer = resident bottom bar. */
|
||||
variant: 'hero' | 'composer'
|
||||
placeholder?: string
|
||||
/** Optional leading accessory row above the textarea (kept for callers; empty state no longer uses it). */
|
||||
accessory?: ReactNode
|
||||
onDraftChange: (text: string) => void
|
||||
onSend: (mode: 'queue' | 'steer') => void
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
/**
|
||||
* Chat store factory (slot terminal design §4): selection + draft + active
|
||||
* view for one session, shared by the conversation and details registrations
|
||||
* (apply constructs one handle and passes it to both). Session-scope
|
||||
* derivation: both mount slots are scope=session, so the framework creates
|
||||
* one instance per session; the persist key is scope-suffixed by the
|
||||
* framework, aligning with the previous per-session draft persistence.
|
||||
*
|
||||
* Module exports the factory only — a module-level handle would pin identity
|
||||
* in the module cache (a de-facto singleton surviving plugin reloads).
|
||||
* Per-session chat store shared by conversation and details registrations.
|
||||
* The plugin creates its handle at apply time so identity follows the fiber.
|
||||
*/
|
||||
import { defineStore, type EngineStoreHandle } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ChatStoreState, SelectionTarget } from './contract/views.ts'
|
||||
|
||||
/**
|
||||
* Annotation twin of the actions literal below (the export needs a declared
|
||||
* return type); drift fails assignability at the defineStore call.
|
||||
*/
|
||||
/** Declared action shape used to give the exported factory a stable return type. */
|
||||
type ChatActions = {
|
||||
select: (draft: ChatStoreState, target: SelectionTarget | null) => void
|
||||
setDraft: (draft: ChatStoreState, text: string) => void
|
||||
@@ -25,18 +15,11 @@ type ChatActions = {
|
||||
}
|
||||
|
||||
/**
|
||||
* Declare the per-session chat store. `selection` is the details-linkage
|
||||
* channel (conversation writes, details reads); `draft` is the composer text
|
||||
* (persisted so it survives session switches and reloads); `view` is the
|
||||
* active conversation view id (a 'conversation.view' entry id — store seat is
|
||||
* the cross-remount survival channel, null falls back to the first view).
|
||||
* @returns the store handle (spec + identity + factory in one value).
|
||||
* Declares the per-session chat state and write surface.
|
||||
* @returns the store handle.
|
||||
*/
|
||||
export function createChatStore(): EngineStoreHandle<ChatStoreState, ChatActions> {
|
||||
return defineStore({
|
||||
// Anchored to the contract shape: consumers read the store through
|
||||
// PropsStore<ChatStore>'s SnapshotSelectorHook<ChatStoreState>, so init
|
||||
// and the contract cannot drift.
|
||||
init: (): ChatStoreState => ({ selection: null, draft: '', view: null }),
|
||||
persist: 'dsh.conversation.chat',
|
||||
actions: {
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
/**
|
||||
* Conversation plugin, node half. Pure UI plugin: the empty apply exists so
|
||||
* the plugin appears in the host cordis.yml / Loader (load and lifecycle
|
||||
* follow the host; the browser half ships via exports["./client"], discovered
|
||||
* through the package.json dshClient declaration). Contract: api-contracts
|
||||
* v3 sections 0.3 and 7.
|
||||
*/
|
||||
/** Host loader entry for the browser-only conversation plugin. */
|
||||
|
||||
/** Host plugin body — no host-side behavior for the conversation plugin. */
|
||||
/** Provides no host-side behavior. */
|
||||
export function apply(): void {}
|
||||
|
||||
@@ -137,7 +137,6 @@ describe('conversation slot inject surface', () => {
|
||||
expect(injected.views.list().map(v => v.id)).toEqual(['chat'])
|
||||
injected.open(ROOT)
|
||||
expect(b.sessionsFake.open).toHaveBeenCalledWith(ROOT)
|
||||
// loadOlder moved to the chat view entry's face (the ring rider).
|
||||
const chatView = b.chatViewSurface(ROOT)
|
||||
chatView.injected.loadOlder()
|
||||
expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* createChatStore unit account (slot terminal design §4): the declared
|
||||
* actions write set, persist round-trip through the scope-suffixed key, and
|
||||
* factory purity (every create() is an independent instance; the factory
|
||||
* itself holds no singleton state).
|
||||
*/
|
||||
/** Chat-store actions, scoped persistence, and instance isolation. */
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
|
||||
|
||||
@@ -146,8 +146,6 @@ describe('keyed toolview hole through the real machinery', () => {
|
||||
|
||||
it('a duplicate key registration fails loud at load', async () => {
|
||||
const b = await bench([])
|
||||
// The bash sample already holds the 'bash' key (later-wins retired with
|
||||
// the ring — the keyed ledger throws instead).
|
||||
expect(() => b.slots.register(
|
||||
{ name: 'conversation.chat.toolview', key: 'bash' },
|
||||
() => null,
|
||||
|
||||
@@ -69,7 +69,7 @@ const runningCall = (callId: string, name = 'bash'): RunningToolCall => ({
|
||||
callId, name, argsRaw: `{"command":"cmd-${callId}"}`, turn: 2, step: 1, time: 1_000, callView: null,
|
||||
})
|
||||
|
||||
/** Empty sessions-list hook stub (the global standard-kit seat; engines carry no hook since the store migration — bind here). */
|
||||
/** Empty sessions-list hook for the global standard-kit seat. */
|
||||
function emptySessions() {
|
||||
const store = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined } as SessionListState)
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
// @vitest-environment jsdom
|
||||
// Final branch tails for the coverage gate, terminal slot form:
|
||||
// AssistantMarkdown non-final reasoning, StatsLine usage-less node,
|
||||
// DetailsPanel titleless selection. (The old cwd WeakMap-cache account
|
||||
// retired with the mechanism — derivation lives in EmptyState now, covered
|
||||
// by the skeleton specs.)
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render } from '@testing-library/react'
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
/**
|
||||
* Test-local selector-hook binder: the engine carries no hook since the store
|
||||
* migration (runtime is React-free); the renderer binds in production, specs
|
||||
* bind here. Delegates to web-react's bindSnapshotSelector SOURCE (same
|
||||
* with-selector uSES shim as production, so selector-level render economics —
|
||||
* a top-level snapshot swap with an unchanged slice does NOT re-render — hold
|
||||
* in Profiler-count specs). Source-relative import: the package dependency
|
||||
* edge to web-react is gone (store migration §7); tests reach the sibling
|
||||
* package the same way they reach their own src internals.
|
||||
* Test-local selector binding through the production uSES implementation.
|
||||
* Runtime remains React-free, so specs bind observable sources here.
|
||||
*/
|
||||
import { bindSnapshotSelector } from '../../web-react/src/bind.ts'
|
||||
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Selection survival across the store seat (terminal design §4): the chat
|
||||
* store now carries what the per-scope selection account used to — this pins
|
||||
* the same behavior contract in the new mechanism. Drives the REAL
|
||||
* SlotsService store axis with the shared createChatStore handle (the exact
|
||||
* apply.ts shape: one handle, two session-slot registrations): same session's
|
||||
* two slots resolve one instance (conversation writes, details reads);
|
||||
* sessions are isolated; a session's death buries its instance AND its
|
||||
* persisted draft; a list refresh does not touch instance identity.
|
||||
* Exercises selection persistence through the real SlotsService store axis;
|
||||
* component stubs cannot prove per-session identity or disposal.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
@@ -15,8 +9,7 @@ import { SessionsService, SlotsService } from '@deepseek-ai/dsh-client-runtime/c
|
||||
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { createChatStore } from '../src/client/stores.ts'
|
||||
|
||||
// The runtime package's programmable fake lives in its tests; import through
|
||||
// the src path (same pattern the runtime specs use — test-support material).
|
||||
// Use the runtime's programmable fake to drive the real session service.
|
||||
import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts'
|
||||
|
||||
const sid = (s: string): SessionId => s as SessionId
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
/**
|
||||
* Layout plugin, node half. Pure UI plugin: the empty apply exists so the
|
||||
* plugin appears in the host cordis.yml / Loader (load and lifecycle follow
|
||||
* the host; the browser half ships via exports["./client"], discovered
|
||||
* through the package.json dshClient declaration). Contract: api-contracts
|
||||
* v3 sections 0.3 and 5.
|
||||
*/
|
||||
/** Host loader entry for the browser-only layout plugin. */
|
||||
|
||||
/** Host plugin body — no host-side behavior for the layout plugin. */
|
||||
/** Provides no host-side behavior. */
|
||||
export function apply(): void {}
|
||||
|
||||
@@ -42,7 +42,7 @@ class ResizeObserverStub {
|
||||
|
||||
let frameWidth = 1920
|
||||
|
||||
/** Minimal selector hook over an engine instance (the engine carries no hook since the store migration; the renderer binds in production, the spec binds here). */
|
||||
/** Test-local selector hook over a framework-neutral store instance. */
|
||||
function hookOf<T>(inst: { subscribe: (fn: () => void) => () => void; getSnapshot: () => T }) {
|
||||
return <S,>(sel: (s: T) => S): S => sel(useSyncExternalStore(inst.subscribe, inst.getSnapshot))
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
/**
|
||||
* Pure React atoms (zero cordis): StateDot, icons, Button/Pill/Menu/Modal/Input,
|
||||
* markdown family, ConnectionBanner. Everything consumes props plus --dsw-*
|
||||
* token vars only. Contract: api-contracts v3 section 8.
|
||||
* Cordis-free React primitives styled only through `--dsw-*` tokens.
|
||||
*/
|
||||
|
||||
export { StateDot } from './StateDot.tsx'
|
||||
|
||||
@@ -16,9 +16,7 @@ async function bench() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SlotsService).await()
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
// Stand-in for ui-conversation's conversation entry: the composer slot only
|
||||
// exists while a live entry declares it in children (declaration account:
|
||||
// design §2.2).
|
||||
// The composer slot exists only while its declaring entry is live.
|
||||
slots.register(
|
||||
{ name: 'root', children: { 'conversation.composer': { kind: 'chain', scope: 'session' } } } as never,
|
||||
() => null,
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
/**
|
||||
* SidebarRoot (figma 133:7629): logo row + collapse, New Session, WorkSpace
|
||||
* section header with the group-by menu, search, session tree list, Settings
|
||||
* foot. Pure presentational — the session list arrives through the standard
|
||||
* useSessions hook, viewing state (expansion, search) is local component
|
||||
* state, and rows are derived in render via useMemo (slot design section 6:
|
||||
* derived data is a pure function, no materializing store).
|
||||
*
|
||||
* Collapse is a slide + crossfade: the content freezes at its expanded
|
||||
* 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
|
||||
@@ -35,7 +28,7 @@ const EXPAND_SLIDE_MS = 300
|
||||
|
||||
const GROUP_BY_ITEMS = [
|
||||
{ id: 'workspace', label: 'WorkSpace' },
|
||||
// Update/Status grouping has no design yet (figma §3) — visible, disabled.
|
||||
// Only workspace grouping is implemented.
|
||||
{ id: 'update', label: 'Update', disabled: true },
|
||||
{ id: 'status', label: 'Status', disabled: true },
|
||||
]
|
||||
@@ -78,8 +71,7 @@ type SessionTreeProps = Pick<SidebarRootComponentProps, 'useSessions' | 'onOpen'
|
||||
/** The scrolling session tree; unmounting at collapse settle drops the sessions subscription and expansion state. */
|
||||
function SessionTree({ useSessions, onOpen, onCreate, query }: SessionTreeProps) {
|
||||
const list = useSessions((s) => s)
|
||||
// Wave-2 seam: row highlight expects `current` on the sessions list
|
||||
// snapshot (sessions.current lives with the runtime sessions service).
|
||||
// Selection belongs to the sessions snapshot, not layout state.
|
||||
const current = useSessions((s) => s.current)
|
||||
const [expandedProjects, setExpandedProjects] = useState<string[]>([])
|
||||
const [expandedSessions, setExpandedSessions] = useState<string[]>([])
|
||||
|
||||
@@ -1,30 +1,19 @@
|
||||
/**
|
||||
* Sidebar plugin, browser half: SidebarRoot registered into the layout-owned
|
||||
* sidebar slot. Pure consumer — the session list arrives through the
|
||||
* standard useSessions prop, tree rows derive in the component, and the
|
||||
* inject surface is plain cross-service callbacks closed over the plugin's
|
||||
* own ctx (slot design sections 5 and 6); props composition in
|
||||
* contract/slots.ts. Export discipline: packages/client/AGENTS.md.
|
||||
*/
|
||||
/** Registers the sidebar UI into the layout-owned slot. */
|
||||
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SidebarRootInjected } from './contract/slots.ts'
|
||||
import { SidebarRoot } from './SidebarRoot.tsx'
|
||||
|
||||
export type { SidebarRootComponentProps, SidebarRootInjected } from './contract/slots.ts'
|
||||
|
||||
/** Required services (cordis fiber inject — the loader passes the whole export surface as an object plugin). */
|
||||
/** Services required by the sidebar plugin. */
|
||||
export const inject = ['slots', 'layout', 'sessions']
|
||||
|
||||
/**
|
||||
* Client plugin body: register SidebarRoot into the sidebar slot. The inject
|
||||
* factory returns service callbacks only (no hooks, no store lines) — all
|
||||
* data reads ride the framework's standard useSessions delivery.
|
||||
* @param ctx - client root context.
|
||||
/** Registers the sidebar component and its service callbacks.
|
||||
* @param ctx - Client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const injectProps = (): SidebarRootInjected => ({
|
||||
// Selection lives with the runtime sessions service (current rides the
|
||||
// list snapshot); layout keeps only panel geometry.
|
||||
// Selection belongs to the sessions service; layout owns only panel geometry.
|
||||
onOpen: (id) => { ctx.sessions.open(id) },
|
||||
onCreate: (cwd) => {
|
||||
// Top-level New Session / New Workspace: clear selection so AppFrame
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
/**
|
||||
* Pure sidebar tree derivation: session list snapshot -> flat render rows.
|
||||
* Groups sessions by project directory (cwd), builds the per-group session
|
||||
* tree from parentId links, sorts by recency, and applies search filtering
|
||||
* with forced ancestor visibility. Derived data is a pure function (slot
|
||||
* design section 6): the component feeds the useSessions snapshot plus its
|
||||
* local viewing state through useMemo — no materializing store.
|
||||
*/
|
||||
/** Pure derivation of flat sidebar rows from sessions and local view state. */
|
||||
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
|
||||
/** Group key for sessions without a project directory. */
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
/**
|
||||
* Sidebar plugin, node half. Pure UI plugin: the empty apply exists so the
|
||||
* plugin appears in the host cordis.yml / Loader (load and lifecycle follow
|
||||
* the host; the browser half ships via exports["./client"], discovered
|
||||
* through the package.json dshClient declaration). Contract: api-contracts
|
||||
* v3 sections 0.3 and 6.
|
||||
*/
|
||||
/** Host loader entry for the browser-only sidebar plugin. */
|
||||
|
||||
/** Host plugin body — no host-side behavior for the sidebar plugin. */
|
||||
/** Provides no host-side behavior. */
|
||||
export function apply(): void {}
|
||||
|
||||
@@ -36,8 +36,7 @@ async function bench() {
|
||||
ctx.provide('sessions', sessions)
|
||||
ctx.provide('layout', layout)
|
||||
const slots = ctx.get('slots') as SlotsService
|
||||
// Stand-in for ui-layout's root entry: the sidebar slot only exists while
|
||||
// a live entry declares it in children (declaration account: design §2.2).
|
||||
// The sidebar slot exists only while its declaring entry is live.
|
||||
slots.register(
|
||||
{ name: 'root', children: { 'sidebar': { kind: 'single', scope: 'root' } } } as never,
|
||||
() => null,
|
||||
|
||||
@@ -10,8 +10,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { act, useSyncExternalStore } from 'react'
|
||||
// Engine home: runtime/client since the store migration; the engine carries
|
||||
// no hook (runtime is React-free), so the spec binds the selector locally.
|
||||
// Runtime is React-free, so the spec binds its selector locally.
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
|
||||
|
||||
@@ -142,13 +142,9 @@ export interface SessionAreaProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* The framework-wired session area component (slot terminal design §7):
|
||||
* subscribes to the current-session selection internally (design fiat ① —
|
||||
* selection authority lives with runtime sessions) and switches between the
|
||||
* session body and the empty branch. Delivered as a standard seat to every
|
||||
* entry whose children declaration contains a session-scope slot (the
|
||||
* derivation rides {@link PropsRenderSlots}); the value is injected by the
|
||||
* installed renderer — business code never imports it.
|
||||
* Framework-wired session area component. It subscribes to runtime-owned
|
||||
* session selection and is injected into entries that declare session-scoped
|
||||
* children; business code does not import it directly.
|
||||
*/
|
||||
export type SessionProviderComponent = (props: SessionAreaProps) => ReactNode
|
||||
|
||||
@@ -352,8 +348,7 @@ export class SlotCore {
|
||||
|
||||
/**
|
||||
* Contribute a component to a declared slot and (optionally) declare child
|
||||
* slots, a store seat, and the registrant's business face — the single
|
||||
* composition API (the separate define API is retired).
|
||||
* slots, a store seat, and the registrant's business face.
|
||||
*
|
||||
* Load-time validation (misconfiguration fails loud; the render hot path
|
||||
* re-checks nothing): registering into an undeclared slot throws; declaring
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
/**
|
||||
* Renderer install seam (slot terminal design §8): the SlotRenderer interface
|
||||
* web-react's machinery implements, the host surface the runtime SlotsService
|
||||
* presents to the installed renderer, and the render-path authorization
|
||||
* errors. Pure types plus two error classes — this package stays React-free
|
||||
* at runtime (React types only).
|
||||
*/
|
||||
/** React-free contracts between the slot host and an installed renderer. */
|
||||
import type { ReactNode } from 'react'
|
||||
import type { SlotEntryDef, SlotSpec, StoredEntry } from './index.ts'
|
||||
|
||||
@@ -22,7 +16,6 @@ export interface HostObservable<T> {
|
||||
* typing lands at the component seam via {@link PropsStore}.
|
||||
*/
|
||||
export interface StoreInstanceLike {
|
||||
/** Current state snapshot (uSES getSnapshot side). */
|
||||
getSnapshot(): unknown
|
||||
/**
|
||||
* Subscribe to state changes (uSES subscribe side).
|
||||
@@ -30,7 +23,6 @@ export interface StoreInstanceLike {
|
||||
* @returns unsubscribe.
|
||||
*/
|
||||
subscribe(fn: () => void): () => void
|
||||
/** Baked write callbacks (delivered to components as `actions`). */
|
||||
readonly actions: Record<string, (...params: never[]) => void>
|
||||
}
|
||||
|
||||
@@ -97,7 +89,7 @@ export interface SlotRendererHost {
|
||||
sessions: {
|
||||
/** Session list source backing the useSessions standard hook. */
|
||||
list: HostObservable<unknown>
|
||||
/** Current-session source backing SessionProvider's self-wiring (design fiat ①). */
|
||||
/** Current-session source used by SessionProvider. */
|
||||
current: HostObservable<string | undefined>
|
||||
/**
|
||||
* Resolve the session standard kit.
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
/**
|
||||
* Store-seat type family (slot terminal design §4): a registrant declares its
|
||||
* shared/exclusive business store as data — schema (`init`), optional
|
||||
* persistence key, and the complete write set (`actions`) — and the framework
|
||||
* owns instance lifecycle (scope derives from the mounting entry's slot).
|
||||
* ui-slots ships the contract types only; the engine-backed `defineStore`
|
||||
* value lives in web-react (the snapshot-store engine's home) and must
|
||||
* satisfy {@link DefineStore}.
|
||||
*/
|
||||
/** Framework-neutral store contracts for slot registrations and the runtime engine. */
|
||||
|
||||
/**
|
||||
* Typed selector hook over a snapshot source. Canonical shape for the whole
|
||||
@@ -41,11 +33,8 @@ export type BakedActions<T, A extends ActionsDecl<T>> = {
|
||||
* and the actions write set.
|
||||
*/
|
||||
export interface StoreSpec<T, A extends ActionsDecl<T>> {
|
||||
/** Initial-state factory; called once per framework-created instance. */
|
||||
init: () => T
|
||||
/** Opt-in persistence key (storage mechanics belong to the engine). */
|
||||
persist?: string
|
||||
/** Complete write set: pure draft transforms. */
|
||||
actions: A
|
||||
}
|
||||
|
||||
@@ -58,9 +47,7 @@ export interface StoreSpec<T, A extends ActionsDecl<T>> {
|
||||
* call create() themselves — instance lifecycle is the framework's.
|
||||
*/
|
||||
export interface StoreInstance<T, A extends ActionsDecl<T>> {
|
||||
/** Baked write callbacks (delivered to components as `actions`). */
|
||||
readonly actions: BakedActions<T, A>
|
||||
/** Current state snapshot (uSES getSnapshot side; test assertions). */
|
||||
getSnapshot(): T
|
||||
/**
|
||||
* Subscribe to state changes (uSES subscribe side).
|
||||
@@ -84,7 +71,6 @@ export interface StoreInstance<T, A extends ActionsDecl<T>> {
|
||||
* identity is a disguised singleton across plugin reloads.
|
||||
*/
|
||||
export interface StoreHandle<T, A extends ActionsDecl<T>> {
|
||||
/** The inert declaration this handle was defined from. */
|
||||
readonly spec: StoreSpec<T, A>
|
||||
/**
|
||||
* Create a live engine instance (framework machinery and tests only).
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
/**
|
||||
* Theme plugin, browser half: ThemeService over the --dsw-* token base
|
||||
* stylesheets in src/styles/ (the sole token source; components must not
|
||||
* hardcode colors). apply(id) toggles body[data-ds-dark-theme] — theming is
|
||||
* CSS cascade, zero React renders. Contract: api-contracts v3 section 8.
|
||||
* The base stylesheets ship separately (the web shell imports them as base
|
||||
* CSS); this plugin only owns the registry and the body-attribute switch.
|
||||
* Browser theme registry over the `--dsw-*` token stylesheets. Theme changes
|
||||
* update CSS variables and `body[data-ds-dark-theme]` without React renders.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
/**
|
||||
* Theme plugin, node half. Pure UI plugin: the empty apply exists so the
|
||||
* plugin appears in the host cordis.yml / Loader (load and lifecycle follow
|
||||
* the host; the browser half ships via exports["./client"], discovered
|
||||
* through the package.json dshClient declaration). ThemeService and its
|
||||
* types live in the client half; consumers import the /client subpath.
|
||||
* Contract: api-contracts v3 section 8.
|
||||
*/
|
||||
/** Host loader entry for the browser implementation exported from `./client`. */
|
||||
|
||||
/** Host plugin body — no host-side behavior for the theme plugin. */
|
||||
export function apply(): void {}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
/**
|
||||
* Trajectory/Waterfall plugin, browser half: contributes the two placeholder
|
||||
* views into the conversation view ring (the 'conversation.view' list slot
|
||||
* declared by ui-conversation). Pure consumer — no ctx service, no Context
|
||||
* declaration merge; the minimal-plugin exemplar. Contract: api-contracts v3
|
||||
* section 8.
|
||||
* Browser trajectory plugin contributing two entries to the conversation
|
||||
* view slot without defining a service.
|
||||
*/
|
||||
import type { Context } from 'cordis'
|
||||
// Type-only: the 'conversation.view' SlotMap row (declared by the slot's
|
||||
@@ -24,8 +21,7 @@ export const inject = ['slots', 'conversation']
|
||||
/**
|
||||
* Client plugin body: register the trajectory and waterfall view tabs. The
|
||||
* registrations ride the slot service's effect wrapper (plugin unload
|
||||
* removes both tabs). Trajectory owns its turn list in-body; Waterfall keeps
|
||||
* the span stats header inside its body (chrome attachment retired).
|
||||
* removes both tabs).
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
/**
|
||||
* Trajectory plugin, node half. Pure UI plugin: the empty apply exists so
|
||||
* the plugin appears in the host cordis.yml / Loader (load and lifecycle
|
||||
* follow the host; the browser half ships via exports["./client"], discovered
|
||||
* through the package.json dshClient declaration). Contract: api-contracts
|
||||
* v3 sections 0.3 and 8.
|
||||
*/
|
||||
/** Host loader entry for the browser-only trajectory plugin. */
|
||||
|
||||
/** Host plugin body — no host-side behavior for the trajectory plugin. */
|
||||
/** Provides no host-side behavior. */
|
||||
export function apply(): void {}
|
||||
|
||||
@@ -59,7 +59,7 @@ function fakeSession(nodes: ConversationSnapshot['nodes']) {
|
||||
return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession<ConversationSnapshot> }
|
||||
}
|
||||
|
||||
/** Empty sessions-list hook stub (breadcrumbs fall back to the raw id; engines carry no hook since the store migration — bind here). */
|
||||
/** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */
|
||||
function emptySessions() {
|
||||
const store = createSnapshotStore<SessionListState>(
|
||||
{ ids: [], byId: {}, current: undefined } as SessionListState)
|
||||
@@ -173,7 +173,6 @@ describe('tab switching in ConversationRoot', () => {
|
||||
expect(screen.getAllByRole('tab').map((t) => t.textContent)).toEqual(['Chat', 'Trajectory', 'Waterfall'])
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
|
||||
// Trajectory no longer mounts the span stats bar; the turn-list chrome owns the body.
|
||||
expect(screen.queryByText(/turns ·/)).toBeNull()
|
||||
expect(screen.getByText('Turn 1')).toBeTruthy()
|
||||
expect(screen.getByText('Turn 2')).toBeTruthy()
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
/**
|
||||
* Shell-side React glue (slot terminal design §8): createSlotRenderer (the
|
||||
* install-seam implementation), SessionProvider (framework-wired render
|
||||
* prop, also delivered as a standard seat to session-area entries),
|
||||
* bindSnapshotSelector (the one hook constructor), and useInvoke. The
|
||||
* snapshot-store engine and defineStore live in runtime (store relocation);
|
||||
* contract types are ui-slots authority — this face re-exports only what its
|
||||
* own values traffic in. React contexts stay in-package: business components
|
||||
* see none.
|
||||
*/
|
||||
/** React bindings for the framework-neutral slot and snapshot contracts. */
|
||||
import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
export { bindSnapshotSelector } from './bind.ts'
|
||||
@@ -20,7 +11,6 @@ export { bindSnapshotSelector } from './bind.ts'
|
||||
*/
|
||||
export type UseSession<Snap extends object = object> = SnapshotSelectorHook<Snap>
|
||||
|
||||
// -- renderer: the install-seam implementation; contract lives in ui-slots --
|
||||
export type {
|
||||
ChainRenderOpts, HostObservable, RenderOpts, SessionCell, SnapshotSelectorHook,
|
||||
SlotRenderer, SlotRendererHost, StoreInstanceLike,
|
||||
@@ -28,7 +18,6 @@ export type {
|
||||
export { SlotOwnershipError, StaleAuthorizationError } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
export { createSlotRenderer } from './scoped-slots.tsx'
|
||||
|
||||
// -- session area: the framework-wired provider; binding contexts stay internal --
|
||||
export { SessionProvider, SlotAssemblyError, type SessionProviderProps } from './session-provider.tsx'
|
||||
|
||||
export { useInvoke } from './use-invoke.ts'
|
||||
|
||||
@@ -1,19 +1,6 @@
|
||||
/**
|
||||
* createSlotRenderer(): the outlet machinery behind the runtime install seam
|
||||
* (slot terminal design §8). renderRoot mounts the host channel and renders
|
||||
* the built-in 'root' key; every deeper slot renders through a per-entry
|
||||
* renderSlot binding synthesized from the entry's children declaration.
|
||||
* Standard-kit synthesis per entry: the global useSessions hook, the session
|
||||
* pair (useSession + sessionId) under SessionProvider, the store pair
|
||||
* (useStore + actions) for store-declaring entries, the renderSlot binding
|
||||
* (entry-identity bound, stale-checked) for children-declaring entries, and
|
||||
* the renderSlotChain binding for entries declaring a chain-kind child
|
||||
* (selector-routed: first non-null select elects and its value joins the
|
||||
* props as `matched`; all-null falls to the owner fallback).
|
||||
* Inject factories run inside the entry component bodies ON PURPOSE
|
||||
* — the per-entry error boundary contains a throwing factory to its own
|
||||
* entry; parameters follow the declaration (sessionId for session slots,
|
||||
* baked actions when a store is declared).
|
||||
* React renderer for declarative slots. Per-entry bindings enforce child
|
||||
* authorization, and entry boundaries contain registrant failures.
|
||||
*/
|
||||
import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
|
||||
import {
|
||||
@@ -27,10 +14,8 @@ import {
|
||||
|
||||
type InjectedProps = Record<string, unknown>
|
||||
|
||||
/** Owner-facing renderSlot binding shape (typed narrowing lands on the wave-1 props seam). */
|
||||
type RenderSlotBinding = (key: string, owner: object, opts?: RenderOpts) => ReactNode
|
||||
|
||||
/** Owner-facing renderSlotChain binding shape (typed narrowing lands on the props seam). */
|
||||
type RenderSlotChainBinding = (key: string, owner: object, opts?: ChainRenderOpts) => ReactNode
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
/**
|
||||
* SessionProvider (framework-wired render prop, slot terminal design §7) plus
|
||||
* the two internal channels the render machinery shares: the renderer host
|
||||
* context (written once by createSlotRenderer's root) and the per-session
|
||||
* binding context (written here, read by session-scope outlets). Both
|
||||
* contexts are in-package machinery — they are NOT exported from the package
|
||||
* index; business components see zero React contexts.
|
||||
*/
|
||||
/** Internal React bindings for the renderer host and active session cell. */
|
||||
import { createContext, useContext, type ReactNode } from 'react'
|
||||
import type {
|
||||
HostObservable, SessionCell, SlotRendererHost, SnapshotSelectorHook,
|
||||
@@ -20,7 +13,7 @@ import { bindSnapshotSelector } from './bind.ts'
|
||||
*/
|
||||
export class SlotAssemblyError extends Error {}
|
||||
|
||||
/** Renderer host channel: written by createSlotRenderer's root element (in-package machinery only). */
|
||||
/** In-package renderer host context. */
|
||||
export const HostContext = createContext<SlotRendererHost | null>(null)
|
||||
|
||||
/**
|
||||
@@ -34,7 +27,6 @@ export function useHost(): SlotRendererHost {
|
||||
return host
|
||||
}
|
||||
|
||||
/** Per-session binding channel for the subtree under SessionProvider (in-package machinery only). */
|
||||
const BindingContext = createContext<SessionCell | null>(null)
|
||||
|
||||
/**
|
||||
@@ -75,11 +67,10 @@ export interface SessionProviderProps {
|
||||
|
||||
/**
|
||||
* Framework-wired session area: subscribes to the host's current-session
|
||||
* source (design fiat ① — selection authority lives with runtime sessions),
|
||||
* resolves the session cell, and remounts the body under key={sessionId} so
|
||||
* a session switch rebuilds the whole session subtree. Ids speak plain
|
||||
* string at this dependency-inverted layer; branding lands on the component
|
||||
* props seam (PropsRuntime).
|
||||
* source, resolves the session cell, and remounts the body under
|
||||
* `key={sessionId}` so a session switch rebuilds the session subtree. This
|
||||
* dependency-inverted layer uses plain string ids; `PropsRuntime` applies the
|
||||
* branded type at the component boundary.
|
||||
*/
|
||||
export function SessionProvider({ empty, children }: SessionProviderProps) {
|
||||
const host = useHost()
|
||||
|
||||
@@ -5,10 +5,8 @@ import { act, render } from '@testing-library/react'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import type { HostObservable as ObservableSnapshot, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
|
||||
// Local one-level equality: the engine's shallowEqual moved to runtime with
|
||||
// the store relocation, and web-react tests must not import runtime (the
|
||||
// dependency direction is runtime → web-react). The eq PARAMETER contract is
|
||||
// what this suite asserts, not any specific equality implementation.
|
||||
// Keep equality local: this suite asserts the eq parameter contract without
|
||||
// adding a reverse dependency from web-react to runtime.
|
||||
const shallowEqual = (a: Record<string, unknown>, b: Record<string, unknown>): boolean =>
|
||||
Object.keys(a).length === Object.keys(b).length && Object.keys(a).every((k) => Object.is(a[k], b[k]))
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Stale renderSlot bindings (slot terminal design §9): a binding dies with
|
||||
* its entry — a retained closure invoked after the entry's disposal throws
|
||||
* StaleAuthorizationError off the ledger check, and an HMR-style reload (new
|
||||
* entry, same key) mints a NEW binding rather than reviving the old one.
|
||||
* A retained render binding dies with its entry. Re-registering the same key
|
||||
* creates a new binding rather than reviving the stale closure.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { act, render } from '@testing-library/react'
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
/**
|
||||
* App-shell assembly plugin (design §3.4): the shell's ONLY composition
|
||||
* responsibility, packaged as a normal static-arrival entry so the host graph
|
||||
* stays the single composition authority. It rides the same entry lifecycle
|
||||
* as every other plugin — the fiber waits on slots/sessions/layout, so by the
|
||||
* time apply runs the layout entry is mounted and its export surface is
|
||||
* readable from the governance side (module loadCache, design §2.6).
|
||||
*
|
||||
* The pseudo package id exists only in the host graph and the shell's static
|
||||
* registry; there is no npm package behind it.
|
||||
* App-shell assembly plugin. Its pseudo package id exists only in the host
|
||||
* graph and shell registry; there is no npm package behind it.
|
||||
*/
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Context } from 'cordis'
|
||||
@@ -33,13 +26,11 @@ declare module 'cordis' {
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'app-shell'
|
||||
|
||||
/** Required services: the product services the assembly closes over (layout registers the 'root' slot entry). */
|
||||
/** Services required before shell assembly. */
|
||||
export const inject = ['slots', 'sessions', 'layout']
|
||||
|
||||
/**
|
||||
* Plugin body: install the React renderer into the slot system and provide
|
||||
* the renderApp face (one ctx-level renderSlot('root') call).
|
||||
* @param ctx - plugin context (inject set active).
|
||||
/** Installs the React renderer and exposes the assembled application.
|
||||
* @param ctx - Plugin context.
|
||||
*/
|
||||
export function apply(ctx: Context): void {
|
||||
// The renderer install is shell territory (web-react is shell-bundled),
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
/**
|
||||
* Platform singletons the shell shares into the module table.
|
||||
* Single source of truth (design §3.3, contract C1): seed keys = tsdown
|
||||
* client externals = the shared surface. The three projections import this
|
||||
* module — the seed table ({@link ../seed.ts}), the tsdown client preset's
|
||||
* external judgement (packages/client/tsdown.client.ts), and the vite alias
|
||||
* check — so the list cannot drift between them.
|
||||
* Shared browser platform modules. Seeding, bundling externals, and Vite
|
||||
* aliases consume this list so their module identities cannot drift.
|
||||
* @module @deepseek-ai/dsh-client-web/src/platform
|
||||
*/
|
||||
|
||||
|
||||
@@ -188,15 +188,12 @@ describe('Agent', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// Simulate an OPEN turn in the log while the agent is idle (status is not a
|
||||
// reliable open-turn signal). inject must append into that open turn, NOT
|
||||
// wrap a new one.
|
||||
// Status is idle while the log has an open turn; enclosure must follow the log.
|
||||
agent.session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
agent.inject([{ type: 'text', text: 'mid' }], { source: { kind: 'plugin', plugin: 'p' } })
|
||||
expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(1)
|
||||
expect(agent.session.events.at(-1)!.type).toBe('user/message')
|
||||
|
||||
// Close the turn; now inject must wrap its own one-shot injection turn.
|
||||
agent.session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
agent.inject([{ type: 'text', text: 'after' }], { source: { kind: 'plugin', plugin: 'p' } })
|
||||
const starts = agent.session.events.filter(e => e.type === 'turn/start')
|
||||
@@ -342,11 +339,9 @@ describe('Agent', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// steer while idle delegates to send
|
||||
agent.steer([{ type: 'text', text: 'steer idle' }], { source: { kind: 'plugin', plugin: 'test' } })
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// The message was recorded as a user-level message (send path)
|
||||
expect(agent.session.events.some(e => e.type === 'user/message')).toBe(true)
|
||||
expect(adapter.requests).toHaveLength(1)
|
||||
})
|
||||
@@ -369,12 +364,10 @@ describe('Agent', () => {
|
||||
prepared.markPublished()
|
||||
const dispose = prepared.startDriver()
|
||||
|
||||
// First dispose
|
||||
const firstDisposal = dispose()
|
||||
expect(agent.status).toBe('disposed')
|
||||
await firstDisposal
|
||||
|
||||
// Second dispose — idempotent, no throw
|
||||
await expect(dispose()).resolves.toBeUndefined()
|
||||
expect(agent.status).toBe('disposed')
|
||||
})
|
||||
|
||||
@@ -142,8 +142,6 @@ describe('Agent.cancel()', () => {
|
||||
|
||||
agent.queue([{ type: 'text', text: 'quiet' }])
|
||||
const idle = agent.whenIdle()
|
||||
// Cancel reaches quiescence with no status transition and no waking send;
|
||||
// whenIdle must still resolve (previously it hung until the next send).
|
||||
agent.cancel({ kind: 'user' })
|
||||
await idle
|
||||
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
|
||||
|
||||
@@ -1096,7 +1096,6 @@ describe('step boundary publication order', () => {
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a-step-order'), { provider: 'mock', model: 'mock' })
|
||||
|
||||
// Append commits before observers run.
|
||||
const observed: { turn: number; step: number; lastEventType: string | undefined; sawStepStart: boolean }[] = []
|
||||
ctx.on('session/event', (subject, event) => {
|
||||
if (subject !== agent.session || event.type !== 'step/start') return
|
||||
@@ -1704,7 +1703,6 @@ describe('disposal and cancellation during pre-step assembly', () => {
|
||||
send(agent, 'go')
|
||||
await new Promise(r => setTimeout(r, 50))
|
||||
|
||||
// Start disposal, then release the block, then await disposal.
|
||||
const disposalDone = fiber.dispose()
|
||||
releasePreStep()
|
||||
await disposalDone
|
||||
|
||||
@@ -283,20 +283,17 @@ describe('SurfaceManager', () => {
|
||||
|
||||
it('empty surface yields empty nodes', () => {
|
||||
const s = new Session(SessionId('empty'))
|
||||
// Only turn boundaries, no surface nodes.
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
s.append('step/end', { turn: 1, step: 1 })
|
||||
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
expect(s.surface.nodes.length).toBe(0)
|
||||
// deriveMessages returns empty array
|
||||
expect(s.deriveMessages()).toEqual([])
|
||||
})
|
||||
|
||||
it('picks up new events incrementally (delta processing)', () => {
|
||||
const s = surfaceSession()
|
||||
expect(s.surface.nodes.length).toBe(2)
|
||||
// Append another surface node
|
||||
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
|
||||
expect(s.surface.nodes.length).toBe(3)
|
||||
expect(s.surface.nodes[2]!).toBe(4) // seq 4: after turn/end at seq 3
|
||||
@@ -306,7 +303,6 @@ describe('SurfaceManager', () => {
|
||||
const original = surfaceSession()
|
||||
original.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
|
||||
const replayed = new Session(SessionId('replay'), [...original.events])
|
||||
// Surface rebuilds from the seeded log's markers.
|
||||
expect(replayed.surface.nodes).toEqual([1, 2, 4])
|
||||
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
|
||||
})
|
||||
|
||||
@@ -1893,7 +1893,6 @@ describe('ToolRegistry', () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
|
||||
// Register a second tool and call its returned disposer directly
|
||||
const dispose = ctx.tools.register({ ...echoTool, name: 'disposable' })
|
||||
expect(ctx.tools.schemas().map(t => t.name)).toEqual(['echo', 'disposable'])
|
||||
|
||||
@@ -2055,9 +2054,7 @@ describe('defineTool / schema DSL', () => {
|
||||
parameters: { a: { type: 'string' as const, required: true as const }, b: { type: 'number' as const } },
|
||||
output: { schema: { type: 'string' }, render: () => [] },
|
||||
async execute(args) {
|
||||
// Verify types at runtime via typeof
|
||||
expect(typeof args.a).toBe('string')
|
||||
// args.b should be undefined when not provided
|
||||
void args
|
||||
return args.a
|
||||
},
|
||||
|
||||
@@ -472,8 +472,8 @@ export async function writeFileAtomic(
|
||||
try {
|
||||
await replaceFile(absolutePath, tempPath)
|
||||
} catch (error: unknown) {
|
||||
// Preserve the old behavior when an external actor removes the observed target during
|
||||
// staging: the temp already carries that target's protected DACL, so rename recreates it.
|
||||
// If the observed target disappears during staging, the protected DACL
|
||||
// already copied to the temp remains authoritative for recreation.
|
||||
if (!isENOENT(error)) throw error
|
||||
await rename(tempPath, absolutePath)
|
||||
}
|
||||
|
||||
@@ -6,14 +6,7 @@ import type { Context } from 'cordis'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { fsHarness, waitForIdle } from './harness.ts'
|
||||
|
||||
/**
|
||||
* With-key smoke for the filesystem tools: a REAL model drives the REAL
|
||||
* read/write/edit tools (over the real local backend + policy gate), and we
|
||||
* verify the WORLD — the file on disk — not the agent's self-report. This is the
|
||||
* "green units, broken product" guard: mocks prove the plumbing, only a real
|
||||
* model proves the tools actually work end-to-end. Key-gated (self-skips without
|
||||
* DEEPSEEK_API_KEY).
|
||||
*/
|
||||
/** Key-gated smoke for a real model driving the local read/write/edit tools. */
|
||||
|
||||
let ctx: Context | undefined
|
||||
let workdir: string | undefined
|
||||
@@ -42,7 +35,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () =>
|
||||
+ 'Tell me when done.' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
// Verify the WORLD: the edit landed on disk.
|
||||
// Assert the filesystem effect independently of the model response.
|
||||
const content = await readFile(join(workdir, 'note.txt'), 'utf8')
|
||||
expect(content).toContain('status: final')
|
||||
expect(content).not.toContain('draft')
|
||||
|
||||
@@ -321,10 +321,9 @@ describe('fold onto the downstream decision', () => {
|
||||
|
||||
const found = reminders(agent)
|
||||
expect(found).toHaveLength(3)
|
||||
// Call 1: below threshold — the downstream context passes through untouched.
|
||||
// Only the repeated call adds guard context; downstream provenance survives.
|
||||
expect(found[0]!.text).toBe('downstream-ctx')
|
||||
expect(found[0]!.source).toEqual({ kind: 'plugin', plugin: 'test' })
|
||||
// Call 2: reminder and downstream context retain separate provenance.
|
||||
expect(found[1]!.text).toContain('repeating the exact same tool call')
|
||||
expect(found[1]!.source).toEqual(GUARD_SOURCE)
|
||||
expect(found[2]).toEqual({ text: 'downstream-ctx', source: { kind: 'plugin', plugin: 'test' } })
|
||||
|
||||
@@ -150,7 +150,6 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
const ctx = await harness(path, new MockAdapter([]))
|
||||
let ran = false
|
||||
ctx.tools.register(defineContentToolFixture({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
|
||||
// Call execute() directly with NO agent — the bridge's no-agent/no-turn path.
|
||||
const { CallId } = await import('@deepseek-ai/dsh-llm')
|
||||
const result = await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'echo', arguments: {} })
|
||||
expect(ran).toBe(false)
|
||||
@@ -159,7 +158,6 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
|
||||
it('a long stderr is truncated in the hook/result summary', async () => {
|
||||
const d = dir()
|
||||
// Emit >500 chars of stderr then exit 2.
|
||||
const s = sh(d, 'long.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n')
|
||||
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
|
||||
@@ -236,7 +234,6 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
const s = sh(d, 'sa.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"child guidance"}}\'\n')
|
||||
const path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] })
|
||||
const ctx = await harness(path, new MockAdapter([]))
|
||||
// Register a fake child agent under the id the event carries.
|
||||
const injected: string[] = []
|
||||
const child = { id: SessionId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { id: SessionId('child-x'), header: { id: 'child-x' } } } as unknown as Parameters<typeof ctx.agents.register>[0]
|
||||
ctx.agents.register(child)
|
||||
@@ -693,7 +690,6 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') })
|
||||
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
|
||||
|
||||
// Register a live child on its own session cwd; emit subagent/end with its id.
|
||||
const { SessionId } = await import('@deepseek-ai/dsh-session')
|
||||
const childHandle = await ctx.agents.create({ sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { provider: 'mock', model: 'mock' } })
|
||||
ctx.emit(subagentCarrier(ctx), 'subagent/end', { runId: SubagentRunId('run-stop'), provider: 'inproc', id: childHandle.agent.id, local: true, stopReason: 'completed' })
|
||||
@@ -735,7 +731,6 @@ export function defineCoverageCases(group: CoverageGroup): void {
|
||||
const adapter = new MockAdapter([textResponse('ok')])
|
||||
const ctx = await harness(path, adapter)
|
||||
const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' })
|
||||
// Send immediately — do NOT wait for the session-start inject.
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
await waitForIdle(ctx, agent)
|
||||
expect(adapter.requests).toHaveLength(1) // the turn ran regardless of hook timing
|
||||
|
||||
@@ -49,7 +49,7 @@ Every request carries the shared attribution header from dsh-llm's `attributionH
|
||||
|
||||
## Errors
|
||||
|
||||
Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` (a response whose provider details identify exhausted quota, balance, or credits), `RATE_LIMIT` (other 429s), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s), `SERVER` (5xx), `HTTP_<status>` otherwise. Its serializable `failure` retains the HTTP status plus a valid positive `Retry-After` seconds/date delay and `x-request-id` / `x-deepseek-request-id` when present. A pre-response transport failure (DNS, refused connection, TLS, proxy) throws `TRANSPORT` naming the configured endpoint and chaining the original rejection as `cause`; caller aborts throw `ABORTED`, and the loop's cancellation signal remains authoritative. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', failure}` chunks.
|
||||
Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA` (a response whose provider details identify exhausted quota, balance, or credits), `RATE_LIMIT` (other 429s), `CONTEXT_WINDOW_EXCEEDED` (a 400 whose provider code, type, or message identifies context overflow), `INVALID_REQUEST` (other 400s), `SERVER` (5xx), `HTTP_<status>` otherwise. Its serializable `failure` retains the HTTP status plus a valid positive `Retry-After` seconds/date delay and `x-request-id` / `x-deepseek-request-id` when present. A pre-response transport failure (DNS, refused connection, TLS, proxy) throws `TRANSPORT` naming the configured endpoint and chaining the original rejection as `cause`; caller aborts throw `ABORTED`, and the loop's cancellation signal remains authoritative. Protocol violations throw `STREAM_CLOSED` (no `[DONE]`) or `MALFORMED_RESPONSE` (bad JSON payload). Unknown wire `finish_reason`s (e.g. `content_filter`, `insufficient_system_resource`) become `finish {kind: 'error', failure}` chunks, and a completed stream whose `stop` (or absent) finish opened no content blocks becomes a `finish {kind: 'error'}` with code `EMPTY_RESPONSE` (retried by default policy).
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @module dsh-llm-deepseek/translate
|
||||
*/
|
||||
|
||||
import { CallId, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, EMPTY_RESPONSE_CODE, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, FinishReason, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||
import { DONE } from './sse.ts'
|
||||
import type { WireChunk, WireUsage } from './types.ts'
|
||||
@@ -80,6 +80,8 @@ function closeBlock(block: OpenBlock): ContentBlock {
|
||||
* Malformed JSON payloads abort the stream with `MALFORMED_RESPONSE`.
|
||||
* @param payloads - SSE data payloads from {@link parseSse}, `[DONE]`-terminated.
|
||||
* @returns deltas as they arrive; `block-end`s, `usage`, and `finish` are all deferred to the `[DONE]` sentinel.
|
||||
* A `stop` (or absent) finish with no opened blocks is a degenerate provider completion and maps to an
|
||||
* `EMPTY_RESPONSE` error finish instead of a successful empty message.
|
||||
*/
|
||||
export async function* translate(payloads: AsyncIterable<string>): AsyncGenerator<StreamChunk> {
|
||||
let nextIndex = 0
|
||||
@@ -102,7 +104,16 @@ export async function* translate(payloads: AsyncIterable<string>): AsyncGenerato
|
||||
yield { type: 'block-end', index: block.index, block: closeBlock(block) }
|
||||
}
|
||||
if (pendingUsage) yield { type: 'usage', usage: pendingUsage }
|
||||
yield { type: 'finish', reason: pendingFinish ?? { kind: 'stop' } }
|
||||
const reason = pendingFinish ?? { kind: 'stop' as const }
|
||||
yield {
|
||||
type: 'finish',
|
||||
reason: reason.kind === 'stop' && order.length === 0
|
||||
? {
|
||||
kind: 'error',
|
||||
failure: { message: 'model returned a completed response with no content', code: EMPTY_RESPONSE_CODE },
|
||||
}
|
||||
: reason,
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { BlockAssembler, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import { BlockAssembler, EMPTY_RESPONSE_CODE, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { DONE } from '../src/sse.ts'
|
||||
import { mapFinishReason, mapUsage, translate } from '../src/translate.ts'
|
||||
@@ -203,7 +203,50 @@ describe('translate: finish and usage handling', () => {
|
||||
|
||||
it('handles chunks with no choices at all', async () => {
|
||||
const chunks = await collect(translate(feed({}, DONE)))
|
||||
expect(chunks).toEqual([{ type: 'finish', reason: { kind: 'stop' } }])
|
||||
expect(chunks).toEqual([{
|
||||
type: 'finish',
|
||||
reason: {
|
||||
kind: 'error',
|
||||
failure: { message: 'model returned a completed response with no content', code: EMPTY_RESPONSE_CODE },
|
||||
},
|
||||
}])
|
||||
})
|
||||
|
||||
it('classifies an explicit stop with no opened blocks as EMPTY_RESPONSE, after usage', async () => {
|
||||
const chunks = await collect(translate(feed(
|
||||
firstChunk,
|
||||
{ choices: [{ delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 7, completion_tokens: 0 } },
|
||||
DONE,
|
||||
)))
|
||||
expect(chunks).toEqual([
|
||||
{ type: 'usage', usage: { inputTokens: 7, outputTokens: 0 } },
|
||||
{
|
||||
type: 'finish',
|
||||
reason: {
|
||||
kind: 'error',
|
||||
failure: { message: 'model returned a completed response with no content', code: EMPTY_RESPONSE_CODE },
|
||||
},
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps a reasoning-only stream a successful stop (any opened block counts)', async () => {
|
||||
const chunks = await collect(translate(feed(
|
||||
firstChunk,
|
||||
{ choices: [{ delta: { content: null, reasoning_content: 'mull' } }] },
|
||||
{ choices: [{ delta: {}, finish_reason: 'stop' }] },
|
||||
DONE,
|
||||
)))
|
||||
expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'stop' } })
|
||||
})
|
||||
|
||||
it('leaves non-stop finishes unclassified even with no opened blocks', async () => {
|
||||
const chunks = await collect(translate(feed(
|
||||
firstChunk,
|
||||
{ choices: [{ delta: {}, finish_reason: 'length' }] },
|
||||
DONE,
|
||||
)))
|
||||
expect(chunks.at(-1)).toEqual({ type: 'finish', reason: { kind: 'max-tokens' } })
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ If a listener rewrites assembled assistant content, the loop drops replay state
|
||||
## Vocabulary differences
|
||||
|
||||
- pi-ai tool-call arguments are parsed objects; the harness stores raw JSON strings. The adapter parses input and re-stringifies output.
|
||||
- pi-ai reports failures as in-stream error events; these map to `finish {kind:'error'|'aborted', failure}` chunks. Provider-specific error text distinguishes terminal `QUOTA` from transient `RATE_LIMIT`, while text and usage signals evaluated against the resolved model's context window normalize overflow to `CONTEXT_WINDOW_EXCEEDED`.
|
||||
- pi-ai reports failures as in-stream error events; these map to `finish {kind:'error'|'aborted', failure}` chunks. Provider-specific error text distinguishes terminal `QUOTA` from transient `RATE_LIMIT`, while text and usage signals evaluated against the resolved model's context window normalize overflow to `CONTEXT_WINDOW_EXCEEDED`. A terminal `stop` whose message carries no content blocks maps to a `finish {kind:'error'}` with code `EMPTY_RESPONSE` (retried by default policy) instead of a successful empty message.
|
||||
- pi-ai folds reasoning tokens into output usage; there is no separate reasoning count to map.
|
||||
- `GenerateOptions.stop` is rejected with `UNSUPPORTED_OPTION` because pi-ai's common streaming surface cannot guarantee it across providers.
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* @module dsh-llm-pi-ai/stream
|
||||
*/
|
||||
|
||||
import { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, isContextWindowExceededError, isQuotaExceededError, LlmError, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, isContextWindowExceededError, isQuotaExceededError, LlmError, QUOTA_EXCEEDED_CODE } from '@deepseek-ai/dsh-llm'
|
||||
import type { FinishReason, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||
import { isContextOverflow } from '@earendil-works/pi-ai'
|
||||
import type { AssistantMessage, AssistantMessageEvent, Usage as PiUsage } from '@earendil-works/pi-ai'
|
||||
@@ -48,7 +48,8 @@ function classifyPiAiError(message: string): string {
|
||||
* @param contextWindow - resolved catalog capacity for usage-based overflow detection.
|
||||
* @returns the mapped harness reason. Recognized error text, `stop` usage above
|
||||
* `contextWindow`, and zero-output `length` usage that fills the window map
|
||||
* to `CONTEXT_WINDOW_EXCEEDED`.
|
||||
* to `CONTEXT_WINDOW_EXCEEDED`; a `stop` with no content blocks maps to an
|
||||
* `EMPTY_RESPONSE` error.
|
||||
*/
|
||||
export function mapStopReason(message: AssistantMessage, contextWindow?: number): FinishReason {
|
||||
const piAiOverflow = isContextOverflow(message, contextWindow)
|
||||
@@ -66,7 +67,19 @@ export function mapStopReason(message: AssistantMessage, contextWindow?: number)
|
||||
}
|
||||
|
||||
switch (message.stopReason) {
|
||||
case 'stop': return { kind: 'stop' }
|
||||
case 'stop':
|
||||
// A terminal stop that produced no content blocks is a degenerate
|
||||
// provider completion, not a successful (empty) assistant message.
|
||||
if (message.content.length === 0) {
|
||||
return {
|
||||
kind: 'error',
|
||||
failure: {
|
||||
message: `model "${message.model}" returned a completed response with no content`,
|
||||
code: EMPTY_RESPONSE_CODE,
|
||||
},
|
||||
}
|
||||
}
|
||||
return { kind: 'stop' }
|
||||
case 'length': return { kind: 'max-tokens' }
|
||||
case 'toolUse': return { kind: 'tool-calls' }
|
||||
case 'aborted': return {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId, CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { AssistantMessage, AssistantMessageEvent, Usage } from '@earendil-works/pi-ai'
|
||||
import { toPiContext } from '../src/context.ts'
|
||||
@@ -520,7 +520,22 @@ describe('mapStopReason / mapUsage', () => {
|
||||
['toolUse', { kind: 'tool-calls' }],
|
||||
['aborted', { kind: 'aborted', failure: { message: 'pi-ai stream aborted', code: 'ABORTED' } }],
|
||||
] as const)('maps %s', (stopReason, expected) => {
|
||||
expect(mapStopReason(assistant({ stopReason }))).toEqual(expected)
|
||||
expect(mapStopReason(assistant({ stopReason, content: [{ type: 'text', text: 'ok' }] }))).toEqual(expected)
|
||||
})
|
||||
|
||||
it('classifies a completed stop with no content as an EMPTY_RESPONSE error', () => {
|
||||
expect(mapStopReason(assistant({ stopReason: 'stop' }))).toEqual({
|
||||
kind: 'error',
|
||||
failure: {
|
||||
message: 'model "deepseek-v4-flash" returned a completed response with no content',
|
||||
code: EMPTY_RESPONSE_CODE,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps a thinking-only stop successful (any block counts as content)', () => {
|
||||
expect(mapStopReason(assistant({ stopReason: 'stop', content: [{ type: 'thinking', thinking: 'mull' }] })))
|
||||
.toEqual({ kind: 'stop' })
|
||||
})
|
||||
|
||||
it('defaults the error message when pi-ai omits it', () => {
|
||||
@@ -580,7 +595,9 @@ describe('mapStopReason / mapUsage', () => {
|
||||
})
|
||||
|
||||
it('uses the resolved context window for silent and length-stop overflows', () => {
|
||||
const silent = assistant({ stopReason: 'stop', usage: usage(101, 0) })
|
||||
// Non-empty content keeps the no-window branch on the successful stop path
|
||||
// (an empty stop is EMPTY_RESPONSE, covered above); overflow wins over both.
|
||||
const silent = assistant({ stopReason: 'stop', usage: usage(101, 0), content: [{ type: 'text', text: 'x' }] })
|
||||
expect(mapStopReason(silent)).toEqual({ kind: 'stop' })
|
||||
expect(mapStopReason(silent, 100)).toEqual({
|
||||
kind: 'error',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Function plugin that retries selected transient model-request failures on the agent loop's closed-step recovery seam. It does not wrap `ctx.llm.stream()`: every adapter call remains one provider attempt, and every retry opens a fresh numbered step.
|
||||
|
||||
The default policy permits two retries for `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`, using bounded exponential backoff from 500 ms to 10 seconds with 10 percent jitter. Delay bounds must fit Node's supported timer range. A valid `providerRetryAfterMs` replaces local backoff when it is within the configured cap; an over-cap instruction delegates to the next recovery policy instead.
|
||||
The default policy permits two retries for `EMPTY_RESPONSE`, `RATE_LIMIT`, `SERVER`, `TIMEOUT`, and `TRANSPORT`, using bounded exponential backoff from 500 ms to 10 seconds with 10 percent jitter. `EMPTY_RESPONSE` is the adapters' classification of a degenerate provider completion (a terminal stop with zero content blocks); the attempt produced nothing durable, so repeating it is safe. Delay bounds must fit Node's supported timer range. A valid `providerRetryAfterMs` replaces local backoff when it is within the configured cap; an over-cap instruction delegates to the next recovery policy instead.
|
||||
|
||||
Before waiting, the plugin appends a non-surface `llm/retry` event with the failure and scheduled delay. Cancellation and plugin disposal abort the wait; disposal drains the plugin's active backoffs, and a callback captured before disposal fails closed if invoked afterward.
|
||||
|
||||
@@ -15,7 +15,7 @@ The separately published `./invariant` companion checks that every retry record
|
||||
initialDelayMs: 500
|
||||
maxDelayMs: 10000
|
||||
jitterRatio: 0.1
|
||||
retryableCodes: [RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT]
|
||||
retryableCodes: [EMPTY_RESPONSE, RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT]
|
||||
```
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -33,7 +33,7 @@ const DEFAULT_MAX_TRANSIENT_RETRIES = 2
|
||||
const DEFAULT_INITIAL_DELAY_MS = 500
|
||||
const DEFAULT_MAX_DELAY_MS = 10_000
|
||||
const DEFAULT_JITTER_RATIO = 0.1
|
||||
const DEFAULT_RETRYABLE_CODES = Object.freeze(['RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT'])
|
||||
const DEFAULT_RETRYABLE_CODES = Object.freeze(['EMPTY_RESPONSE', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT'])
|
||||
|
||||
/** Deployment-owned limits and classification for transient request recovery. */
|
||||
export interface Config {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Fiber } from 'cordis'
|
||||
import LlmService, { CallId, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import LlmService, { CallId, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
@@ -51,6 +51,25 @@ function textResponse(text: string): StreamChunk[] {
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* A degenerate empty provider completion as an error finish chunk. Both
|
||||
* adapters emit this shape and the EMPTY_RESPONSE code (the field the policy
|
||||
* routes on); the message text here is the deepseek adapter's phrasing (pi-ai
|
||||
* qualifies it with the model name).
|
||||
*/
|
||||
function emptyCompletion(): StreamChunk[] {
|
||||
return [
|
||||
{ type: 'usage', usage: { inputTokens: 0, outputTokens: 0 } },
|
||||
{
|
||||
type: 'finish',
|
||||
reason: {
|
||||
kind: 'error',
|
||||
failure: { message: 'model returned a completed response with no content', code: EMPTY_RESPONSE_CODE },
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
async function harness(
|
||||
adapter: LlmAdapter,
|
||||
config: retry.Config = {},
|
||||
@@ -158,6 +177,39 @@ describe('bounded transient retry policy', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('retries an EMPTY_RESPONSE error finish under the default retryable codes', async () => {
|
||||
vi.useFakeTimers()
|
||||
const adapter = new ScriptedAdapter([
|
||||
emptyCompletion(),
|
||||
textResponse('recovered'),
|
||||
])
|
||||
// No retryableCodes override: this proves the default policy covers the
|
||||
// adapters' empty-completion classification end to end (finish-chunk error
|
||||
// delivery, not a thrown stream error).
|
||||
;({ ctx: context } = await harness(adapter))
|
||||
const agent = context.agentLoop.create(SessionId('retry-empty-response'), { provider: 'mock', model: 'mock' })
|
||||
const scheduled = waitForRetry(context, agent, 1)
|
||||
|
||||
agent.followup([{ type: 'text', text: 'go' }])
|
||||
const event = await scheduled
|
||||
expect(event.data.failure).toEqual({
|
||||
message: 'model returned a completed response with no content',
|
||||
code: EMPTY_RESPONSE_CODE,
|
||||
})
|
||||
|
||||
const idle = waitForIdle(context, agent)
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
await idle
|
||||
|
||||
expect(adapter.requests).toHaveLength(2)
|
||||
expect(agent.session.events.filter(event => event.type === 'assistant/message').map(event => event.data.step))
|
||||
.toEqual([2])
|
||||
expect(agent.session.deriveMessages().at(-1)).toMatchObject({
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'recovered' }],
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves partial failed chunks on their step without committing a message or tool side effect', async () => {
|
||||
vi.useFakeTimers()
|
||||
const adapter = new ScriptedAdapter([
|
||||
|
||||
@@ -54,6 +54,7 @@ Every product adapter sends application identity on provider HTTP requests. `att
|
||||
- `errorChain(value)` — renders a thrown value with its full `cause` chain and AggregateError members for diagnostic surfaces (UI notices, logger lines, durable `turn/end` messages), so transport wrappers like undici's `TypeError: fetch failed` surface the underlying `ECONNREFUSED`/DNS/TLS detail instead of masking it. Rendering only — route on `code`, never by parsing the result.
|
||||
- `CONTEXT_WINDOW_EXCEEDED_CODE` — the provider-neutral code both DeepSeek adapters use when a request exceeds the model context window, regardless of thrown-HTTP versus in-band finish delivery. `isContextWindowExceededError(detail)` is their shared conservative classifier for OpenAI-compatible provider detail.
|
||||
- `QUOTA_EXCEEDED_CODE` — the non-transient provider-neutral code for exhausted account quota, balance, credits, budget, or usage limits. `isQuotaExceededError(detail)` keeps those failures distinct from request-rate limits.
|
||||
- `EMPTY_RESPONSE_CODE` — the provider-neutral code both adapters use for a degenerate provider completion: a terminal `stop` that carried no content blocks at all. Classified as an error finish (not a successful empty message) because the attempt produced nothing durable; `dsh-llm-retry` retries it by default.
|
||||
|
||||
### Real adapters
|
||||
|
||||
|
||||
@@ -27,6 +27,17 @@ export const CONTEXT_WINDOW_EXCEEDED_CODE = 'CONTEXT_WINDOW_EXCEEDED'
|
||||
/** Canonical provider-neutral code for an exhausted account quota or balance. */
|
||||
export const QUOTA_EXCEEDED_CODE = 'QUOTA'
|
||||
|
||||
/**
|
||||
* Canonical provider-neutral code for a response that completed normally but
|
||||
* carried no content blocks at all. Providers occasionally emit a degenerate
|
||||
* completion (a terminal stop with zero output); adapters classify it as this
|
||||
* failure instead of yielding an empty assistant message, because an empty
|
||||
* message silently ends the turn with nothing for the user or the loop to act
|
||||
* on. The attempt produced nothing durable, so retry policy treats it as safe
|
||||
* to repeat.
|
||||
*/
|
||||
export const EMPTY_RESPONSE_CODE = 'EMPTY_RESPONSE'
|
||||
|
||||
/** Structured codes and plain phrases that explicitly name a context bound being exceeded. */
|
||||
const STRUCTURED_CONTEXT_OVERFLOW = new RegExp(
|
||||
String.raw`(?:^|[^a-z0-9])context[\s_-](?:length|window)[\s_-]`
|
||||
|
||||
@@ -51,7 +51,7 @@ const activeServerNames = new WeakMap<Context, Set<string>>()
|
||||
|
||||
/** Config for connecting to an MCP server via a spawned child process over stdio. */
|
||||
export interface StdioConfig {
|
||||
/** Transport type: spawn a child process and communicate over stdio. */
|
||||
/** Selects child-process stdio transport. */
|
||||
transport: 'stdio'
|
||||
/**
|
||||
* Stable local namespace for this server's model-facing tool names
|
||||
@@ -59,21 +59,21 @@ export interface StdioConfig {
|
||||
* unique across live mcp-client instances.
|
||||
*/
|
||||
serverName: string
|
||||
/** Executable to spawn. */
|
||||
/** Executable used to start the server. */
|
||||
command: string
|
||||
/** Arguments passed to the command. */
|
||||
/** Arguments passed directly, without shell interpolation. */
|
||||
args: string[]
|
||||
/** Extra env vars merged on top of scrubbed ambient env. */
|
||||
env: Record<string, string>
|
||||
/** Working directory for the child process. */
|
||||
cwd: string
|
||||
/** Timeout per callTool invocation (ms). */
|
||||
/** Per-tool-call timeout in milliseconds. */
|
||||
toolCallTimeoutMs: number
|
||||
}
|
||||
|
||||
/** Config for connecting to an MCP server over Streamable HTTP (SSE). */
|
||||
export interface StreamableHttpConfig {
|
||||
/** Transport type: connect to an MCP server over Streamable HTTP (SSE). */
|
||||
/** Selects Streamable HTTP transport. */
|
||||
transport: 'streamable-http'
|
||||
/**
|
||||
* Stable local namespace for this server's model-facing tool names
|
||||
@@ -81,15 +81,15 @@ export interface StreamableHttpConfig {
|
||||
* unique across live mcp-client instances.
|
||||
*/
|
||||
serverName: string
|
||||
/** MCP server URL. */
|
||||
/** MCP endpoint URL. */
|
||||
url: string
|
||||
/** Extra headers (e.g. auth tokens). */
|
||||
/** Additional headers attached to MCP requests. */
|
||||
headers: Record<string, string>
|
||||
/** Timeout per callTool invocation (ms). */
|
||||
/** Per-tool-call timeout in milliseconds. */
|
||||
toolCallTimeoutMs: number
|
||||
}
|
||||
|
||||
/** Discriminated union of all supported MCP transport configurations. */
|
||||
/** Configuration for one stdio or Streamable HTTP MCP server. */
|
||||
export type Config = StdioConfig | StreamableHttpConfig
|
||||
|
||||
export const Config = z.union([
|
||||
|
||||
@@ -213,13 +213,11 @@ describe('apply (plugin lifecycle)', () => {
|
||||
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
|
||||
|
||||
// Simulate the notification handler being invoked with a new tool list.
|
||||
mockListTools.mockResolvedValue({
|
||||
tools: [{ name: 'updated', inputSchema: { type: 'object' } }],
|
||||
nextCursor: undefined,
|
||||
})
|
||||
|
||||
// Extract and call the notification handler.
|
||||
const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void>
|
||||
await handler()
|
||||
|
||||
|
||||
@@ -314,18 +314,16 @@ describe('server-filesystem — real filesystem operations', () => {
|
||||
const filePath = join(tempDir, 'test.txt')
|
||||
const content = 'Hello from MCP e2e test!'
|
||||
|
||||
// Write via MCP tool
|
||||
const writeResult = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: nextCallId(), name: 'mcp__filesystem__write_file', arguments: { path: filePath, content },
|
||||
})
|
||||
expect(writeResult.isError).toBe(false)
|
||||
|
||||
// Verify file was actually written (world verification)
|
||||
// Assert the filesystem effect independently of the tool result.
|
||||
const onDisk = await readFile(filePath, 'utf8')
|
||||
expect(onDisk).toBe(content)
|
||||
|
||||
// Read back via MCP tool
|
||||
const readResult = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: nextCallId(), name: 'mcp__filesystem__read_file', arguments: { path: filePath },
|
||||
@@ -335,7 +333,6 @@ describe('server-filesystem — real filesystem operations', () => {
|
||||
})
|
||||
|
||||
it('list_directory shows written file', async () => {
|
||||
// Ensure a file exists
|
||||
await writeFile(join(tempDir, 'listed.txt'), 'listed')
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
|
||||
@@ -811,7 +811,6 @@ describe('tool execution — non-object args fallback', () => {
|
||||
)
|
||||
|
||||
await syncTools(client as never, ctx, defaultOpts, new Map())
|
||||
// Simulate model emitting `null` as tool arguments (malformed).
|
||||
await ctx.tools.execute({ signal: testToolSignal, callId: CallId('c1'), name: 'mcp__srv__coerce', arguments: null })
|
||||
|
||||
expect(client.callTool).toHaveBeenCalledWith(
|
||||
|
||||
@@ -6,14 +6,16 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence
|
||||
|
||||
```
|
||||
<root>/
|
||||
cwd-<sha256(cwd)[:12]>/ # per-project bucket (or _no-cwd/ when no cwd)
|
||||
<encoded-id>.jsonl.zstd # default: checksummed header frame + append frames
|
||||
<encoded-id>.jsonl # only with compression: 'none'
|
||||
--<normalized-cwd>--/ # readable project directory (or _no-cwd/)
|
||||
<encoded-id>/ # session-owned directory
|
||||
session.jsonl.zstd # default: checksummed header frame + append frames
|
||||
session.jsonl # only with compression: 'none'
|
||||
```
|
||||
|
||||
- The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`).
|
||||
- A storage record is a `SessionEvent` JSON verbatim, or — written only under `packChunks` — a **packed chunk row** (`text-chunks` / `reasoning-chunks` / `tool-call-chunks`; bare slash-less tags like the header's `session`, so row tags cannot be confused with event types): one line holding a run of ≥3 consecutive same-block `assistant/chunk` delta events, `seq0`/`time0` plus per-member `dt` gaps reconstructing every member's `seq`/`time` exactly. The lossless codec lives in `@deepseek-ai/dsh-session` (`packChunkRuns`/`decodeStorageRecord`) and whitelists exact shapes — anything unrecognized stores verbatim. Reading is layout-blind: `load` always decodes rows, so packed, unpacked, and mixed files load identically.
|
||||
- Session ids are unvalidated branded strings, so they are injectively escaped to a single safe path segment before use (no traversal, no collision).
|
||||
- The project directory keeps the normalized cwd readable for navigation and is bounded for filesystem component limits. Separator replacement and truncation are intentionally lossy, so cwd strings that normalize alike share a project directory; session ids still select distinct session directories. On a case-insensitive filesystem, identity validation accepts an alternate path spelling only when filesystem canonicalization resolves both spellings to the same transcript. The configured root remains deployment-controlled: it may be project-local, shared, temporary, or centralized. The [project-session directory decision](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) records this tradeoff.
|
||||
- Session ids are unvalidated branded strings, so they are injectively escaped to a single safe path segment before use (no traversal, no collision). The resulting directory is reserved for additional session-owned artifacts; discovery reads only the fixed transcript filename.
|
||||
|
||||
## Config
|
||||
|
||||
@@ -23,17 +25,17 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence
|
||||
| `packChunks` | `boolean` (default `false`) | Write delta-chunk runs as packed rows (~60% smaller logical logs measured on a real coding session). Off, the written logical layout is byte-identical to the pre-packing format; reading packed rows works regardless of this switch. Off by default while the snapshot goldens stay one-event-per-line — recording with packing on rewrites every fixture `session.jsonl`. |
|
||||
| `compression` | `'zstd' \| 'none'` | Defaults to `'zstd'`; `'none'` retains newline-delimited UTF-8 text. |
|
||||
|
||||
`locate(meta)` returns `{ kind: 'jsonl', path }` using the resolved absolute root and the same cwd-bucket/id encoding as materialization. It performs no filesystem I/O: the target can be returned before the file exists, and an existing file contains only the last flushed prefix.
|
||||
`locate(meta)` returns `{ kind: 'jsonl', path }` for the fixed transcript inside the resolved project/session directories. It performs no filesystem I/O: the target can be returned before the directory or file exists, and an existing file contains only the last flushed prefix.
|
||||
|
||||
## Physical encoding
|
||||
|
||||
The default artifact is a standard concatenation of independent [Zstandard frames](../../../.agents/notes/implemented/architecture/2026-07-19-zstandard-jsonl-session-logs.md): one checksummed frame containing only the header line, followed by one checksummed frame per durable append batch. The backend uses Node's built-in Zstandard API with its default compression level and exposes no level knob. Listing reads and validates only the header frame. `compression: 'none'` keeps the same logical lines in the original raw representation.
|
||||
|
||||
A root belongs to one encoding. Startup discovery and targeted lookup reject the opposite suffix with an error naming the incompatible artifact and instructing the caller to select the matching mode or a separate root. There is no migration, mixed-root fallback, or dual write.
|
||||
A root belongs to one encoding. Startup discovery and targeted lookup reject the opposite suffix with an error naming the incompatible artifact and instructing the caller to select the matching mode or a separate root. Flat `<project>/<id>.jsonl*` artifacts are also rejected instead of ignored. There is no migration, mixed-root fallback, or dual write.
|
||||
|
||||
## Durability and crash semantics
|
||||
|
||||
- **Bound storage identity.** Lookup requires one matching encoded filename across the cwd buckets, then verifies that the header id equals the requested id and that the header's id/cwd derive the selected path. Listing applies the same path check and rejects duplicate ids. Identity failures occur before repair or append.
|
||||
- **Bound storage identity.** Lookup requires one matching session directory across the readable project directories, then verifies that the header id equals the requested id and that the header's id/cwd derive the selected transcript path. Listing applies the same path check and rejects duplicate ids. Identity failures occur before repair or append.
|
||||
- **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s the encoded header and first batch in a temporary file. POSIX publishes it without overwrite via a hard link and `fsync`s the parent directory. Windows publishes it without overwrite via `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` and creates missing directories through the same write-through pattern. A created-but-never-appended session leaves nothing on disk and is absent from `list`.
|
||||
- **Append-only.** Flushed events are never rewritten. Subsequent raw batches append lines; compressed batches append one frame. Both paths `fsync`, and a caught write or sync failure rolls the file back to its prior byte length.
|
||||
- **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.
|
||||
@@ -64,6 +66,7 @@ JSONL storage does not mutate live request prefixes. A resumed loop can reuse pr
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Only the configured encoding and current `SESSION_FORMAT_VERSION` (v0) load** — changing compression requires a separate/fresh root or selecting the legacy raw mode; the pre-release format has no migration.
|
||||
- **The flat-file storage layout does not load** — use a separate root or move pre-release artifacts into the project/session directory layout before loading.
|
||||
- **Compressed files are not directly line-readable** — use the backend to load them, or select `compression: 'none'` before writing a fresh root when text fixtures or external line readers are required.
|
||||
- **Nothing deletes session files** — logs accumulate under `root` until removed externally (the seam has no deletion surface).
|
||||
- **One live writer per session** — append and repair are coordinated only inside the owning backend instance. Another backend instance or process must not write the same session until that owner reaches quiescent disposal; initial same-id publication remains collision-safe through the POSIX no-overwrite hard link or Windows write-through rename without replacement.
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
* On-disk format helpers for the JSONL session-persistence backend: path
|
||||
* sanitization (a {@link SessionId} is an unvalidated branded string, so it
|
||||
* MUST be encoded before use in a path — no traversal, no collision), the
|
||||
* per-cwd directory layout, header-line (de)serialization, and the
|
||||
* per-project/session directory layout, header-line (de)serialization, and the
|
||||
* truncation-repair offset computation.
|
||||
*
|
||||
* @module dsh-session-persistence-jsonl/format
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import { join } from 'node:path'
|
||||
import { decodeStorageRecord, packChunkRuns } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, SessionHeader, SessionId, StorageRecord } from '@deepseek-ai/dsh-session'
|
||||
@@ -123,24 +122,64 @@ export function encodeSegment(raw: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* The directory a session's files live in: the configured root, then a per-cwd
|
||||
* subdirectory so sessions group by project. The cwd subdir is a stable hash of
|
||||
* the cwd (short, collision-resistant, filesystem-safe); sessions without a
|
||||
* cwd go in a shared `_no-cwd` bucket.
|
||||
* @param root - the backend's session root directory.
|
||||
* @param cwd - the session's project directory; `undefined` selects the shared `_no-cwd` bucket.
|
||||
* @returns the per-cwd bucket directory path under `root`.
|
||||
* Build the readable directory key for a project path.
|
||||
* Filesystem separators and drive separators become `-`; unsafe code units use
|
||||
* the same `~XXXX` escape as session ids. The key is bounded for filesystem
|
||||
* component limits. Separator replacement and truncation are intentionally
|
||||
* lossy, following the common human-navigable project-directory convention.
|
||||
* @param cwd - the session's project directory.
|
||||
* @returns a single filesystem-safe project directory name.
|
||||
*/
|
||||
export function sessionDir(root: string, cwd: string | undefined): string {
|
||||
export function projectKey(cwd: string): string {
|
||||
if (cwd.length === 0) throw new Error('cannot encode an empty project path')
|
||||
let readable = ''
|
||||
let separatorRun = false
|
||||
for (let i = 0; i < cwd.length; i++) {
|
||||
const code = cwd.charCodeAt(i)
|
||||
const ch = String.fromCharCode(code)
|
||||
if (ch === '/' || ch === '\\' || ch === ':') {
|
||||
if (!separatorRun) readable += '-'
|
||||
separatorRun = true
|
||||
} else if (ch !== '~' && /^[A-Za-z0-9._-]$/.test(ch)) {
|
||||
readable += ch
|
||||
separatorRun = false
|
||||
} else {
|
||||
readable += '~' + code.toString(16).toUpperCase().padStart(4, '0')
|
||||
separatorRun = false
|
||||
}
|
||||
}
|
||||
const slug = readable.replace(/^-+/, '') || 'root'
|
||||
return `--${slug.slice(0, 251)}--`
|
||||
}
|
||||
|
||||
/**
|
||||
* The configured root's human-navigable project directory. A configured root
|
||||
* may be local or shared; this grouping does not prescribe its deployment.
|
||||
* @param root - the backend's session root directory.
|
||||
* @param cwd - the session's project directory; `undefined` selects `_no-cwd`.
|
||||
* @returns the project directory path under `root`.
|
||||
*/
|
||||
export function projectDir(root: string, cwd: string | undefined): string {
|
||||
if (cwd === undefined) return join(root, '_no-cwd')
|
||||
const hash = createHash('sha256').update(cwd).digest('hex').slice(0, 12)
|
||||
return join(root, `cwd-${hash}`)
|
||||
return join(root, projectKey(cwd))
|
||||
}
|
||||
|
||||
/**
|
||||
* The directory owned by one session and available for future session-local
|
||||
* artifacts.
|
||||
* @param root - the backend's session root directory.
|
||||
* @param cwd - the session's project directory.
|
||||
* @param id - the session id, encoded to one safe path segment.
|
||||
* @returns the session directory beneath its project directory.
|
||||
*/
|
||||
export function sessionDir(root: string, cwd: string | undefined, id: SessionId): string {
|
||||
return join(projectDir(root, cwd), encodeSegment(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* The append-only event-log file path for a session.
|
||||
* @param root - the backend's session root directory.
|
||||
* @param cwd - the session's project directory (picks the per-cwd bucket; `undefined` → `_no-cwd`).
|
||||
* @param cwd - the session's project directory (`undefined` → `_no-cwd`).
|
||||
* @param id - the session id, path-encoded via {@link encodeSegment} before filesystem use.
|
||||
* @param compression - physical artifact encoding and filename suffix.
|
||||
* @returns the session's configured JSONL artifact path.
|
||||
@@ -151,7 +190,7 @@ export function logPath(
|
||||
id: SessionId,
|
||||
compression: JsonlCompression,
|
||||
): string {
|
||||
return join(sessionDir(root, cwd), `${encodeSegment(id)}${logSuffix(compression)}`)
|
||||
return join(sessionDir(root, cwd, id), `session${logSuffix(compression)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { readdirSync } from 'node:fs'
|
||||
import { open, mkdir, readFile, readdir, link, rm, stat, truncate } from 'node:fs/promises'
|
||||
import { open, mkdir, readFile, readdir, realpath, link, rm, stat, truncate } from 'node:fs/promises'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { randomBytes } from 'node:crypto'
|
||||
import {
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import {
|
||||
encodeSegment, eventLines, logPath, logSuffix, parseHeaderMeta, scanLog, sessionDir, toHeaderLine,
|
||||
encodeSegment, eventLines, logPath, logSuffix, parseHeaderMeta, projectDir, scanLog, sessionDir, toHeaderLine,
|
||||
type JsonlCompression,
|
||||
} from './format.ts'
|
||||
import { compressZstdFrame, decompressZstdFrame, scanZstdFrames } from './zstd.ts'
|
||||
@@ -40,9 +40,9 @@ export interface Config {
|
||||
/**
|
||||
* Root directory for all session files. Required (no default): a default of
|
||||
* `process.cwd()` would scatter session files as the process's cwd changes
|
||||
* (bash calls, subprocesses). Sessions group under per-cwd subdirectories. An
|
||||
* existing root must be a readable directory; an absent root is created on
|
||||
* first materialization.
|
||||
* (bash calls, subprocesses). Sessions group under human-readable project
|
||||
* directories, then per-session directories. An existing root must be a
|
||||
* readable directory; an absent root is created on first materialization.
|
||||
*/
|
||||
root: string
|
||||
/**
|
||||
@@ -141,7 +141,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
/* jscpd:ignore-end */
|
||||
// --- PersistenceBackend hooks (the file-bytes storage primitives) ---
|
||||
|
||||
/** Read a stored prefix by id across all cwd buckets when cwd is unknown. */
|
||||
/** Read a stored prefix by id across all project directories when cwd is unknown. */
|
||||
async loadStored(id: SessionId): Promise<StoredPrefix<JsonlTornMarker> | undefined> {
|
||||
await this.ensureRootEncoding()
|
||||
const path = await this.findLog(id)
|
||||
@@ -168,7 +168,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
: {},
|
||||
}
|
||||
}
|
||||
this.assertStoredIdentity(path, prefix.meta, expectedId)
|
||||
await this.assertStoredIdentity(path, prefix.meta, expectedId)
|
||||
return prefix
|
||||
}
|
||||
|
||||
@@ -278,9 +278,12 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
await this.ensureRootEncoding()
|
||||
const artifacts: Array<{ header: SessionHeader; path: string }> = []
|
||||
const ids = new Set<SessionId>()
|
||||
for (const dir of await this.listCwdDirs()) {
|
||||
for (const name of await this.listArtifactNames(dir)) {
|
||||
const path = join(dir, name)
|
||||
for (const project of await this.listProjectDirs()) {
|
||||
for (const dir of await this.listSessionDirs(project)) {
|
||||
const opposite = join(dir, `session${logSuffix(this.oppositeCompression())}`)
|
||||
if (await this.exists(opposite)) throw this.encodingMismatch(opposite)
|
||||
const path = join(dir, `session${logSuffix(this.compression)}`)
|
||||
if (!await this.exists(path)) continue
|
||||
// Read only headers so listing scales with session count, not log size.
|
||||
const first = this.compression === 'zstd'
|
||||
? await this.readFirstZstdLine(path)
|
||||
@@ -288,9 +291,9 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
if (first === undefined) continue // empty/half-written file
|
||||
const meta = parseHeaderMeta(first)
|
||||
if (meta === undefined) continue // not a session header
|
||||
this.assertStoredIdentity(path, meta)
|
||||
await this.assertStoredIdentity(path, meta)
|
||||
if (ids.has(meta.id)) {
|
||||
throw new Error(`duplicate JSONL session id "${meta.id}" appears in multiple cwd buckets`)
|
||||
throw new Error(`duplicate JSONL session id "${meta.id}" appears in multiple project directories`)
|
||||
}
|
||||
ids.add(meta.id)
|
||||
artifacts.push({ header: meta, path })
|
||||
@@ -303,20 +306,22 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
|
||||
/** Atomically write the header line + first batch (temp-write, fsync, publish). */
|
||||
private async materialize(meta: SessionHeader, events: readonly SessionEvent[]): Promise<void> {
|
||||
const dir = sessionDir(this.root, meta.cwd)
|
||||
const project = projectDir(this.root, meta.cwd)
|
||||
const dir = sessionDir(this.root, meta.cwd, meta.id)
|
||||
const finalPath = logPath(this.root, meta.cwd, meta.id, this.compression)
|
||||
await this.rejectOppositeArtifact(meta.cwd, meta.id)
|
||||
const content = await this.encodeMaterialization(meta, events)
|
||||
/* v8 ignore next -- native Windows coverage exercises this platform dispatch; Linux covers the POSIX peer */
|
||||
if (process.platform === 'win32') {
|
||||
await this.materializeWin32(dir, finalPath, meta.id, content)
|
||||
await this.materializeWin32(project, dir, finalPath, meta.id, content)
|
||||
} else {
|
||||
await this.materializePosix(dir, finalPath, meta.id, content)
|
||||
await this.materializePosix(project, dir, finalPath, meta.id, content)
|
||||
}
|
||||
}
|
||||
|
||||
/* v8 ignore start -- Windows uses the Win32 durable-publish path; POSIX coverage exercises this peer. */
|
||||
private async materializePosix(
|
||||
project: string,
|
||||
dir: string,
|
||||
finalPath: string,
|
||||
id: SessionId,
|
||||
@@ -324,8 +329,10 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
): Promise<void> {
|
||||
await mkdir(this.root, { recursive: true, mode: 0o700 })
|
||||
await this.syncDirPosix(dirname(this.root))
|
||||
await mkdir(dir, { recursive: true, mode: 0o700 })
|
||||
await mkdir(project, { recursive: true, mode: 0o700 })
|
||||
await this.syncDirPosix(this.root)
|
||||
await mkdir(dir, { recursive: true, mode: 0o700 })
|
||||
await this.syncDirPosix(project)
|
||||
await this.rejectExistingLog(finalPath, id)
|
||||
const tmp = await this.writeSyncedTempFile(finalPath, content)
|
||||
// Publish via link()+unlink(), NOT rename(): link fails with EEXIST if the
|
||||
@@ -358,12 +365,14 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
|
||||
/* v8 ignore start -- native Windows coverage exercises this integration path */
|
||||
private async materializeWin32(
|
||||
project: string,
|
||||
dir: string,
|
||||
finalPath: string,
|
||||
id: SessionId,
|
||||
content: Buffer | string,
|
||||
): Promise<void> {
|
||||
await ensureDurableDirectoryWin32(this.root)
|
||||
await ensureDurableDirectoryWin32(project)
|
||||
await ensureDurableDirectoryWin32(dir)
|
||||
await this.rejectExistingLog(finalPath, id)
|
||||
const tmp = await this.writeSyncedTempFile(finalPath, content)
|
||||
@@ -541,19 +550,19 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
}
|
||||
}
|
||||
|
||||
/** Find the unique physical log for an id across every cwd bucket. */
|
||||
/** Find the unique physical log for an id across every project directory. */
|
||||
private async findLog(id: SessionId): Promise<string | undefined> {
|
||||
const target = encodeSegment(id) + logSuffix(this.compression)
|
||||
const oppositeTarget = encodeSegment(id) + logSuffix(this.oppositeCompression())
|
||||
const matches: string[] = []
|
||||
for (const dir of await this.listCwdDirs()) {
|
||||
const path = join(dir, target)
|
||||
const opposite = join(dir, oppositeTarget)
|
||||
for (const project of await this.listProjectDirs()) {
|
||||
await this.rejectLegacyFlatArtifact(project, id)
|
||||
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)
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
throw new Error(`duplicate JSONL session id "${id}" appears in multiple cwd buckets`)
|
||||
throw new Error(`duplicate JSONL session id "${id}" appears in multiple project directories`)
|
||||
}
|
||||
return matches[0]
|
||||
}
|
||||
@@ -569,7 +578,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
}
|
||||
|
||||
/** Reject metadata that does not identify the selected physical log. */
|
||||
private assertStoredIdentity(path: string, meta: SessionHeader, expectedId?: SessionId): void {
|
||||
private async assertStoredIdentity(path: string, meta: SessionHeader, expectedId?: SessionId): Promise<void> {
|
||||
if (expectedId !== undefined && meta.id !== expectedId) {
|
||||
throw new Error(`corrupt session log "${path}": requested id "${expectedId}" does not match header id "${meta.id}"`)
|
||||
}
|
||||
@@ -579,13 +588,30 @@ 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) {
|
||||
throw new Error(`corrupt session log "${path}": header id "${meta.id}" and cwd belong at "${expectedPath}"`)
|
||||
if (path !== expectedPath && !await this.sameFile(path, expectedPath)) {
|
||||
throw new Error(`corrupt session log "${path}": header id "${meta.id}" and cwd identify "${expectedPath}"`)
|
||||
}
|
||||
}
|
||||
|
||||
/** The cwd-bucket directories under the root (absolute paths). */
|
||||
private async listCwdDirs(): Promise<string[]> {
|
||||
/**
|
||||
* Whether two path spellings resolve to the same physical file. This admits
|
||||
* case aliases on case-insensitive filesystems without weakening identity
|
||||
* checks on case-sensitive stores.
|
||||
*/
|
||||
private async sameFile(path: string, expectedPath: string): Promise<boolean> {
|
||||
try {
|
||||
const [actual, expected] = await Promise.all([realpath(path), realpath(expectedPath)])
|
||||
return actual === expected
|
||||
} catch (error) {
|
||||
/* 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 */
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** The human-readable project directories under the configured root. */
|
||||
private async listProjectDirs(): Promise<string[]> {
|
||||
try {
|
||||
const entries = await readdir(this.root, { withFileTypes: true })
|
||||
return entries.filter(e => e.isDirectory()).map(e => join(this.root, e.name))
|
||||
@@ -596,13 +622,13 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
}
|
||||
}
|
||||
|
||||
private async listArtifactNames(dir: string): Promise<string[]> {
|
||||
const entries = await readdir(dir)
|
||||
const oppositeSuffix = logSuffix(this.oppositeCompression())
|
||||
const incompatible = entries.find(name => name.endsWith(oppositeSuffix))
|
||||
if (incompatible !== undefined) throw this.encodingMismatch(`${dir}/${incompatible}`)
|
||||
const suffix = logSuffix(this.compression)
|
||||
return entries.filter(name => name.endsWith(suffix))
|
||||
/** List session-owned directories and reject the obsolete flat-file layout. */
|
||||
private async listSessionDirs(project: string): Promise<string[]> {
|
||||
const entries = await readdir(project, { withFileTypes: true })
|
||||
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))
|
||||
return entries.filter(entry => entry.isDirectory()).map(entry => join(project, entry.name))
|
||||
}
|
||||
|
||||
/** Reject a root that already belongs to the other physical encoding. */
|
||||
@@ -612,11 +638,19 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
}
|
||||
|
||||
private async checkRootEncoding(): Promise<void> {
|
||||
const oppositeSuffix = logSuffix(this.oppositeCompression())
|
||||
for (const dir of await this.listCwdDirs()) {
|
||||
const entries = await readdir(dir)
|
||||
const incompatible = entries.find(name => name.endsWith(oppositeSuffix))
|
||||
if (incompatible !== undefined) throw this.encodingMismatch(`${dir}/${incompatible}`)
|
||||
for (const project of await this.listProjectDirs()) {
|
||||
for (const dir of await this.listSessionDirs(project)) {
|
||||
const incompatible = join(dir, `session${logSuffix(this.oppositeCompression())}`)
|
||||
if (await this.exists(incompatible)) throw this.encodingMismatch(incompatible)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async rejectLegacyFlatArtifact(project: string, id: SessionId): Promise<void> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -637,6 +671,13 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
)
|
||||
}
|
||||
|
||||
private legacyLayout(path: string): Error {
|
||||
return new Error(
|
||||
`session artifact ${JSON.stringify(path)} uses the unsupported flat-file layout; `
|
||||
+ 'use a separate root or move it into a project/session directory before loading',
|
||||
)
|
||||
}
|
||||
|
||||
private async exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
const handle = await open(path, 'r')
|
||||
@@ -646,7 +687,7 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
|
||||
// Only ENOENT means absent. A permission/I/O error must surface rather
|
||||
// than letting load or collision checks proceed under false absence.
|
||||
// Windows reports ENOENT, not ENOTDIR, for `regular-file/child`; verify
|
||||
// the immediate parent so a blocked cwd bucket remains a storage fault.
|
||||
// the immediate parent so a blocked session directory remains a storage fault.
|
||||
/* v8 ignore else -- Windows reports file-valued parents as ENOENT; POSIX covers direct ENOTDIR. */
|
||||
if (isENOENT(error)) {
|
||||
await this.assertLogParentAllowsAbsence(path)
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
*/
|
||||
|
||||
import { mkdtemp, rm, stat } from 'node:fs/promises'
|
||||
import { basename, join, parse, resolve, toNamespacedPath } from 'node:path'
|
||||
import { join, parse, resolve, toNamespacedPath } from 'node:path'
|
||||
|
||||
type MoveFileExW = (existing: string, replacement: string, flags: number) => number
|
||||
type GetLastError = () => number
|
||||
@@ -139,7 +139,9 @@ export async function ensureDurableDirectoryWin32(target: string): Promise<void>
|
||||
}
|
||||
|
||||
async function createLeafDirectoryWin32(parent: string, target: string): Promise<void> {
|
||||
const staging = await mkdtemp(join(parent, `.dsh-mkdir-${basename(target)}-`))
|
||||
// Keep the staging component independent of the target basename so a legal
|
||||
// 255-byte target component does not make mkdtemp's sibling name too long.
|
||||
const staging = await mkdtemp(join(parent, '.dsh-mkdir-'))
|
||||
try {
|
||||
await publishNewFileWin32(staging, target)
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises'
|
||||
import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat, symlink } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { isAbsolute, join, relative, resolve } from 'node:path'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { encodeSegment, eventLines, logPath, scanLog, sessionDir, toHeaderLine } from '../src/format.ts'
|
||||
import {
|
||||
encodeSegment, eventLines, logPath, projectDir, projectKey, scanLog, sessionDir, toHeaderLine,
|
||||
} from '../src/format.ts'
|
||||
import { runPersistenceContract, meta, oneTurnLog, appendLog } from '../../session-persistence/tests/contract.ts'
|
||||
import { runCoordinatorContract, type CoordinatorFixture } from '../../session-persistence/tests/coordinator-contract.ts'
|
||||
|
||||
@@ -125,6 +127,16 @@ describe('SessionPersistenceJsonl: format helpers', () => {
|
||||
expect(() => encodeSegment('')).toThrow(/empty/)
|
||||
})
|
||||
|
||||
it('projectKey normalizes project paths into bounded readable names', () => {
|
||||
expect(projectKey('/Users/qyj/work/deepseek-harness')).toBe('--Users-qyj-work-deepseek-harness--')
|
||||
expect(projectKey('/a/b-c')).toBe(projectKey('/a-b/c'))
|
||||
expect(projectKey('C:\\work\\agent')).toBe('--C-work-agent--')
|
||||
expect(projectKey('/开发/~agent')).toBe('--~5F00~53D1-~007Eagent--')
|
||||
expect(projectKey('/')).toBe('--root--')
|
||||
expect(projectKey('/' + 'x'.repeat(1_000))).toHaveLength(255)
|
||||
expect(() => projectKey('')).toThrow(/empty project path/)
|
||||
})
|
||||
|
||||
it('resolves a relative custom root before locating a session', async () => {
|
||||
const absoluteRoot = await freshRoot()
|
||||
const ctx = new Context()
|
||||
@@ -161,15 +173,15 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
|
||||
await ctx.sessionPersistence.create(m)
|
||||
// locate() is a pure target-path calculation: neither it nor create()
|
||||
// materializes a file before the first append.
|
||||
const dir = sessionDir(root, '/work')
|
||||
const dir = sessionDir(root, '/work', m.id)
|
||||
await expect(stat(rawLogPath(root, '/work', m.id))).rejects.toThrow()
|
||||
expect((await ctx.sessionPersistence.list()).map(h => h.id)).not.toContain(m.id)
|
||||
|
||||
await ctx.sessionPersistence.append(m.id, oneTurnLog())
|
||||
// now materialized
|
||||
expect((await stat(dir)).isDirectory()).toBe(true)
|
||||
expect((await stat(rawLogPath(root, '/work', m.id))).isFile()).toBe(true)
|
||||
expect((await ctx.sessionPersistence.list()).map(h => h.id)).toContain(m.id)
|
||||
void dir
|
||||
})
|
||||
|
||||
it('keeps the same location on resume and gives a fork its own location', async () => {
|
||||
@@ -266,7 +278,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
|
||||
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)
|
||||
await mkdir(sessionDir(root, m.cwd), { recursive: true })
|
||||
await mkdir(sessionDir(root, m.cwd, m.id), { recursive: true })
|
||||
await writeFile(path, [
|
||||
JSON.stringify(toHeaderLine(m)),
|
||||
JSON.stringify({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }),
|
||||
@@ -281,7 +293,7 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
|
||||
it('rejects a stored v0 full header carrying the legacy fallback reason', async () => {
|
||||
const m = meta('legacy-header-fallback', '/legacy')
|
||||
const path = rawLogPath(root, m.cwd, m.id)
|
||||
await mkdir(sessionDir(root, m.cwd), { recursive: true })
|
||||
await mkdir(sessionDir(root, m.cwd, m.id), { recursive: true })
|
||||
await writeFile(path, [
|
||||
JSON.stringify(toHeaderLine(m)),
|
||||
JSON.stringify({
|
||||
@@ -711,7 +723,7 @@ describe('SessionPersistenceJsonl: packed chunk rows (packChunks: true)', () =>
|
||||
const log = chunkRunLog()
|
||||
// First turn written line-per-event by an unpacked-config writer (an old
|
||||
// file, hand-planted so this packed-config backend adopts it on load).
|
||||
await mkdir(sessionDir(root, '/work'), { recursive: true })
|
||||
await mkdir(sessionDir(root, '/work', m.id), { recursive: true })
|
||||
await writeFile(rawLogPath(root, '/work', m.id), [
|
||||
JSON.stringify({ type: 'session', version: 0, id: 'mixed', createdAt: 1000, cwd: '/work', delegationDepth: 0 }),
|
||||
...log.map(e => JSON.stringify(e)),
|
||||
@@ -807,34 +819,93 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
await expect(stat(rawLogPath(root, '/mutated', SessionId('create-snap')))).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('list discovers sessions across multiple cwd buckets', async () => {
|
||||
it('list discovers sessions across multiple project directories', async () => {
|
||||
await ctx.sessionPersistence.create(meta('p1', '/projA'))
|
||||
await ctx.sessionPersistence.append(SessionId('p1'), oneTurnLog())
|
||||
await ctx.sessionPersistence.create(meta('p2', '/projB'))
|
||||
await ctx.sessionPersistence.append(SessionId('p2'), oneTurnLog())
|
||||
await ctx.sessionPersistence.create(meta('p3')) // no cwd → _no-cwd bucket
|
||||
await ctx.sessionPersistence.create(meta('p3')) // no cwd → _no-cwd project directory
|
||||
await ctx.sessionPersistence.append(SessionId('p3'), oneTurnLog())
|
||||
|
||||
const ids = (await ctx.sessionPersistence.list()).map(x => x.id).sort()
|
||||
expect(ids).toEqual(['p1', 'p2', 'p3'])
|
||||
})
|
||||
|
||||
it('groups sessions whose cwd paths normalize to the same project directory', async () => {
|
||||
const first = meta('normalized-first', '/a/b-c')
|
||||
const second = meta('normalized-second', '/a-b/c')
|
||||
await ctx.sessionPersistence.create(first)
|
||||
await ctx.sessionPersistence.append(first.id, oneTurnLog())
|
||||
await ctx.sessionPersistence.create(second)
|
||||
await ctx.sessionPersistence.append(second.id, oneTurnLog())
|
||||
|
||||
expect(projectDir(root, first.cwd)).toBe(projectDir(root, second.cwd))
|
||||
expect(await readdir(projectDir(root, first.cwd))).toEqual(expect.arrayContaining([
|
||||
encodeSegment(first.id),
|
||||
encodeSegment(second.id),
|
||||
]))
|
||||
expect((await ctx.sessionPersistence.list()).map(header => header.id).sort())
|
||||
.toEqual([first.id, second.id].sort())
|
||||
})
|
||||
|
||||
it('list on an empty root returns nothing', async () => {
|
||||
expect(await ctx.sessionPersistence.list()).toEqual([])
|
||||
})
|
||||
|
||||
it('list skips empty and non-header .jsonl files (metadata-only read)', async () => {
|
||||
it('keeps the transcript in an extensible session-owned directory', async () => {
|
||||
const m = meta('owned-directory', '/project')
|
||||
await ctx.sessionPersistence.create(m)
|
||||
await ctx.sessionPersistence.append(m.id, oneTurnLog())
|
||||
const dir = sessionDir(root, m.cwd, m.id)
|
||||
await writeFile(join(dir, 'metadata.json'), '{}\n')
|
||||
await writeFile(join(projectDir(root, m.cwd), 'README'), 'project metadata\n')
|
||||
await mkdir(join(projectDir(root, m.cwd), 'reserved-session'), { recursive: true })
|
||||
|
||||
expect(await readdir(dir)).toEqual(expect.arrayContaining(['metadata.json', 'session.jsonl']))
|
||||
expect((await ctx.sessionPersistence.list()).map(header => header.id)).toContain(m.id)
|
||||
expect((await ctx.sessionPersistence.load(m.id)).events).toEqual(oneTurnLog())
|
||||
})
|
||||
|
||||
it('rejects the obsolete flat-file layout instead of ignoring stored sessions', async () => {
|
||||
const m = meta('legacy-flat', '/legacy')
|
||||
const project = projectDir(root, m.cwd)
|
||||
const path = join(project, `${encodeSegment(m.id)}.jsonl`)
|
||||
await mkdir(project, { recursive: true })
|
||||
await writeFile(path, [
|
||||
JSON.stringify(toHeaderLine(m)),
|
||||
...oneTurnLog().map(event => JSON.stringify(event)),
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/unsupported flat-file layout/)
|
||||
await expect(ctx.sessionPersistence.list()).rejects.toThrow(/unsupported flat-file layout/)
|
||||
})
|
||||
|
||||
it('rejects a compressed obsolete flat-file artifact during targeted lookup', async () => {
|
||||
const m = meta('legacy-compressed-flat', '/legacy')
|
||||
const project = projectDir(root, m.cwd)
|
||||
expect(await ctx.sessionPersistence.list()).toEqual([])
|
||||
await mkdir(project, { recursive: true })
|
||||
await writeFile(join(project, `${encodeSegment(m.id)}.jsonl.zstd`), 'legacy')
|
||||
|
||||
await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/unsupported flat-file layout/)
|
||||
})
|
||||
|
||||
it('list skips empty and non-header session logs (metadata-only read)', async () => {
|
||||
// A real session…
|
||||
await ctx.sessionPersistence.create(meta('real', '/p'))
|
||||
await ctx.sessionPersistence.append(SessionId('real'), oneTurnLog())
|
||||
// …alongside two junk files in the _no-cwd bucket: an EMPTY file (readFirstLine
|
||||
// returns undefined) and a file whose first line is not a session header
|
||||
// (parseHeaderMeta returns undefined). Both are skipped, not listed.
|
||||
const bucket = join(root, '_no-cwd')
|
||||
await mkdir(bucket, { recursive: true })
|
||||
await writeFile(join(bucket, 'empty.jsonl'), '')
|
||||
await writeFile(join(bucket, 'notheader.jsonl'), '{"type":"turn/start"}\n')
|
||||
await writeFile(join(bucket, 'badjson.jsonl'), 'not json at all\n')
|
||||
// …alongside junk session directories whose fixed transcript is empty or
|
||||
// lacks a header. Both remain unmaterialized and are skipped.
|
||||
for (const [id, content] of [
|
||||
['empty', ''],
|
||||
['notheader', '{"type":"turn/start"}\n'],
|
||||
['badjson', 'not json at all\n'],
|
||||
] as const) {
|
||||
const path = rawLogPath(root, undefined, SessionId(id))
|
||||
await mkdir(sessionDir(root, undefined, SessionId(id)), { recursive: true })
|
||||
await writeFile(path, content)
|
||||
}
|
||||
|
||||
const ids = (await ctx.sessionPersistence.list()).map(x => x.id).sort()
|
||||
expect(ids).toEqual(['real'])
|
||||
@@ -843,10 +914,10 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
it('list reads a header line longer than the 8KB read chunk', async () => {
|
||||
// A tolerated extra field makes this valid header exceed the 8192-byte read buffer, proving
|
||||
// `readFirstLine` accumulates chunks before `list()` parses it.
|
||||
const bucket = join(root, '_no-cwd')
|
||||
await mkdir(bucket, { recursive: true })
|
||||
const id = SessionId('big')
|
||||
await mkdir(sessionDir(root, undefined, id), { recursive: true })
|
||||
const bigHeader = JSON.stringify({ type: 'session', version: 0, id: 'big', createdAt: 1, delegationDepth: 0, pad: 'x'.repeat(9000) })
|
||||
await writeFile(join(bucket, 'big.jsonl'), bigHeader + '\n')
|
||||
await writeFile(rawLogPath(root, undefined, id), bigHeader + '\n')
|
||||
const ids = (await ctx.sessionPersistence.list()).map(x => x.id)
|
||||
expect(ids).toContain('big')
|
||||
})
|
||||
@@ -857,30 +928,47 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
await ctx.sessionPersistence.append(m.id, oneTurnLog())
|
||||
await rewriteHeader(rawLogPath(root, m.cwd, m.id), (header) => { header.cwd = '/elsewhere' })
|
||||
|
||||
await expect(ctx.sessionPersistence.list()).rejects.toThrow(/and cwd belong at/)
|
||||
await expect(ctx.sessionPersistence.list()).rejects.toThrow(/and cwd identify/)
|
||||
})
|
||||
|
||||
it('accepts an alternate project path only when it identifies the same physical log', async () => {
|
||||
const m = meta('physical-alias', '/stored')
|
||||
await ctx.sessionPersistence.create(m)
|
||||
await ctx.sessionPersistence.append(m.id, oneTurnLog())
|
||||
const path = rawLogPath(root, m.cwd, m.id)
|
||||
const aliasCwd = '/alias'
|
||||
await symlink(
|
||||
projectDir(root, m.cwd),
|
||||
projectDir(root, aliasCwd),
|
||||
process.platform === 'win32' ? 'junction' : 'dir',
|
||||
)
|
||||
await rewriteHeader(path, (header) => { header.cwd = aliasCwd })
|
||||
|
||||
expect((await ctx.sessionPersistence.load(m.id)).meta.cwd).toBe(aliasCwd)
|
||||
expect((await ctx.sessionPersistence.list()).map(header => header.id)).toContain(m.id)
|
||||
})
|
||||
|
||||
it('list rejects a session header whose id cannot name a storage path', async () => {
|
||||
const bucket = sessionDir(root, undefined)
|
||||
await mkdir(bucket, { recursive: true })
|
||||
await writeFile(join(bucket, 'invalid-id.jsonl'), JSON.stringify({
|
||||
const dir = join(projectDir(root, undefined), 'invalid-id')
|
||||
await mkdir(dir, { recursive: true })
|
||||
await writeFile(join(dir, 'session.jsonl'), JSON.stringify({
|
||||
type: 'session', version: 0, id: '', createdAt: 1, delegationDepth: 0,
|
||||
}) + '\n')
|
||||
|
||||
await expect(ctx.sessionPersistence.list()).rejects.toThrow(/header id cannot name a storage path/)
|
||||
})
|
||||
|
||||
it('load and list reject one id materialized in multiple cwd buckets', async () => {
|
||||
it('load and list reject one id materialized in multiple project directories', async () => {
|
||||
const id = SessionId('duplicate')
|
||||
for (const cwd of ['/a', '/b']) {
|
||||
const m = meta(id, cwd)
|
||||
await mkdir(sessionDir(root, cwd), { recursive: true })
|
||||
await mkdir(sessionDir(root, cwd, id), { recursive: true })
|
||||
const content = [JSON.stringify(toHeaderLine(m)), ...oneTurnLog().map(event => JSON.stringify(event))].join('\n') + '\n'
|
||||
await writeFile(rawLogPath(root, cwd, id), content)
|
||||
}
|
||||
|
||||
await expect(ctx.sessionPersistence.load(id)).rejects.toThrow(/appears in multiple cwd buckets/)
|
||||
await expect(ctx.sessionPersistence.list()).rejects.toThrow(/appears in multiple cwd buckets/)
|
||||
await expect(ctx.sessionPersistence.load(id)).rejects.toThrow(/appears in multiple project directories/)
|
||||
await expect(ctx.sessionPersistence.list()).rejects.toThrow(/appears in multiple project directories/)
|
||||
})
|
||||
|
||||
it('a DIFFERENT live session object reusing a disposed id gets its own init (no stale cache)', async () => {
|
||||
@@ -1003,12 +1091,12 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
await expect(backend.exists(join(blocker, 'child.jsonl'))).rejects.toThrow(/ENOTDIR/)
|
||||
})
|
||||
|
||||
it('materialization surfaces a cwd-bucket storage fault', async () => {
|
||||
it('materialization surfaces a project-directory storage fault', async () => {
|
||||
const cwd = '/x'
|
||||
const ctx2 = new Context()
|
||||
await ctx2.plugin(SessionStore)
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
|
||||
await writeFile(sessionDir(root, cwd), 'x') // bucket path is now a FILE
|
||||
await writeFile(projectDir(root, cwd), 'x') // project path is now a file
|
||||
let s!: Session
|
||||
await ctx2.plugin(Object.assign((inner: Context) => {
|
||||
s = inner.sessions.create(SessionId('exists-fault'), { meta: { cwd } })
|
||||
@@ -1056,14 +1144,14 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
})
|
||||
|
||||
|
||||
it('createCore rejects an id already on disk under a DIFFERENT cwd bucket', async () => {
|
||||
it('createCore rejects an id already on disk under a different project directory', async () => {
|
||||
// Persist the id under cwd A.
|
||||
const a = meta('dup-id', '/projA')
|
||||
await ctx.sessionPersistence.create(a)
|
||||
await ctx.sessionPersistence.append(a.id, oneTurnLog())
|
||||
// A fresh backend creating the SAME id under cwd B must still refuse: load
|
||||
// identifies by id across all buckets, so a second log would make resume
|
||||
// nondeterministic. create scans every bucket, not just meta.cwd's.
|
||||
// identifies by id across all projects, so a second log would make resume
|
||||
// nondeterministic. create scans every project, not just meta.cwd's.
|
||||
const ctx2 = new Context()
|
||||
await ctx2.plugin(SessionStore)
|
||||
await ctx2.plugin(SessionPersistenceJsonl, { root, compression: 'none' })
|
||||
|
||||
@@ -151,6 +151,15 @@ describe('Windows durable namespace helpers', () => {
|
||||
expect(existsSync(raced)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps staging names valid for a maximum-length target component', async () => {
|
||||
const { ensureDurableDirectoryWin32 } = await importWithFilesystemMove()
|
||||
const root = await tempRoot()
|
||||
const target = join(root, 'x'.repeat(255))
|
||||
|
||||
await ensureDurableDirectoryWin32(target)
|
||||
expect(existsSync(target)).toBe(true)
|
||||
})
|
||||
|
||||
it('surfaces directory publication failures other than an existing-target race', async () => {
|
||||
const { ensureDurableDirectoryWin32 } = await importWithError(ERROR_ACCESS_DENIED)
|
||||
const root = await tempRoot()
|
||||
|
||||
@@ -391,15 +391,21 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => {
|
||||
|
||||
it('skips empty, incomplete, and non-header compressed artifacts while rejecting malformed header frames', async () => {
|
||||
const root = await freshRoot()
|
||||
const bucket = sessionDir(root, undefined)
|
||||
await mkdir(bucket, { recursive: true })
|
||||
await writeFile(join(bucket, 'empty.jsonl.zstd'), '')
|
||||
await writeFile(join(bucket, 'partial.jsonl.zstd'), MAGIC)
|
||||
await writeFile(join(bucket, 'not-header.jsonl.zstd'), await compressZstdFrame('{"type":"turn/start"}\n'))
|
||||
for (const [id, content] of [
|
||||
['empty', Buffer.alloc(0)],
|
||||
['partial', MAGIC],
|
||||
['not-header', await compressZstdFrame('{"type":"turn/start"}\n')],
|
||||
] as const) {
|
||||
const sessionId = SessionId(id)
|
||||
await mkdir(sessionDir(root, undefined, sessionId), { recursive: true })
|
||||
await writeFile(logPath(root, undefined, sessionId, 'zstd'), content)
|
||||
}
|
||||
const ctx = await mount(root)
|
||||
expect(await ctx.sessionPersistence.list()).toEqual([])
|
||||
|
||||
await writeFile(join(bucket, 'two-lines.jsonl.zstd'), await compressZstdFrame([
|
||||
const twoLinesId = SessionId('two-lines')
|
||||
await mkdir(sessionDir(root, undefined, twoLinesId), { recursive: true })
|
||||
await writeFile(logPath(root, undefined, twoLinesId, 'zstd'), await compressZstdFrame([
|
||||
JSON.stringify(toHeaderLine(meta('two-lines'))),
|
||||
JSON.stringify({ type: 'turn/start' }),
|
||||
'',
|
||||
@@ -411,8 +417,9 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => {
|
||||
|
||||
it('rejects missing, empty, and checksum-corrupt header frames on targeted reads', async () => {
|
||||
const root = await freshRoot()
|
||||
const bucket = sessionDir(root, undefined)
|
||||
await mkdir(bucket, { recursive: true })
|
||||
for (const id of ['partial-only', 'empty-header', 'bad-checksum']) {
|
||||
await mkdir(sessionDir(root, undefined, SessionId(id)), { recursive: true })
|
||||
}
|
||||
await writeFile(logPath(root, undefined, SessionId('partial-only'), 'zstd'), MAGIC)
|
||||
await writeFile(logPath(root, undefined, SessionId('empty-header'), 'zstd'), await compressZstdFrame(''))
|
||||
const corruptHeader = Buffer.from(await compressZstdFrame(`${JSON.stringify(toHeaderLine(meta('bad-checksum')))}\n`))
|
||||
@@ -453,7 +460,7 @@ describe('SessionPersistenceJsonl: encoding selection', () => {
|
||||
expect(await ctx.sessionPersistence.list()).toEqual([])
|
||||
|
||||
const loadHeader = meta('late-raw-load', '/late')
|
||||
await mkdir(sessionDir(root, loadHeader.cwd), { recursive: true })
|
||||
await mkdir(sessionDir(root, loadHeader.cwd, loadHeader.id), { recursive: true })
|
||||
await writeFile(logPath(root, loadHeader.cwd, loadHeader.id, 'none'), [
|
||||
JSON.stringify(toHeaderLine(loadHeader)),
|
||||
...oneTurnLog().map(e => JSON.stringify(e)),
|
||||
@@ -471,13 +478,13 @@ describe('SessionPersistenceJsonl: encoding selection', () => {
|
||||
await ctx.sessionPersistence.list()
|
||||
const header = meta('late-raw-materialize', '/late')
|
||||
await ctx.sessionPersistence.create(header)
|
||||
await mkdir(sessionDir(root, header.cwd), { recursive: true })
|
||||
await mkdir(sessionDir(root, header.cwd, header.id), { recursive: true })
|
||||
await writeFile(logPath(root, header.cwd, header.id, 'none'), [
|
||||
JSON.stringify(toHeaderLine(header)),
|
||||
...oneTurnLog().map(e => JSON.stringify(e)),
|
||||
'',
|
||||
].join('\n'))
|
||||
await expect(ctx.sessionPersistence.append(header.id, oneTurnLog())).rejects.toThrow(/uses \.jsonl/)
|
||||
expect((await readdir(sessionDir(root, header.cwd))).some(name => name.endsWith('.jsonl.zstd'))).toBe(false)
|
||||
expect((await readdir(sessionDir(root, header.cwd, header.id))).some(name => name.endsWith('.jsonl.zstd'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -683,7 +683,7 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise<
|
||||
const fix = await makeFixture()
|
||||
const { ctx, fiber } = await freshCtx(fix)
|
||||
try {
|
||||
// Ownerless state created WITHOUT a cwd (the no-cwd bucket).
|
||||
// Ownerless state created WITHOUT a cwd (the `_no-cwd` project directory).
|
||||
await ctx.sessionPersistence.create(meta('no-cwd-state'))
|
||||
// A live session reusing the id but WITH cwd WORK is a cwd mismatch
|
||||
// (undefined vs WORK) and must be rejected.
|
||||
|
||||
@@ -102,7 +102,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive
|
||||
await run.dispose()
|
||||
|
||||
expect(result.stopReason).toBe('completed')
|
||||
// Verify the WORLD: the child process actually wrote the file in its cwd.
|
||||
// Assert the filesystem effect independently of the model response.
|
||||
const proof = await readFile(join(workdir, 'proof.txt'), 'utf8')
|
||||
expect(proof).toContain('ACP_CHILD_WAS_HERE')
|
||||
}, 180_000)
|
||||
|
||||
@@ -6,14 +6,7 @@ import type { Context } from 'cordis'
|
||||
import { spawnHarness, waitForIdle } from './harness.ts'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
/**
|
||||
* With-key smoke for the in-process spawn backend: a REAL parent agent delegates
|
||||
* to a REAL child (via the `subagent` tool → spawn backend) that uses the REAL
|
||||
* bash tool to write a file, and we verify the WORLD (the file on disk) — not
|
||||
* the agent's self-report. This is the "green units, broken product" guard:
|
||||
* mocks prove the plumbing, only a real model proves a parent can actually drive
|
||||
* a child to do real work. Key-gated (self-skips without DEEPSEEK_API_KEY).
|
||||
*/
|
||||
/** Key-gated smoke for a real parent delegating filesystem work to a real child. */
|
||||
|
||||
let ctx: Context | undefined
|
||||
let workdir: string | undefined
|
||||
@@ -37,7 +30,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('spawn backend with-key smoke', (
|
||||
+ 'After the subagent finishes, tell me it is done.' }])
|
||||
await waitForIdle(ctx, parent)
|
||||
|
||||
// Verify the WORLD: the child actually wrote the file.
|
||||
// Assert the filesystem effect independently of the model response.
|
||||
const proof = await readFile(join(workdir, 'proof.txt'), 'utf8')
|
||||
expect(proof).toContain('SUBAGENT_WAS_HERE')
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
/**
|
||||
* Subagent seam vocabulary: the request/result/capability types a
|
||||
* {@link SubagentProvider} consumes and produces. No runtime code — types
|
||||
* only, per the package convention.
|
||||
* Request, result, and capability contracts for {@link SubagentProvider}.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-subagent/types
|
||||
*/
|
||||
@@ -17,7 +15,7 @@ export type SubagentRunId = Branded<'SubagentRunId'>
|
||||
|
||||
/**
|
||||
* Brand a string as a {@link SubagentRunId}.
|
||||
* @param id - the raw id string (the service mints UUIDs; tests may pass fixtures).
|
||||
* @param id - the raw run id.
|
||||
* @returns the same string, branded.
|
||||
*/
|
||||
export function SubagentRunId(id: string): SubagentRunId {
|
||||
@@ -30,16 +28,13 @@ export function SubagentRunId(id: string): SubagentRunId {
|
||||
* is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent
|
||||
* degradation" rule). These static flags cover features needed before a run exists; runtime
|
||||
* capabilities such as steering and resume are optional {@link SubagentRun} methods whose presence
|
||||
* is the capability.
|
||||
* is the capability. Each flag corresponds one-to-one to a {@link SubagentStartRequest} option:
|
||||
* `depthLimit` to `maxDepth`; the other names match.
|
||||
*/
|
||||
export interface SubagentCapabilities {
|
||||
/** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */
|
||||
readonly outputSchema: boolean
|
||||
/** Enforce {@link SubagentStartRequest.maxDepth} (recursion cap). */
|
||||
readonly depthLimit: boolean
|
||||
/** Enforce {@link SubagentStartRequest.toolFilter} (child tool scoping). */
|
||||
readonly toolFilter: boolean
|
||||
/** Honor {@link SubagentStartRequest.persona} (a per-child persona). */
|
||||
readonly persona: boolean
|
||||
}
|
||||
|
||||
@@ -50,16 +45,12 @@ export interface SubagentCapabilities {
|
||||
* passes it to {@link SubagentProvider.start}.
|
||||
*/
|
||||
export interface SubagentStartRequest {
|
||||
/** The task/prompt for the child agent (a user message in the child session). */
|
||||
/** Content delivered as the child's user message. */
|
||||
readonly prompt: ContentBlock[]
|
||||
/**
|
||||
* The spawning ("parent") agent — the one whose tool call started this
|
||||
* subagent. REQUIRED: in-process backends read `parent.session.header` for
|
||||
* the working directory, the `parentSession` lineage to stamp on the child,
|
||||
* and the parent's delegation depth. The out-of-process backend (ACP) reads
|
||||
* exactly one field — the session header's cwd, the child's workspace when
|
||||
* no deployment `cwd` override is configured; nothing else crosses the
|
||||
* process boundary.
|
||||
* The spawning agent. In-process providers derive workspace, lineage, and
|
||||
* delegation depth from its durable session state. ACP reads only its cwd,
|
||||
* and only when no deployment `cwd` override is configured.
|
||||
*/
|
||||
readonly parent: Agent
|
||||
/**
|
||||
@@ -70,7 +61,6 @@ export interface SubagentStartRequest {
|
||||
* afterward.
|
||||
*/
|
||||
readonly signal: AbortSignal
|
||||
/** Per-child agent options (model and plugin-defined extension fields). */
|
||||
readonly agentOptions?: AgentOptions
|
||||
/**
|
||||
* Object-rooted JSON Schema within `assertObjectJsonSchema`'s enforced subset. Start rejects
|
||||
@@ -112,9 +102,9 @@ export interface SubagentStartRequest {
|
||||
export interface SubagentStopReasonMap {
|
||||
/** The child finished its turn normally. */
|
||||
completed: 'completed'
|
||||
/** The run was cancelled by its request signal or by disposal. */
|
||||
/** Cancelled through the request signal or disposal. */
|
||||
aborted: 'aborted'
|
||||
/** The child failed (model error, transport error). */
|
||||
/** Model or transport failure. */
|
||||
error: 'error'
|
||||
/** The child hit its token ceiling before finishing. */
|
||||
'max-tokens': 'max-tokens'
|
||||
@@ -170,9 +160,8 @@ export interface SubagentRun {
|
||||
*/
|
||||
readonly result: Promise<SubagentResult>
|
||||
/**
|
||||
* Cancel remaining work, reach child quiescence, and release the run's
|
||||
* resources (in-process: dispose the owned agent and remove its session;
|
||||
* ACP: kill and reap the subprocess). Idempotent.
|
||||
* Cancel remaining work, reach child quiescence, and release resources.
|
||||
* Idempotent.
|
||||
*/
|
||||
dispose(): Promise<void>
|
||||
/**
|
||||
@@ -188,12 +177,9 @@ export interface SubagentRun {
|
||||
}
|
||||
|
||||
/**
|
||||
* A subagent backend: one transport for running a child agent (in-process
|
||||
* spawn/fork, ACP to another process, …). Implementations register under a
|
||||
* unique name via {@link SubagentService.registerProvider}; multiple providers
|
||||
* coexist in one context (unlike the single-implementation bash seam). The
|
||||
* Providers are trusted same-process implementations; callers treat their
|
||||
* descriptors and returned values as borrowed immutable data.
|
||||
* One registered transport for running child agents. Providers are trusted
|
||||
* same-process implementations; callers treat descriptors and returned values
|
||||
* as borrowed immutable data.
|
||||
*/
|
||||
export interface SubagentProvider {
|
||||
/** Unique registry name (e.g. `spawn`, `fork`, `acp`). */
|
||||
|
||||
@@ -535,39 +535,29 @@ function latestOpenTurn(content: string): number | undefined {
|
||||
* `parentSession`) leads, then each subagent child by ascending `createdAt`.
|
||||
*
|
||||
* Snapshot configs select the JSONL backend's raw mode, which lays sessions
|
||||
* out as `<root>/<cwd-bucket>/<encoded-id>.jsonl` (one bucket per cwd). A
|
||||
* parent and its same-cwd in-process child land in the SAME bucket, so
|
||||
* collecting all files across all buckets catches both. Returns `[]` if no log
|
||||
* was produced (a no-session scenario).
|
||||
* out as `<root>/<project>/<session-id>/session.jsonl`. Recursive collection
|
||||
* catches the primary and every child session. Returns `[]` if no log was
|
||||
* produced (a no-session scenario).
|
||||
*/
|
||||
async function harvestSessionLogs(root: string): Promise<HarvestedLog[]> {
|
||||
let cwdDirs: string[]
|
||||
let files: string[]
|
||||
try {
|
||||
cwdDirs = await readdir(root)
|
||||
files = await readdir(root, { recursive: true })
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
const logs: HarvestedLog[] = []
|
||||
for (const dir of cwdDirs) {
|
||||
const sub = join(root, dir)
|
||||
let files: string[]
|
||||
try {
|
||||
files = await readdir(sub)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
for (const f of files) {
|
||||
if (!f.endsWith('.jsonl')) continue
|
||||
const content = await readFile(join(sub, f), 'utf8')
|
||||
const firstLine = content.split('\n').find(line => line.trim().length > 0) ?? '{}'
|
||||
const header = JSON.parse(firstLine) as { id?: unknown; createdAt?: unknown; parentSession?: unknown }
|
||||
logs.push({
|
||||
id: typeof header.id === 'string' ? header.id : '',
|
||||
createdAt: typeof header.createdAt === 'number' ? header.createdAt : 0,
|
||||
...typeof header.parentSession === 'string' ? { parentSession: header.parentSession } : {},
|
||||
content,
|
||||
})
|
||||
}
|
||||
for (const file of files) {
|
||||
if (basename(file) !== 'session.jsonl') continue
|
||||
const content = await readFile(join(root, file), 'utf8')
|
||||
const firstLine = content.split('\n').find(line => line.trim().length > 0) ?? '{}'
|
||||
const header = JSON.parse(firstLine) as { id?: unknown; createdAt?: unknown; parentSession?: unknown }
|
||||
logs.push({
|
||||
id: typeof header.id === 'string' ? header.id : '',
|
||||
createdAt: typeof header.createdAt === 'number' ? header.createdAt : 0,
|
||||
...typeof header.parentSession === 'string' ? { parentSession: header.parentSession } : {},
|
||||
content,
|
||||
})
|
||||
}
|
||||
// Primary (no parentSession) first, then children by ascending createdAt. A
|
||||
// scenario has exactly one top-level session. In the synchronous cut sibling
|
||||
|
||||
@@ -23,9 +23,9 @@ import { dirname, join } from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { createInterface } from 'node:readline'
|
||||
|
||||
/** One scripted session log: a file path under the sessions root plus its JSONL lines. */
|
||||
/** One scripted session log: a transcript path under the sessions root plus its JSONL lines. */
|
||||
interface ScriptedLog {
|
||||
/** Path relative to `$DSH_SNAPSHOT_SESSIONS_ROOT`, e.g. `bucket/a.jsonl` (an empty dir segment is invalid). */
|
||||
/** Path relative to `$DSH_SNAPSHOT_SESSIONS_ROOT`, e.g. `project/session/session.jsonl`. */
|
||||
file: string
|
||||
/**
|
||||
* The JSONL records. String templates `{{CWD}}` and `{{SID}}` are replaced
|
||||
@@ -61,7 +61,7 @@ interface Behavior {
|
||||
logs?: ScriptedLog[]
|
||||
/** Leave a stray FILE directly under the sessions root (harvest must skip it). */
|
||||
strayRootFile?: boolean
|
||||
/** Leave a stray non-`.jsonl` file inside a bucket (harvest must skip it). */
|
||||
/** Leave a stray non-transcript file inside a project directory (harvest must skip it). */
|
||||
strayBucketFile?: boolean
|
||||
/** Delete the sessions root entirely (harvest must yield no logs). */
|
||||
deleteSessionsRoot?: boolean
|
||||
@@ -125,7 +125,7 @@ function instantiate(value: unknown): unknown {
|
||||
|
||||
/** Persist an open turn so cancellation tests wait on agent state, not presentation output. */
|
||||
function persistParkedTurnStart(): void {
|
||||
parkedTurnLog = join(sessionsRoot, 'ready', 'open.jsonl')
|
||||
parkedTurnLog = join(sessionsRoot, 'ready', sessionId, 'session.jsonl')
|
||||
mkdirSync(dirname(parkedTurnLog), { recursive: true })
|
||||
writeFileSync(parkedTurnLog, [
|
||||
JSON.stringify({ type: 'session', version: 0, id: sessionId, createdAt: 1, cwd: sessionCwd, delegationDepth: 0 }),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"prompt": "respond",
|
||||
"logs": [
|
||||
{ "file": "b/parent.jsonl", "lines": [
|
||||
{ "file": "b/parent/session.jsonl", "lines": [
|
||||
{ "type": "session", "id": "{{SID}}", "createdAt": 700, "cwd": "{{CWD}}", "delegationDepth": 0 },
|
||||
{ "type": "request/header", "seq": 0, "time": 3, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } }
|
||||
]},
|
||||
{ "file": "b/child.jsonl", "lines": [
|
||||
{ "file": "b/child/session.jsonl", "lines": [
|
||||
{ "type": "session", "id": "abababab-cdcd-4efe-8ada-badabadabada", "createdAt": 800, "cwd": "{{CWD}}", "parentSession": "{{SID}}", "delegationDepth": 1 },
|
||||
{ "type": "request/header", "seq": 0, "time": 2, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } }
|
||||
]}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"prompt": "respond",
|
||||
"logs": [{
|
||||
"file": "b/main.jsonl",
|
||||
"file": "b/main/session.jsonl",
|
||||
"lines": [
|
||||
{ "type": "session", "id": "{{SID}}", "createdAt": 600, "cwd": "{{CWD}}", "delegationDepth": 0 },
|
||||
{ "type": "request/header", "seq": 0, "time": 4, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"prompt": "error",
|
||||
"logs": [{
|
||||
"file": "b/main.jsonl",
|
||||
"file": "b/main/session.jsonl",
|
||||
"lines": [
|
||||
{ "type": "session", "id": "{{SID}}", "createdAt": 500, "cwd": "{{CWD}}", "delegationDepth": 0 },
|
||||
{ "type": "turn/end", "seq": 1, "time": 9, "data": { "error": "model exploded" } }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"prompt": "error",
|
||||
"logs": [{
|
||||
"file": "b/main.jsonl",
|
||||
"file": "b/main/session.jsonl",
|
||||
"lines": [
|
||||
{ "type": "session", "id": "{{SID}}", "createdAt": 400, "cwd": "{{CWD}}", "delegationDepth": 0 },
|
||||
{ "type": "hook/result", "seq": 1, "time": 8, "data": { "decision": "block", "durationMs": 37 } }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"prompt": "respond",
|
||||
"logs": [{
|
||||
"file": "b/main.jsonl",
|
||||
"file": "b/main/session.jsonl",
|
||||
"lines": [
|
||||
{ "type": "session", "id": "{{SID}}", "createdAt": 100, "cwd": "{{CWD}}", "delegationDepth": 0 },
|
||||
{ "type": "request/header", "seq": 0, "time": 100, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } },
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
"prompt": "respond",
|
||||
"echoWorkspace": true,
|
||||
"logs": [
|
||||
{ "file": "b/parent.jsonl", "lines": [
|
||||
{ "file": "b/parent/session.jsonl", "lines": [
|
||||
{ "type": "session", "id": "{{SID}}", "createdAt": 200, "cwd": "{{CWD}}", "delegationDepth": 0 },
|
||||
{ "type": "request/header", "seq": 0, "time": 5, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } },
|
||||
{ "type": "assistant/chunk", "seq": 1, "time": 5, "data": { "turn": 1, "step": 1, "chunk": { "type": "text-delta", "index": 0, "text": "hi" } } }
|
||||
]},
|
||||
{ "file": "b/child.jsonl", "lines": [
|
||||
{ "file": "b/child/session.jsonl", "lines": [
|
||||
{ "type": "session", "id": "eeeeeeee-1111-4222-8333-444444444444", "createdAt": 300, "cwd": "{{CWD}}", "parentSession": "{{SID}}", "delegationDepth": 1 },
|
||||
{ "type": "request/header", "seq": 0, "time": 6, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } }
|
||||
]}
|
||||
|
||||
@@ -380,7 +380,7 @@ describe('runScenario', () => {
|
||||
const { fixtureFile } = await scenario({
|
||||
permissionProbe: true,
|
||||
logs: [{
|
||||
file: 'bucket/main.jsonl',
|
||||
file: 'project/main/session.jsonl',
|
||||
lines: [
|
||||
{ type: 'session', id: '{{SID}}', createdAt: 42, cwd: '{{CWD}}' },
|
||||
{ type: 'turn/start', seq: 1, time: 9, data: { turn: 1 } },
|
||||
@@ -546,7 +546,7 @@ describe('runScenario', () => {
|
||||
prompt: 'hang-until-cancel',
|
||||
persistLogsOnCancel: true,
|
||||
logs: [{
|
||||
file: 'bucket/session.jsonl',
|
||||
file: 'project/main/session.jsonl',
|
||||
lines: [
|
||||
{ type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
|
||||
{ type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'aborted' } } },
|
||||
@@ -565,7 +565,7 @@ describe('runScenario', () => {
|
||||
prompt: 'hang-until-cancel',
|
||||
persistLogsOnCancel: true,
|
||||
logs: [{
|
||||
file: 'bucket/session.jsonl',
|
||||
file: 'project/main/session.jsonl',
|
||||
lines: [
|
||||
{ type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 3 } },
|
||||
@@ -596,7 +596,7 @@ describe('runScenario', () => {
|
||||
prompt: 'hang-until-cancel',
|
||||
persistLogsOnCancel: true,
|
||||
logs: [{
|
||||
file: 'bucket/session.jsonl',
|
||||
file: 'project/main/session.jsonl',
|
||||
lines: [
|
||||
{ type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
@@ -618,7 +618,7 @@ describe('runScenario', () => {
|
||||
prompt: 'hang-until-cancel',
|
||||
persistLogsOnCancel: true,
|
||||
logs: [{
|
||||
file: 'bucket/session.jsonl',
|
||||
file: 'project/main/session.jsonl',
|
||||
lines: [
|
||||
{ type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
@@ -642,7 +642,7 @@ describe('runScenario', () => {
|
||||
prompt: 'hang-until-cancel',
|
||||
persistLogsOnCancel: true,
|
||||
logs: [{
|
||||
file: 'bucket/session.jsonl',
|
||||
file: 'project/main/session.jsonl',
|
||||
lines: [
|
||||
{ type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: turn === undefined ? {} : { turn } },
|
||||
@@ -673,7 +673,7 @@ describe('runScenario', () => {
|
||||
prompt: 'hang-until-cancel',
|
||||
persistLogsOnCancel: true,
|
||||
logs: [{
|
||||
file: 'bucket/session.jsonl',
|
||||
file: 'project/main/session.jsonl',
|
||||
lines: [
|
||||
{ type: 'session', version: 0, id: '{{SID}}', createdAt: 1, delegationDepth: 0 },
|
||||
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } },
|
||||
@@ -823,11 +823,11 @@ describe('runScenario', () => {
|
||||
// File names chosen so readdir feeds the sort children-first AND
|
||||
// parent-in-the-middle: the comparator then sees a parent on both
|
||||
// sides of a pair, plus the same-createdAt (localeCompare) tiebreak.
|
||||
{ file: 'b1/aa-child-c.jsonl', lines: [{ type: 'session', id: 'cccccccc-0000-4000-8000-000000000000', createdAt: 500, parentSession: '{{SID}}' }] },
|
||||
{ file: 'b1/bb-parent.jsonl', lines: [{ type: 'session', id: '{{SID}}', createdAt: 900 }] },
|
||||
{ file: 'b1/cc-child-a.jsonl', lines: [{ type: 'session', id: 'aaaaaaaa-0000-4000-8000-000000000000', createdAt: 500, parentSession: '{{SID}}' }] },
|
||||
{ file: 'b1/aa-child-c/session.jsonl', lines: [{ type: 'session', id: 'cccccccc-0000-4000-8000-000000000000', createdAt: 500, parentSession: '{{SID}}' }] },
|
||||
{ file: 'b1/bb-parent/session.jsonl', lines: [{ type: 'session', id: '{{SID}}', createdAt: 900 }] },
|
||||
{ file: 'b1/cc-child-a/session.jsonl', lines: [{ type: 'session', id: 'aaaaaaaa-0000-4000-8000-000000000000', createdAt: 500, parentSession: '{{SID}}' }] },
|
||||
// Missing id/createdAt fall back to ''/0; earliest child by createdAt.
|
||||
{ file: 'b2/orphan-fields.jsonl', lines: [{ type: 'session', parentSession: '{{SID}}' }] },
|
||||
{ file: 'b2/orphan/session.jsonl', lines: [{ type: 'session', parentSession: '{{SID}}' }] },
|
||||
],
|
||||
})
|
||||
const result = await runScenario(
|
||||
@@ -844,7 +844,7 @@ describe('runScenario', () => {
|
||||
})
|
||||
|
||||
it('treats an empty log file as a header-less primary with default fields', { timeout: 20_000 }, async () => {
|
||||
const { fixtureFile } = await scenario({ logs: [{ file: 'b/empty.jsonl', lines: [] }] })
|
||||
const { fixtureFile } = await scenario({ logs: [{ file: 'b/empty/session.jsonl', lines: [] }] })
|
||||
const result = await runScenario(
|
||||
{ steps: boot },
|
||||
{ agent: AGENT, mode: 'replay', fixtureFile },
|
||||
|
||||
@@ -3484,8 +3484,7 @@ describe('terminal mounting', () => {
|
||||
await tick()
|
||||
expect(result.terminal.output.length).toBe(beforeSameScheme)
|
||||
|
||||
// Simulate the terminal responding with a light color scheme report
|
||||
// (ESC [?997;2n = light, ESC [?997;1n = dark).
|
||||
// ESC [?997;2n reports light; ESC [?997;1n reports dark.
|
||||
result.terminal.send('\x1b[?997;2n')
|
||||
await tick()
|
||||
await tick()
|
||||
@@ -3497,11 +3496,9 @@ describe('terminal mounting', () => {
|
||||
// uses ANSI 90 for the same header text.
|
||||
expect(result.terminal.output).toContain('\x1b[90mdeepseek-v4-flash')
|
||||
|
||||
// Switch back to dark scheme.
|
||||
result.terminal.send('\x1b[?997;1n')
|
||||
await tick()
|
||||
await tick()
|
||||
// After switching back, a new write uses SGR 2 for the header detail.
|
||||
expect(result.terminal.output).toContain('\x1b[2mdeepseek-v4-flash')
|
||||
await dispose(result)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user