Merge branch 'codex/tool-json-schema-dsl' into codex/canonical-tool-output
This commit is contained in:
31
packages/host/runtime/README.md
Normal file
31
packages/host/runtime/README.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# @deepseek-ai/dsh-host-runtime
|
||||
|
||||
Host runtime assembly for `dsc`: `bootHost` composes the core plugin spine (LLM service + DeepSeek adapter, sessions with JSONL persistence, system prompt, tools, agents, agent loop, local bash), `createApiProxy` implements the [`dsh-host-apiproxy`](../apiproxy/README.md) contract over that composition, and `startHost` is the one-step shell seam returning `{ api, handler, defaults, ctx, dispose }`.
|
||||
|
||||
Which plugins mount and with what defaults is decided only here — shells must not `ctx.plugin` to alter the assembly. `RunningHost.ctx` is a formal seam with exactly two sanctioned uses: mounting protocol front-door plugins (e.g. a future `dsh acp`) and headless session-event subscription; consuming clients must not bypass `api` through it.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Key | Default | Contract |
|
||||
|---|---:|---|
|
||||
| `persistenceRoot` | (required) | Root directory for JSONL session persistence. |
|
||||
| `provider` | `'deepseek'` | Default provider route injected as agentOptions on create/resume and reported by `host.describe`. |
|
||||
| `model` | `'deepseek-v4-flash'` | Default model id, same single source as `provider`. |
|
||||
|
||||
## ApiProxy implementation notes
|
||||
|
||||
Unary methods take the narrow `RpcRequest<P>` and echo `request.rpcId`; a prompt's rpcId rides `MessageSource` into the `user/message` event so clients can promote optimistic echoes. `history`/`prompt` on a cold session implicitly resume it, deduplicating concurrent calls through an in-flight table; `history` paginates backwards on message boundaries (never mid-message). The mux stream replays a `session/subscribed` baseline per attached session on open; the host stream carries session lifecycle, running flips, and `agent/error` as the only outlet for live failures with no turn position.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the model-facing plugins bootHost mounts and the provider/model defaults injected into created and resumed agents.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the mounted model-facing plugins own their request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **`respond` is a stub** — it always returns `not-pending`; the approval/question pending registry (stable-rpcId mint on accept, baseline replay on stream reopen, wire answerer) is the next host-side step.
|
||||
- **`session.list` covers live sessions only** — cold sessions in the persistence directory are not yet merged into the listing; `host.describe.version` is a placeholder rather than the `apps/cli` package version.
|
||||
- **The assembly is fixed** — per-deployment plugin selection (user profile, log sinks, alternative persistence) has a documented home here but no configuration surface yet.
|
||||
82
packages/host/runtime/package.json
Normal file
82
packages/host/runtime/package.json
Normal file
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-host-runtime",
|
||||
"description": "Host runtime assembly for dsh: bootHost composes the core spine, createApiProxy implements the contract, startHost is the one-step shell seam",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@cordisjs/plugin-timer": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-i18n": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
|
||||
"@deepseek-ai/dsh-compact-basic": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-fs-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-spill-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-spill-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-fork": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-token-meter": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-fs": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-fs-search": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-todo": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^"
|
||||
}
|
||||
}
|
||||
429
packages/host/runtime/src/api-proxy.ts
Normal file
429
packages/host/runtime/src/api-proxy.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
/**
|
||||
* Host-side ApiProxy implementation (minimal-first —
|
||||
* describe/list/create/history/prompt/cancel and both streams are real,
|
||||
* respond is a stub). Signature discipline: unary takes the narrow
|
||||
* RpcRequest<P> and echoes request.rpcId on the RpcResponse<T>.
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import type { JsonValue, Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
|
||||
import type { ApiProxy, HistoryEntry, HostFrame, MuxFrame, SessionSummary, ToolEventView } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
|
||||
/** Page size when history is called without maxMessages. */
|
||||
const DEFAULT_MAX_MESSAGES = 50
|
||||
|
||||
/** Surface message event types (the pagination counting unit). */
|
||||
const MESSAGE_TYPES = new Set(['user/message', 'assistant/message', 'steering/message'])
|
||||
|
||||
/**
|
||||
* Message-boundary pagination: count maxMessages surface messages backwards from
|
||||
* the window tail; the cut is the starting seq of the oldest message group
|
||||
* (chunks group via sourceEventSeqs — never cut mid-message). The tail page
|
||||
* naturally includes the in-progress partial.
|
||||
*/
|
||||
function paginate(
|
||||
events: readonly SessionEvent[],
|
||||
beforeSeq: number | undefined,
|
||||
maxMessages: number,
|
||||
): { events: SessionEvent[]; hasMore: boolean } {
|
||||
const window = beforeSeq === undefined ? [...events] : events.filter(event => event.seq < beforeSeq)
|
||||
let count = 0
|
||||
let cut = 0
|
||||
for (let i = window.length - 1; i >= 0; i--) {
|
||||
const event = window[i] as SessionEvent
|
||||
if (!MESSAGE_TYPES.has(event.type)) continue
|
||||
count++
|
||||
const sources = (event as { sourceEventSeqs?: number[] }).sourceEventSeqs
|
||||
const groupStart = sources !== undefined && sources.length > 0 ? Math.min(event.seq, ...sources) : event.seq
|
||||
if (count >= maxMessages) {
|
||||
cut = groupStart
|
||||
break
|
||||
}
|
||||
}
|
||||
const page = window.filter(event => event.seq >= cut)
|
||||
return { events: page, hasMore: cut > 0 }
|
||||
}
|
||||
|
||||
/** Wrap an ok result echoing the request's rpcId. */
|
||||
function ok<T>(request: RpcRequest<unknown>, value: T): RpcResponse<T> {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value } }
|
||||
}
|
||||
|
||||
/** Wrap an error result echoing the request's rpcId. */
|
||||
function err<T>(request: RpcRequest<unknown>, error: RpcError): RpcResponse<T> {
|
||||
return { rpcId: request.rpcId, result: { ok: false, error } }
|
||||
}
|
||||
|
||||
/** Simple async queue: core callbacks push, the AsyncIterable pulls; abort/return cleans up. */
|
||||
class FrameQueue<F> {
|
||||
private buffer: F[] = []
|
||||
private waiter: (() => void) | undefined
|
||||
private done = false
|
||||
|
||||
push(item: F): void {
|
||||
if (this.done) return
|
||||
this.buffer.push(item)
|
||||
this.waiter?.()
|
||||
}
|
||||
|
||||
end(): void {
|
||||
this.done = true
|
||||
this.waiter?.()
|
||||
}
|
||||
|
||||
async *iterate(signal: AbortSignal, cleanup: () => void): AsyncGenerator<F> {
|
||||
const onAbort = (): void => { this.end() }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
try {
|
||||
while (true) {
|
||||
while (this.buffer.length > 0) yield this.buffer.shift() as F
|
||||
if (this.done || signal.aborted) return
|
||||
await new Promise<void>((resolve) => { this.waiter = resolve })
|
||||
this.waiter = undefined
|
||||
}
|
||||
} finally {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
cleanup()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side frame mint: pure pushes get a fresh rpcId per frame (stable ids
|
||||
* for answerable frames belong to the approval/question registry, absent in
|
||||
* this minimal version).
|
||||
*/
|
||||
function frame<F>(payload: F): RpcRequest<F> {
|
||||
return { rpcId: RpcId(randomUUID()), payload }
|
||||
}
|
||||
|
||||
/** SessionSummary projection for attached (in-memory) sessions. */
|
||||
function summarize(session: Session, running: boolean): SessionSummary {
|
||||
return {
|
||||
sessionId: session.id,
|
||||
updatedAt: session.events.at(-1)?.time ?? session.header.createdAt,
|
||||
running,
|
||||
...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
|
||||
...session.header.cwd === undefined ? {} : { cwd: session.header.cwd },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SessionSummary projection for cold (persisted, unattached) sessions.
|
||||
* updatedAt is the log file's mtime; backends without a per-session file
|
||||
* (locate() undefined) fall back to the header's createdAt.
|
||||
*/
|
||||
async function summarizeCold(persistence: SessionPersistence, meta: SessionHeader): Promise<SessionSummary> {
|
||||
let updatedAt = meta.createdAt
|
||||
const location = persistence.locate(meta)
|
||||
if (location !== undefined) {
|
||||
try {
|
||||
updatedAt = (await stat(location.path)).mtimeMs
|
||||
} catch {
|
||||
// The log vanished between list() and stat() (concurrent cleanup); createdAt stands in.
|
||||
}
|
||||
}
|
||||
return {
|
||||
sessionId: meta.id,
|
||||
updatedAt,
|
||||
running: false,
|
||||
...meta.parentSession === undefined ? {} : { parentSessionId: meta.parentSession },
|
||||
/* v8 ignore next -- the empty arm needs a cwd-less meta, but list()
|
||||
filters those out (legacy logs are not served); the conditional mirrors
|
||||
summarize() shape. */
|
||||
...meta.cwd === undefined ? {} : { cwd: meta.cwd },
|
||||
}
|
||||
}
|
||||
|
||||
/** Host-level default agent routing (same shape as bootHost's HostDefaults; avoids an impl→index reverse import). */
|
||||
export interface ApiProxyDefaults {
|
||||
provider: string
|
||||
model: string
|
||||
/** Default project directory for new sessions whose create request carries no cwd. */
|
||||
cwd: string
|
||||
}
|
||||
|
||||
/** The tool/call payload fields the presenter path reads. */
|
||||
interface ToolCallData { callId: string; name: string; arguments: string }
|
||||
/** The tool/result payload fields the presenter path reads. */
|
||||
interface ToolResultData { callId: string; content: ContentBlock[]; isError: boolean; meta?: JsonValue }
|
||||
|
||||
/**
|
||||
* Compute the render intent for a tool/call or tool/result event through the
|
||||
* presenters registered at this moment; every other event type gets none. A
|
||||
* result's presenter needs its call's parsed args — `argsFor` supplies them
|
||||
* (live: the per-session call table; history: an in-page backscan), returning
|
||||
* undefined when the pairing is unavailable (e.g. the call fell off the page),
|
||||
* which soft-falls to no view. Presenter or JSON.parse throws also soft-fall:
|
||||
* the client's documented default (generic JSON card) covers every miss.
|
||||
*/
|
||||
function viewFor(ctx: Context, event: SessionEvent, argsFor: (callId: string) => unknown): ToolEventView | undefined {
|
||||
try {
|
||||
if (event.type === 'tool/call') {
|
||||
const { name, arguments: raw } = event.data as ToolCallData
|
||||
const view = ctx.tools.get(name)?.presentCall?.(JSON.parse(raw))
|
||||
return view === undefined ? undefined : { for: 'call', view }
|
||||
}
|
||||
if (event.type === 'tool/result') {
|
||||
const { callId, content, isError, meta } = event.data as ToolResultData
|
||||
const call = argsFor(callId) as { name: string; args: unknown } | undefined
|
||||
if (call === undefined) return undefined
|
||||
const view = ctx.tools.get(call.name)?.presentResult?.(call.args, { content, isError, ...meta === undefined ? {} : { meta } })
|
||||
return view === undefined ? undefined : { for: 'result', view }
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
// A throwing presenter (or unparseable arguments) must not break delivery;
|
||||
// the event still ships, just without a view.
|
||||
console.error(`api-proxy: presenter failed for ${event.type}, falling back to generic: ${String(error)}`)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a tool/result's call pairing by scanning a window of events backwards
|
||||
* for the matching tool/call. Used by the history path (the page is the
|
||||
* window — a cross-page pairing soft-falls to no view) and by live-path table
|
||||
* misses after a reconnect-eviction.
|
||||
*/
|
||||
function backscanArgs(events: readonly SessionEvent[], callId: string): { name: string; args: unknown } | undefined {
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const event = events[i] as SessionEvent
|
||||
if (event.type !== 'tool/call') continue
|
||||
const data = event.data as ToolCallData
|
||||
if (data.callId !== callId) continue
|
||||
try {
|
||||
return { name: data.name, args: JSON.parse(data.arguments) }
|
||||
} catch {
|
||||
// Unparseable stored arguments: same soft-fall as a live parse failure.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown by the cold-resume path when the id names no servable session
|
||||
* (absent from the store, or a pre-project legacy log without a cwd).
|
||||
*/
|
||||
class SessionNotFound extends Error {}
|
||||
|
||||
/**
|
||||
* Implement ApiProxy over the ctx composed by bootHost.
|
||||
* @param ctx - the root context returned by bootHost (sessions/agents services mounted).
|
||||
* @param defaults - host-level default provider/model: injected as
|
||||
* agentOptions on create/resume, reported by describe from the same source.
|
||||
* @returns the ApiProxy implementation (minimal-first; stubs noted per method).
|
||||
*/
|
||||
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
|
||||
const agentOptions = { provider: defaults.provider, model: defaults.model }
|
||||
/** Implicit resume of cold sessions, deduplicating concurrent calls (follows the jsonrpc sessionCreations precedent). */
|
||||
const resumes = new Map<SessionId, Promise<Agent>>()
|
||||
|
||||
/**
|
||||
* Gate the cold path on the store: an id absent from it, or naming a legacy
|
||||
* log without a cwd (pre-release stance: not served, no compatibility), is
|
||||
* not-found before any resume is attempted. With the gate passed, a later
|
||||
* resume failure is genuinely internal. No persistence configured skips the
|
||||
* gate — resume itself then fails loud with its own diagnostic.
|
||||
*/
|
||||
async function assertServable(sessionId: SessionId): Promise<void> {
|
||||
const persistence = ctx.get('sessionPersistence')
|
||||
if (persistence === undefined) return
|
||||
const meta = (await persistence.list()).find(m => m.id === sessionId)
|
||||
if (meta === undefined || meta.cwd === undefined) throw new SessionNotFound(`session "${sessionId}" not found`)
|
||||
}
|
||||
|
||||
async function agentFor(sessionId: SessionId): Promise<{ agent: Agent } | { error: RpcError }> {
|
||||
const live = ctx.agents.get(sessionId)
|
||||
if (live !== undefined) return { agent: live }
|
||||
let resume = resumes.get(sessionId)
|
||||
if (resume === undefined) {
|
||||
resume = (async () => {
|
||||
try {
|
||||
await assertServable(sessionId)
|
||||
const handle = await ctx.agents.resume({ resumeSessionId: sessionId, agentOptions })
|
||||
return handle.agent
|
||||
} finally {
|
||||
resumes.delete(sessionId)
|
||||
}
|
||||
})()
|
||||
resumes.set(sessionId, resume)
|
||||
}
|
||||
try {
|
||||
return { agent: await resume }
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof SessionNotFound) {
|
||||
return { error: { code: 'session-not-found', message: error.message, details: { sessionId } } }
|
||||
}
|
||||
// The internal details slot is contractually {}; the reason rides the message.
|
||||
return { error: { code: 'internal', message: `resume failed for session "${sessionId}": ${String(error)}`, details: {} } }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
sessions: {
|
||||
// Attached sessions summarize from memory; persisted-but-unattached (cold)
|
||||
// sessions merge in from the persistence store so history survives restarts.
|
||||
// Legacy logs without a cwd (pre-project stance) are not served — every
|
||||
// session now records its project at create time.
|
||||
async list(request) {
|
||||
const items = ctx.sessions.list().map((session) => {
|
||||
const agent = ctx.agents.get(session.id)
|
||||
return summarize(session, agent?.status === 'running')
|
||||
})
|
||||
const attached = new Set(items.map(item => item.sessionId))
|
||||
const persistence = ctx.get('sessionPersistence')
|
||||
if (persistence !== undefined) {
|
||||
const cold = (await persistence.list()).filter(meta => !attached.has(meta.id) && meta.cwd !== undefined)
|
||||
items.push(...await Promise.all(cold.map(meta => summarizeCold(persistence, meta))))
|
||||
}
|
||||
items.sort((a, b) => b.updatedAt - a.updatedAt)
|
||||
return ok(request, { items })
|
||||
},
|
||||
|
||||
async create(request) {
|
||||
const sessionId = `session-${randomUUID()}` as SessionId
|
||||
// A session's cwd is its project path. When the creator does not choose
|
||||
// one, the default project is the host-level default (the host process
|
||||
// working directory unless boot overrides it).
|
||||
const cwd = request.payload.cwd ?? defaults.cwd
|
||||
const handle = await ctx.agents.create({ sessionId, agentOptions, meta: { cwd } })
|
||||
return ok(request, { sessionId: handle.agent.id })
|
||||
},
|
||||
|
||||
async history(request) {
|
||||
const { sessionId, beforeSeq, maxMessages } = request.payload
|
||||
const found = await agentFor(sessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
const page = paginate(found.agent.session.events, beforeSeq, maxMessages ?? DEFAULT_MAX_MESSAGES)
|
||||
// Views are computed against the registry at pagination time; result
|
||||
// pairing scans within the page only (message-boundary pagination keeps
|
||||
// a call and its result on one page — a cross-page miss soft-falls).
|
||||
const entries: HistoryEntry[] = page.events.map((event) => {
|
||||
const view = viewFor(ctx, event, callId => backscanArgs(page.events, callId))
|
||||
return { event, ...view === undefined ? {} : { view } }
|
||||
})
|
||||
return ok(request, { events: entries, hasMore: page.hasMore })
|
||||
},
|
||||
|
||||
async prompt(request) {
|
||||
const { sessionId, mode, content } = request.payload
|
||||
const found = await agentFor(sessionId)
|
||||
if ('error' in found) return err(request, found.error)
|
||||
const agent = found.agent
|
||||
// The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation).
|
||||
const source: MessageSource = { kind: 'user', rpcId: request.rpcId }
|
||||
try {
|
||||
if (mode === 'steer') agent.steer(content, { source })
|
||||
else agent.send(content, { source })
|
||||
} catch (error: unknown) {
|
||||
// A synchronous throw from send/steer means disposed or invalid input; surface as agent-busy with the reason attached.
|
||||
return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } })
|
||||
}
|
||||
return ok(request, { accepted: true as const })
|
||||
},
|
||||
|
||||
cancel(request) {
|
||||
const { sessionId } = request.payload
|
||||
const agent = ctx.agents.get(sessionId)
|
||||
if (agent === undefined) {
|
||||
return Promise.resolve(err(request, {
|
||||
code: 'session-not-found',
|
||||
message: `session "${sessionId}" not found (not attached)`,
|
||||
details: { sessionId },
|
||||
}))
|
||||
}
|
||||
agent.cancel()
|
||||
return Promise.resolve(ok(request, { accepted: true as const }))
|
||||
},
|
||||
},
|
||||
|
||||
host: {
|
||||
describe(request) {
|
||||
// TODO(step2): version should read apps/cli's package.json; placeholder for now.
|
||||
return Promise.resolve(ok(request, {
|
||||
version: '0.0.1',
|
||||
cwd: process.cwd(),
|
||||
provider: defaults.provider,
|
||||
model: defaults.model,
|
||||
attachedSessions: ctx.agents.list().length,
|
||||
}))
|
||||
},
|
||||
},
|
||||
|
||||
events: {
|
||||
mux(_request, signal) {
|
||||
const queue = new FrameQueue<RpcRequest<MuxFrame>>()
|
||||
for (const session of ctx.sessions.list()) {
|
||||
queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
|
||||
}
|
||||
// Per-session open-call table for result-view pairing. Bounded by the
|
||||
// per-turn call count: entries clear on turn/end; a table miss (stream
|
||||
// opened mid-turn) backscans the session's in-memory events instead.
|
||||
const openCalls = new Map<SessionId, Map<string, { name: string; args: unknown }>>()
|
||||
const disposers = [
|
||||
ctx.on('session/event', (session: Session, event: SessionEvent) => {
|
||||
if (event.type === 'tool/call') {
|
||||
const data = event.data as ToolCallData
|
||||
try {
|
||||
let table = openCalls.get(session.id)
|
||||
if (table === undefined) openCalls.set(session.id, table = new Map<string, { name: string; args: unknown }>())
|
||||
table.set(data.callId, { name: data.name, args: JSON.parse(data.arguments) })
|
||||
} catch {
|
||||
// Unparseable model arguments: leave the table unset; the result view soft-falls.
|
||||
}
|
||||
} else if (event.type === 'turn/end') {
|
||||
openCalls.delete(session.id)
|
||||
}
|
||||
const view = viewFor(ctx, event, callId =>
|
||||
openCalls.get(session.id)?.get(callId) ?? backscanArgs(session.events, callId))
|
||||
queue.push(frame({ type: 'session/event', sessionId: session.id, event, ...view === undefined ? {} : { view } }))
|
||||
}),
|
||||
ctx.on('session/created', (session: Session) => {
|
||||
queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
|
||||
}),
|
||||
ctx.on('session/disposed', (session: Session) => {
|
||||
openCalls.delete(session.id)
|
||||
}),
|
||||
]
|
||||
return queue.iterate(signal, () => { for (const dispose of disposers) dispose() })
|
||||
},
|
||||
|
||||
host(_request, signal) {
|
||||
const queue = new FrameQueue<RpcRequest<HostFrame>>()
|
||||
const disposers = [
|
||||
ctx.on('session/created', (session: Session) => {
|
||||
queue.push(frame({
|
||||
type: 'host/session-added',
|
||||
sessionId: session.id,
|
||||
...session.header.parentSession === undefined ? {} : { parentSessionId: session.header.parentSession },
|
||||
}))
|
||||
}),
|
||||
ctx.on('session/disposed', (session: Session) => {
|
||||
queue.push(frame({ type: 'host/session-removed', sessionId: session.id }))
|
||||
}),
|
||||
ctx.on('agent/status', (agent: Agent, status: AgentStatus) => {
|
||||
if (status === 'disposed') return
|
||||
queue.push(frame({ type: 'host/session-status', sessionId: agent.id, running: status === 'running' }))
|
||||
}),
|
||||
ctx.on('agent/error', (agent: Agent, _turn: number, _step: number, error: Error) => {
|
||||
queue.push(frame({ type: 'host/agent-error', sessionId: agent.id, message: String(error) }))
|
||||
}),
|
||||
]
|
||||
return queue.iterate(signal, () => { for (const dispose of disposers) dispose() })
|
||||
},
|
||||
},
|
||||
|
||||
// TODO(step2): approval/question pending registry (wire answerer + proxy provider).
|
||||
respond(_message: ClientResponse): Promise<RpcReceipt> {
|
||||
return Promise.resolve({ accepted: false, reason: 'not-pending' })
|
||||
},
|
||||
}
|
||||
}
|
||||
133
packages/host/runtime/src/boot.ts
Normal file
133
packages/host/runtime/src/boot.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Core spine composition for the dsh host: mounts the harness core plugins
|
||||
* one by one (each awaited so a load failure surfaces deterministically at
|
||||
* boot, unlike bundle plugins whose children mount unawaited).
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import Timer from '@cordisjs/plugin-timer'
|
||||
import LlmService from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import TaskService from '@deepseek-ai/dsh-tasks'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import LocalBashExecutor from '@deepseek-ai/dsh-bash-local'
|
||||
import * as toolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import * as toolTodo from '@deepseek-ai/dsh-tool-todo'
|
||||
import * as toolTasks from '@deepseek-ai/dsh-tool-tasks'
|
||||
import FsLocal from '@deepseek-ai/dsh-fs-local'
|
||||
import * as fsPolicy from '@deepseek-ai/dsh-fs-policy'
|
||||
import * as toolFs from '@deepseek-ai/dsh-tool-fs'
|
||||
import * as toolFsSearch from '@deepseek-ai/dsh-tool-fs-search'
|
||||
import SkillService from '@deepseek-ai/dsh-skill'
|
||||
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
|
||||
import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
|
||||
import TokenMeter from '@deepseek-ai/dsh-token-meter'
|
||||
import CompactBasic from '@deepseek-ai/dsh-compact-basic'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn'
|
||||
import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork'
|
||||
import * as toolSubagent from '@deepseek-ai/dsh-tool-subagent'
|
||||
import WorkflowWorkerthread from '@deepseek-ai/dsh-workflow-workerthread'
|
||||
import * as toolWorkflow from '@deepseek-ai/dsh-tool-workflow'
|
||||
import * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
|
||||
import SpillLocal from '@deepseek-ai/dsh-spill-local'
|
||||
import * as spillPolicy from '@deepseek-ai/dsh-spill-policy'
|
||||
|
||||
/** Options for bootHost — the assembly-layer composition knobs. */
|
||||
export interface BootHostOptions {
|
||||
/** Root directory for JSONL session persistence. */
|
||||
persistenceRoot: string
|
||||
/** Default provider route for created/resumed agents (defaults to 'deepseek', the only adapter bootHost registers). */
|
||||
provider?: string
|
||||
/** Default model id (defaults to 'deepseek-v4-flash', matching the demos). */
|
||||
model?: string
|
||||
/**
|
||||
* Default project directory for sessions created without an explicit cwd
|
||||
* (defaults to the host process working directory). A session's cwd is its
|
||||
* project path — a per-session choice, not a host property; this option only
|
||||
* supplies the value used when the creator does not choose one.
|
||||
*/
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
/** Host-level default agent routing: the single source injected on create and reported by host.describe. */
|
||||
export interface HostDefaults {
|
||||
provider: string
|
||||
model: string
|
||||
/** Default project directory for new sessions whose create request carries no cwd. */
|
||||
cwd: string
|
||||
}
|
||||
|
||||
/** Booted host handle: composed root context + resolved defaults + disposer. */
|
||||
export interface HostHandle {
|
||||
/** Root context with the full plugin assembly mounted. */
|
||||
ctx: Context
|
||||
/** Resolved default agent routing (options ?? built-in fallbacks). */
|
||||
defaults: HostDefaults
|
||||
/** Tear down the whole plugin tree. */
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the harness host plugin assembly (the one place deciding which plugins mount and
|
||||
* with what defaults — shells must not alter the assembly).
|
||||
* @param options - persistence root and optional default provider/model.
|
||||
* @returns the booted handle (ctx + defaults + dispose).
|
||||
*/
|
||||
export async function bootHost(options: BootHostOptions): Promise<HostHandle> {
|
||||
const defaults: HostDefaults = {
|
||||
provider: options.provider ?? 'deepseek',
|
||||
model: options.model ?? 'deepseek-v4-flash',
|
||||
cwd: options.cwd ?? process.cwd(),
|
||||
}
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Timer)
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(TaskService)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LlmDeepSeek, {})
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root: options.persistenceRoot, compression: 'none' })
|
||||
await ctx.plugin(LocalBashExecutor, {})
|
||||
// Tool suite mirroring the demo:repl composition (repl-agent/cordis.yml +
|
||||
// the agent-spine bundle) so web sessions get the same coding-agent tool
|
||||
// face; deviations are noted inline.
|
||||
await ctx.plugin(toolBash, {})
|
||||
await ctx.plugin(toolTodo)
|
||||
await ctx.plugin(toolTasks, {})
|
||||
// fs paths resolve against the host default project rather than the raw
|
||||
// process cwd — the same source create() injects into session.cwd.
|
||||
await ctx.plugin(FsLocal, { cwd: defaults.cwd })
|
||||
await ctx.plugin(fsPolicy)
|
||||
await ctx.plugin(toolFs, {})
|
||||
await ctx.plugin(toolFsSearch, {})
|
||||
// Skill stack with the demo default dshHome (~/.dsh via resolveDshHome).
|
||||
await ctx.plugin(SkillService, {})
|
||||
await ctx.plugin(SkillLocal, {})
|
||||
await ctx.plugin(toolSkill, {})
|
||||
// Request pressure + compaction (service-wide defaults, as in repl-agent).
|
||||
await ctx.plugin(TokenMeter)
|
||||
await ctx.plugin(CompactBasic)
|
||||
// Subagent spawn/fork backends and their two model-facing tool instances.
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(SubagentSpawn, { providerName: 'spawn' })
|
||||
await ctx.plugin(SubagentFork, { providerName: 'fork' })
|
||||
await ctx.plugin(toolSubagent, { provider: 'spawn', toolName: 'subagent' })
|
||||
await ctx.plugin(toolSubagent, { provider: 'fork', toolName: 'subagent_fork' })
|
||||
await ctx.plugin(WorkflowWorkerthread, { provider: 'spawn' })
|
||||
await ctx.plugin(toolWorkflow, {})
|
||||
// Declared per-tool timeouts become enforced deadlines.
|
||||
await ctx.plugin(timeoutPolicy)
|
||||
// Oversized tool output spills to session-scoped files (repl-agent budget).
|
||||
await ctx.plugin(SpillLocal, {})
|
||||
await ctx.plugin(spillPolicy, { maxInlineBytes: 50000 })
|
||||
return { ctx, defaults, dispose: () => ctx.fiber.dispose() }
|
||||
}
|
||||
14
packages/host/runtime/src/index.ts
Normal file
14
packages/host/runtime/src/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* @deepseek-ai/dsh-host-runtime — host runtime assembly layer: the core spine
|
||||
* composition (bootHost), the ApiProxy implementation (createApiProxy), and
|
||||
* the one-step shell seam (startHost). Host-level configuration (defaults,
|
||||
* persistenceRoot, future user profile) lives here.
|
||||
*/
|
||||
|
||||
export { bootHost } from './boot.ts'
|
||||
export type { BootHostOptions, HostDefaults, HostHandle } from './boot.ts'
|
||||
export { createApiProxy } from './api-proxy.ts'
|
||||
export type { ApiProxyDefaults } from './api-proxy.ts'
|
||||
export { startHost } from './start.ts'
|
||||
export type { StartHostOptions, RunningHost } from './start.ts'
|
||||
export { mountWebPlugins, WEB_UI_PLUGINS } from './web-plugins.ts'
|
||||
31
packages/host/runtime/src/invariant.ts
Normal file
31
packages/host/runtime/src/invariant.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-host-runtime`.
|
||||
* @module @deepseek-ai/dsh-host-runtime/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-host-runtime'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'host-runtime-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this assembly layer only composes plugins owned
|
||||
* elsewhere; the event/data relations it touches (session events, agent
|
||||
* lifecycle, wire frames) are asserted by their owning packages' companions.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
58
packages/host/runtime/src/start.ts
Normal file
58
packages/host/runtime/src/start.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* One-step host startup seam: boot core → assemble ApiProxy → assemble the
|
||||
* fetch handler. The returned RunningHost is shell-agnostic — node:http
|
||||
* (dsh web), in-process injection (dsh -p, tests), an IPC bridge (future
|
||||
* Electron sidecar), and front-door plugin mounting (future dsh acp) all
|
||||
* consume the same shape.
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
|
||||
import { bootHost } from './boot.ts'
|
||||
import type { BootHostOptions, HostDefaults } from './boot.ts'
|
||||
import { createApiProxy } from './api-proxy.ts'
|
||||
|
||||
/** Options for startHost. */
|
||||
export interface StartHostOptions {
|
||||
/**
|
||||
* Passed through to bootHost verbatim (persistenceRoot required +
|
||||
* provider?/model?). Future host-level knobs (profile, log sink — any
|
||||
* output added to the assembly MUST be switchable off here) land as
|
||||
* additive fields.
|
||||
*/
|
||||
boot: BootHostOptions
|
||||
}
|
||||
|
||||
/** Running host handle: the contract impl plus its fetch carrier and root ctx. */
|
||||
export interface RunningHost {
|
||||
/** Contract implementation (direct calls for in-process consumers; the input of an IPC adapter). */
|
||||
api: ApiProxy
|
||||
/** WHATWG-fetch-shaped carrier (web shell bridges it to node:http; host-side endpoint of an IPC bridge). */
|
||||
handler: { fetch: typeof fetch }
|
||||
/** Host-level default routing (describe and every shell share this single source). */
|
||||
defaults: HostDefaults
|
||||
/**
|
||||
* Root context — a formal seam, not an escape hatch: (1) the mount point for
|
||||
* protocol front-door plugins (`dsh acp` = startHost() → ctx.plugin(uiAcp, config));
|
||||
* (2) headless session-event subscription. Discipline: consuming clients must
|
||||
* not bypass `api` through ctx; shells must not ctx.plugin to alter the
|
||||
* assembly (mounting a front door is the shell's own shape, not an assembly change).
|
||||
*/
|
||||
ctx: Context
|
||||
/** Single shutdown exit (ctx.fiber.dispose()). Idempotent: a second call returns the same promise. */
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the host and assemble its consumption surfaces in one step.
|
||||
* @param options - boot passthrough (see StartHostOptions).
|
||||
* @returns the running host handle shared by every shell shape.
|
||||
*/
|
||||
export async function startHost(options: StartHostOptions): Promise<RunningHost> {
|
||||
const host = await bootHost(options.boot)
|
||||
const api = createApiProxy(host.ctx, host.defaults)
|
||||
const handler = toFetchHandler(api)
|
||||
let disposing: Promise<void> | undefined
|
||||
return { api, handler, defaults: host.defaults, ctx: host.ctx, dispose: () => (disposing ??= host.dispose()) }
|
||||
}
|
||||
63
packages/host/runtime/src/web-plugins.ts
Normal file
63
packages/host/runtime/src/web-plugins.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Web UI plugin assembly: mounts @cordisjs/plugin-loader with an in-memory
|
||||
* entry tree listing the eight UI plugin packages (the P-I config-source bar —
|
||||
* a cordis.yml file form comes later; install/remove currently means editing
|
||||
* this list and restarting). The web plugin registry discovers the entries by
|
||||
* their package.json dshClient declarations; node halves are empty applies,
|
||||
* so mounting them here costs nothing beyond Loader governance.
|
||||
*/
|
||||
import { createRequire } from 'node:module'
|
||||
import type { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
|
||||
/** The eight UI plugin packages served to the browser (order = manifest order). */
|
||||
export const WEB_UI_PLUGINS = [
|
||||
'@deepseek-ai/dsh-client-connection',
|
||||
'@deepseek-ai/dsh-client-runtime',
|
||||
'@deepseek-ai/dsh-client-ui-theme',
|
||||
'@deepseek-ai/dsh-client-i18n',
|
||||
'@deepseek-ai/dsh-client-ui-layout',
|
||||
'@deepseek-ai/dsh-client-ui-sidebar',
|
||||
'@deepseek-ai/dsh-client-ui-conversation',
|
||||
'@deepseek-ai/dsh-client-ui-trajectory',
|
||||
] as const
|
||||
|
||||
/** What the shell hands the web plugin registry (loader view + module resolution seam). */
|
||||
export interface MountedWebPlugins {
|
||||
/** Entry enumeration surface of the mounted Loader (registry scan source). */
|
||||
loader: { entries(): Iterable<{ options: { name: string }; fiber?: unknown; disabled: boolean }> }
|
||||
/** Resolve a plugin package's package.json absolute path. */
|
||||
resolvePkgJson: (name: string) => string
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount the Loader (when absent) and create one in-memory entry per UI
|
||||
* plugin, then wait for the tree to settle. A plugin whose import fails
|
||||
* leaves its entry fiber-less — surfaced here as a loud throw listing the
|
||||
* failures (misconfiguration must not silently drop a UI plugin).
|
||||
* @param ctx - host root context (bootHost product).
|
||||
* @returns the loader view and package.json resolver the registry consumes.
|
||||
*/
|
||||
export async function mountWebPlugins(ctx: Context): Promise<MountedWebPlugins> {
|
||||
// The Loader resolves bare specifiers against ctx.baseUrl; without one the
|
||||
// import silently fails and every entry stays fiber-less. This package
|
||||
// depends on all eight UI plugins, so its own URL is the right anchor.
|
||||
ctx.baseUrl ??= import.meta.url
|
||||
if (ctx.get('loader') === undefined) await ctx.plugin(Loader)
|
||||
const existing = new Set([...ctx.loader.entries()].map(entry => entry.options.name))
|
||||
for (const name of WEB_UI_PLUGINS) {
|
||||
if (!existing.has(name)) await ctx.loader.create({ name })
|
||||
}
|
||||
await ctx.loader.await()
|
||||
const dead = [...ctx.loader.entries()]
|
||||
.filter(entry => (WEB_UI_PLUGINS as readonly string[]).includes(entry.options.name))
|
||||
.filter(entry => entry.fiber === undefined && !entry.disabled)
|
||||
if (dead.length > 0) {
|
||||
throw new Error(`web-plugins: UI plugin(s) failed to load: ${dead.map(e => e.options.name).join(', ')}`)
|
||||
}
|
||||
const require = createRequire(import.meta.url)
|
||||
return {
|
||||
loader: ctx.loader,
|
||||
resolvePkgJson: name => require.resolve(`${name}/package.json`),
|
||||
}
|
||||
}
|
||||
94
packages/host/runtime/tests/api-proxy-cold.spec.ts
Normal file
94
packages/host/runtime/tests/api-proxy-cold.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Cold-session and degenerate-composition paths of the host ApiProxy:
|
||||
* sessions.list merging persisted-but-unattached summaries (mtime source,
|
||||
* createdAt fallbacks, lineage projection) and the resume error split when
|
||||
* the composition has no persistence gate and no agent factory.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, writeFileSync, utimesSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
|
||||
const sid = (id: string): SessionId => id as SessionId
|
||||
|
||||
let nextRpc = 1
|
||||
function request<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(`cold-${String(nextRpc++)}`), payload }
|
||||
}
|
||||
|
||||
function header(id: string, createdAt: number, extra: Partial<SessionHeader> = {}): SessionHeader {
|
||||
return { version: 0, id: sid(id), createdAt, cwd: '/proj', ...extra }
|
||||
}
|
||||
|
||||
describe('sessions.list cold merge', () => {
|
||||
it('summarizes unattached sessions: log mtime, locate-less and vanished-log createdAt fallbacks, lineage', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-cold-'))
|
||||
const logPath = join(root, 'a.log')
|
||||
writeFileSync(logPath, 'log-bytes')
|
||||
utimesSync(logPath, 5000, 5000) // mtime 5_000_000 ms — newer than every createdAt below
|
||||
const metas = [
|
||||
header('session-a', 1000),
|
||||
header('session-b', 2000, { parentSession: sid('session-parent') }),
|
||||
header('session-c', 1500),
|
||||
]
|
||||
// Structural fake of the persistence face list() consumes: list + locate.
|
||||
// locate: a real per-session file (mtime wins), a backend without one
|
||||
// (SQLite shape → createdAt), and a path whose file vanished (stat ENOENT
|
||||
// → createdAt).
|
||||
ctx.provide('sessionPersistence', {
|
||||
list: () => Promise.resolve(metas),
|
||||
locate: (meta: SessionHeader) => {
|
||||
if (meta.id === sid('session-a')) return { kind: 'jsonl', path: logPath }
|
||||
if (meta.id === sid('session-c')) return { kind: 'jsonl', path: join(root, 'vanished.log') }
|
||||
return undefined
|
||||
},
|
||||
})
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||
|
||||
const response = await api.sessions.list(request({}))
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
const items = response.result.value.items
|
||||
expect(items.map(item => item.sessionId)).toEqual(['session-a', 'session-b', 'session-c'])
|
||||
const [a, b, c] = items
|
||||
expect(a?.updatedAt).toBeCloseTo(5_000_000, -3)
|
||||
expect(a?.running).toBe(false)
|
||||
expect(a?.cwd).toBe('/proj')
|
||||
expect(a?.parentSessionId).toBeUndefined()
|
||||
expect(b?.updatedAt).toBe(2000)
|
||||
expect(b?.parentSessionId).toBe('session-parent')
|
||||
expect(c?.updatedAt).toBe(1500)
|
||||
})
|
||||
})
|
||||
|
||||
describe('degenerate composition (no persistence, no factory)', () => {
|
||||
it('list skips the cold merge and resume maps a non-not-found failure to internal', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||
|
||||
const listed = await api.sessions.list(request({}))
|
||||
expect(listed.result.ok).toBe(true)
|
||||
if (listed.result.ok) expect(listed.result.value.items).toEqual([])
|
||||
|
||||
// No persistence → the servable gate passes silently; the factory-less
|
||||
// registry then rejects resume, which is NOT a SessionNotFound.
|
||||
const response = await api.sessions.history(request({ sessionId: sid('session-ghost') }))
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (!response.result.ok) {
|
||||
expect(response.result.error.code).toBe('internal')
|
||||
expect(response.result.error.message).toMatch(/resume failed for session "session-ghost"/)
|
||||
}
|
||||
})
|
||||
})
|
||||
179
packages/host/runtime/tests/api-proxy-view.spec.ts
Normal file
179
packages/host/runtime/tests/api-proxy-view.spec.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Tool-card view computation over the mux live path: three standard card types
|
||||
* arrive on the frame, a presenterless tool ships no view field, and a throwing
|
||||
* presenter soft-falls to no view (the event still ships). Result pairing works
|
||||
* both through the live open-call table and the backscan fallback after
|
||||
* turn/end cleared it.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry, { defineContentToolFixture } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
|
||||
import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { createApiProxy } from '../src/api-proxy.ts'
|
||||
|
||||
const reply = (text: string): Promise<ContentBlock[]> => Promise.resolve([{ type: 'text', text }])
|
||||
|
||||
function tool(name: string, presenters: Pick<ToolDefinition, 'presentCall' | 'presentResult'>): ToolDefinition {
|
||||
return defineContentToolFixture({
|
||||
name,
|
||||
description: `tool ${name}`,
|
||||
parameters: {},
|
||||
execute: () => reply(`ran:${name}`),
|
||||
...presenters,
|
||||
})
|
||||
}
|
||||
|
||||
async function harness(): Promise<{ ctx: Context }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SystemPrompt, { persona: '' })
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
ctx.tools.register(tool('gen', {
|
||||
presentCall: () => ({ card: 'generic', title: 'gen call' }),
|
||||
presentResult: (_args, result) => ({ card: 'generic', title: result.isError ? 'gen failed' : 'gen done' }),
|
||||
}))
|
||||
ctx.tools.register(tool('term', {
|
||||
presentCall: args => ({ card: 'terminal', title: (args as { cmd?: string }).cmd ?? '' }),
|
||||
presentResult: () => ({ card: 'terminal', output: 'done' }),
|
||||
}))
|
||||
ctx.tools.register(tool('diffy', {
|
||||
presentCall: () => ({ card: 'diff', title: 'Write f.txt', diffs: [{ path: 'f.txt', oldText: null, newText: 'x' }] }),
|
||||
}))
|
||||
ctx.tools.register(tool('plain', {}))
|
||||
ctx.tools.register(tool('boom', {
|
||||
presentCall: () => { throw new Error('presenter exploded') },
|
||||
}))
|
||||
return { ctx }
|
||||
}
|
||||
|
||||
/** Drain frames from an open mux stream until `count` session/event frames arrived. */
|
||||
async function collect(iterable: AsyncIterable<RpcRequest<MuxFrame>>, count: number, abort: AbortController): Promise<MuxFrame[]> {
|
||||
const frames: MuxFrame[] = []
|
||||
for await (const frame of iterable) {
|
||||
frames.push(frame.payload)
|
||||
if (frames.filter(f => f.type === 'session/event').length >= count) abort.abort()
|
||||
}
|
||||
return frames
|
||||
}
|
||||
|
||||
describe('mux live view computation', () => {
|
||||
it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => {
|
||||
const { ctx } = await harness()
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||
const abort = new AbortController()
|
||||
const stream = api.events.mux({ rpcId: RpcId('t-mux'), payload: {} }, abort.signal)
|
||||
const collected = collect(stream, 7, abort)
|
||||
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-gen'), name: 'gen', arguments: '{}' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-term'), name: 'term', arguments: '{"cmd":"echo hi"}' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-diff'), name: 'diffy', arguments: '{}' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-plain'), name: 'plain', arguments: '{}' })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-boom'), name: 'boom', arguments: '{}' })
|
||||
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c-gen'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
|
||||
|
||||
const frames = await collected
|
||||
const events = frames.filter(f => f.type === 'session/event')
|
||||
const byCall = new Map(events
|
||||
.filter(f => f.event.type === 'tool/call' || f.event.type === 'tool/result')
|
||||
.map(f => [`${f.event.type}:${(f.event.data as { callId: string }).callId}`, f]))
|
||||
|
||||
expect(byCall.get('tool/call:c-gen')?.view).toEqual({ for: 'call', view: { card: 'generic', title: 'gen call' } })
|
||||
expect(byCall.get('tool/call:c-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'echo hi' } })
|
||||
expect(byCall.get('tool/call:c-diff')?.view?.view.card).toBe('diff')
|
||||
// No presenter → the frame carries no view property at all.
|
||||
expect('view' in (byCall.get('tool/call:c-plain') ?? {})).toBe(false)
|
||||
// Throwing presenter → soft-fall: event ships, no view.
|
||||
expect(byCall.get('tool/call:c-boom')).toBeDefined()
|
||||
expect('view' in (byCall.get('tool/call:c-boom') ?? {})).toBe(false)
|
||||
// Result pairing through the live table: presentResult saw the call's args.
|
||||
expect(byCall.get('tool/result:c-gen')?.view).toEqual({ for: 'result', view: { card: 'generic', title: 'gen done' } })
|
||||
})
|
||||
|
||||
it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => {
|
||||
const { ctx } = await harness()
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||
const session = ctx.sessions.create()
|
||||
// history resolves the agent first; a live structural stub is enough (only
|
||||
// .session is read on this path).
|
||||
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-term'), name: 'term', arguments: '{"cmd":"ls"}' })
|
||||
// meta rides through to presentResult's ToolResult (the spread arm).
|
||||
session.append('tool/result', { turn: 1, step: 1, callId: CallId('h-term'), content: [{ type: 'text', text: 'ok' }], isError: false, meta: { n: 1 } }, { surfaceOp: 'append' })
|
||||
// Unpaired result: no tool/call with this id anywhere in the page.
|
||||
session.append('tool/result', { turn: 1, step: 1, callId: CallId('h-orphan'), content: [{ type: 'text', text: 'x' }], isError: false }, { surfaceOp: 'append' })
|
||||
// Paired, but the call's stored arguments do not parse: backscan soft-falls.
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-bad'), name: 'term', arguments: '{broken' })
|
||||
session.append('tool/result', { turn: 1, step: 1, callId: CallId('h-bad'), content: [{ type: 'text', text: 'y' }], isError: false }, { surfaceOp: 'append' })
|
||||
// Presenterless tool: pairing succeeds but presentResult is absent.
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('h-plain'), name: 'plain', arguments: '{}' })
|
||||
session.append('tool/result', { turn: 1, step: 1, callId: CallId('h-plain'), content: [{ type: 'text', text: 'z' }], isError: false }, { surfaceOp: 'append' })
|
||||
|
||||
const response = await api.sessions.history({ rpcId: RpcId('t-hist'), payload: { sessionId: session.id } })
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
const entries = response.result.value.events
|
||||
const byKey = new Map(entries
|
||||
.filter(entry => entry.event.type === 'tool/call' || entry.event.type === 'tool/result')
|
||||
.map(entry => [`${entry.event.type}:${(entry.event.data as { callId: string }).callId}`, entry]))
|
||||
expect(byKey.get('tool/call:h-term')?.view).toEqual({ for: 'call', view: { card: 'terminal', title: 'ls' } })
|
||||
expect(byKey.get('tool/result:h-term')?.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } })
|
||||
expect('view' in (byKey.get('tool/result:h-orphan') ?? {})).toBe(false)
|
||||
expect('view' in (byKey.get('tool/result:h-bad') ?? {})).toBe(false)
|
||||
expect('view' in (byKey.get('tool/result:h-plain') ?? {})).toBe(false)
|
||||
})
|
||||
|
||||
it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => {
|
||||
const { ctx } = await harness()
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||
const abort = new AbortController()
|
||||
const stream = api.events.mux({ rpcId: RpcId('t-mux3'), payload: {} }, abort.signal)
|
||||
|
||||
let session: Session | undefined
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
session = inner.sessions.create('session-doomed' as SessionId)
|
||||
}, { inject: ['sessions'] }))
|
||||
session?.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session?.append('tool/call', { turn: 1, step: 1, callId: CallId('c-doomed'), name: 'term', arguments: '{"cmd":"x"}' })
|
||||
// Disposing the owning fiber detaches the session mid-stream; the
|
||||
// session/disposed listener must clear its open-call table entry.
|
||||
await fiber.dispose()
|
||||
|
||||
const frames = await collect(stream, 2, abort)
|
||||
const call = frames.find(f => f.type === 'session/event' && f.event.type === 'tool/call')
|
||||
expect(call?.type === 'session/event' && call.view?.for).toBe('call')
|
||||
})
|
||||
|
||||
it('pairs a result after turn/end via the in-memory backscan fallback', async () => {
|
||||
const { ctx } = await harness()
|
||||
const api = createApiProxy(ctx, { provider: 'p', model: 'm', cwd: '/tmp' })
|
||||
const abort = new AbortController()
|
||||
const stream = api.events.mux({ rpcId: RpcId('t-mux2'), payload: {} }, abort.signal)
|
||||
const collected = collect(stream, 4, abort)
|
||||
|
||||
const session = ctx.sessions.create()
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c-late'), name: 'term', arguments: '{"cmd":"tail"}' })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
// The turn/end above cleared the live table; pairing must fall back to
|
||||
// scanning the session's in-memory events.
|
||||
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c-late'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
|
||||
|
||||
const frames = await collected
|
||||
const result = frames.find(f => f.type === 'session/event' && f.event.type === 'tool/result')
|
||||
expect(result?.type === 'session/event' && result.view).toEqual({ for: 'result', view: { card: 'terminal', output: 'done' } })
|
||||
})
|
||||
})
|
||||
367
packages/host/runtime/tests/host-runtime.spec.ts
Normal file
367
packages/host/runtime/tests/host-runtime.spec.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { agentEvents } from '@deepseek-ai/dsh-agent'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { HostFrame, MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import { bootHost, startHost, type HostHandle, type RunningHost } from '../src/index.ts'
|
||||
|
||||
/** Scripted adapter: each model call consumes the next chunk list; 'hang' streams then waits for abort. */
|
||||
class ScriptedAdapter extends LlmAdapter {
|
||||
constructor(private script: (StreamChunk[] | 'hang')[]) {
|
||||
super()
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
const entry = this.script.shift()
|
||||
if (!entry) throw new Error('ScriptedAdapter: script exhausted')
|
||||
if (entry === 'hang') {
|
||||
yield { type: 'block-start', index: 0, blockType: 'text' }
|
||||
await new Promise<void>((_resolve, reject) => {
|
||||
options.signal?.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
|
||||
})
|
||||
return
|
||||
}
|
||||
yield * entry
|
||||
}
|
||||
}
|
||||
|
||||
function textResponse(text: string): StreamChunk[] {
|
||||
return [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text },
|
||||
{ type: 'block-end', index: 0, block: { type: 'text', text } },
|
||||
{ type: 'usage', usage: { inputTokens: 10, outputTokens: text.length } },
|
||||
{ type: 'finish', reason: { kind: 'stop' } },
|
||||
]
|
||||
}
|
||||
|
||||
function request<P>(payload: P): RpcRequest<P> {
|
||||
return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }
|
||||
}
|
||||
let nextRpc = 1
|
||||
|
||||
function waitForIdle(ctx: Context, agent: Agent): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const dispose = ctx.on('agent/status', (subject: Agent, status: string) => {
|
||||
if (subject === agent && status === 'idle') {
|
||||
dispose()
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function expectOk<T>(response: RpcResponse<T>): T {
|
||||
expect(response.result.ok).toBe(true)
|
||||
if (!response.result.ok) throw new Error('unreachable')
|
||||
return response.result.value
|
||||
}
|
||||
|
||||
let host: RunningHost | undefined
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', 'spec-placeholder-key')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await host?.dispose()
|
||||
host = undefined
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
async function boot(script: (StreamChunk[] | 'hang')[] = []): Promise<RunningHost> {
|
||||
host = await startHost({
|
||||
boot: { persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-host-runtime-')), provider: 'scripted', model: 'test-model' },
|
||||
})
|
||||
host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter(script))
|
||||
return host
|
||||
}
|
||||
|
||||
describe('bootHost / startHost', () => {
|
||||
it('falls back to the deepseek defaults and disposes idempotently', async () => {
|
||||
const handle: HostHandle = await bootHost({ persistenceRoot: mkdtempSync(join(tmpdir(), 'dsh-boot-')) })
|
||||
expect(handle.defaults).toMatchObject({ provider: 'deepseek', model: 'deepseek-v4-flash' })
|
||||
expect(typeof handle.defaults.cwd).toBe('string')
|
||||
await handle.dispose()
|
||||
})
|
||||
|
||||
it('startHost assembles api + handler over the same defaults and dedupes dispose', async () => {
|
||||
const running = await boot()
|
||||
expect(running.defaults).toMatchObject({ provider: 'scripted', model: 'test-model' })
|
||||
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-h', method: 'host.describe', payload: {} })
|
||||
const response = await running.handler.fetch(new Request('http://x/api/host.describe', { method: 'POST', body }))
|
||||
const parsed = await response.json() as { result: { ok: boolean; value: { provider: string } } }
|
||||
expect(parsed.result.value.provider).toBe('scripted')
|
||||
const first = running.dispose()
|
||||
expect(running.dispose()).toBe(first)
|
||||
await first
|
||||
host = undefined
|
||||
})
|
||||
})
|
||||
|
||||
describe('host.describe', () => {
|
||||
it('reports version, cwd, defaults, and the attached count', async () => {
|
||||
const { api } = await boot()
|
||||
const value = expectOk(await api.host.describe(request({})))
|
||||
expect(value).toMatchObject({ version: '0.0.1', cwd: process.cwd(), provider: 'scripted', model: 'test-model', attachedSessions: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessions.create / list', () => {
|
||||
it('creates a session (echoing the request rpcId) and lists it newest-first', async () => {
|
||||
const { api } = await boot()
|
||||
const created = await api.sessions.create(request({ cwd: '/tmp' }))
|
||||
const { sessionId } = expectOk(created)
|
||||
expect(created.rpcId).toMatch(/^req-/)
|
||||
const second = expectOk(await api.sessions.create(request({}))).sessionId
|
||||
|
||||
const { items } = expectOk(await api.sessions.list(request({})))
|
||||
expect(items.map(item => item.sessionId)).toContain(sessionId)
|
||||
expect(items.map(item => item.sessionId)).toContain(second)
|
||||
const first = items.find(item => item.sessionId === sessionId)
|
||||
expect(first?.cwd).toBe('/tmp')
|
||||
expect(first?.running).toBe(false)
|
||||
expect(first?.parentSessionId).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessions.prompt / cancel', () => {
|
||||
it('queues a prompt whose rpcId rides into user/message, then the reply lands', async () => {
|
||||
const running = await boot([textResponse('pong')])
|
||||
const { api, ctx } = running
|
||||
const { sessionId } = expectOk(await api.sessions.create(request({})))
|
||||
const agent = ctx.agents.get(sessionId)
|
||||
expect(agent).toBeDefined()
|
||||
const idle = waitForIdle(ctx, agent as Agent)
|
||||
const promptRequest = request({ sessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'ping' }] })
|
||||
expectOk(await api.sessions.prompt(promptRequest))
|
||||
await idle
|
||||
|
||||
const value = expectOk(await api.sessions.history(request({ sessionId })))
|
||||
const events = value.events.map(entry => entry.event)
|
||||
const userEvent = events.find(event => event.type === 'user/message') as
|
||||
| { data: { source?: { rpcId?: string } } } | undefined
|
||||
expect(userEvent?.data.source?.rpcId).toBe(promptRequest.rpcId)
|
||||
const reply = events.find(event => event.type === 'assistant/message')
|
||||
expect(reply).toBeDefined()
|
||||
})
|
||||
|
||||
it('steer on an idle agent falls through to send', async () => {
|
||||
const running = await boot([textResponse('steered')])
|
||||
const { api, ctx } = running
|
||||
const { sessionId } = expectOk(await api.sessions.create(request({})))
|
||||
const idle = waitForIdle(ctx, ctx.agents.get(sessionId) as Agent)
|
||||
expectOk(await api.sessions.prompt(request({ sessionId, mode: 'steer' as const, content: [{ type: 'text' as const, text: 'now' }] })))
|
||||
await idle
|
||||
})
|
||||
|
||||
it('errors session-not-found on a ghost session', async () => {
|
||||
const { api } = await boot()
|
||||
const response = await api.sessions.prompt(request({ sessionId: 'session-void' as SessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'x' }] }))
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found')
|
||||
})
|
||||
|
||||
it('maps a synchronous send throw to agent-busy', async () => {
|
||||
const { api } = await boot()
|
||||
const { sessionId } = expectOk(await api.sessions.create(request({})))
|
||||
const poisoned = [{ type: 'text', text: 'x', bad: () => 1 }] as never
|
||||
const response = await api.sessions.prompt(request({ sessionId, mode: 'queue' as const, content: poisoned }))
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (!response.result.ok) expect(response.result.error.code).toBe('agent-busy')
|
||||
})
|
||||
|
||||
it('cancels an attached agent and rejects an unattached one', async () => {
|
||||
const running = await boot(['hang'])
|
||||
const { api, ctx } = running
|
||||
const { sessionId } = expectOk(await api.sessions.create(request({})))
|
||||
const agent = ctx.agents.get(sessionId) as Agent
|
||||
agent.send([{ type: 'text', text: 'run forever' }])
|
||||
expectOk(await api.sessions.cancel(request({ sessionId })))
|
||||
|
||||
const missing = await api.sessions.cancel(request({ sessionId: 'session-none' as SessionId }))
|
||||
expect(missing.result.ok).toBe(false)
|
||||
if (!missing.result.ok) expect(missing.result.error.code).toBe('session-not-found')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessions.history', () => {
|
||||
it('implicitly resumes a cold session, deduplicating concurrent calls to one attach', async () => {
|
||||
const persistenceRoot = mkdtempSync(join(tmpdir(), 'dsh-host-resume-'))
|
||||
const first = await startHost({ boot: { persistenceRoot, provider: 'scripted', model: 'test-model' } })
|
||||
first.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([textResponse('persisted')]))
|
||||
const { sessionId } = expectOk(await first.api.sessions.create(request({})))
|
||||
const agent = first.ctx.agents.get(sessionId) as Agent
|
||||
const idle = waitForIdle(first.ctx, agent)
|
||||
agent.send([{ type: 'text', text: 'save me' }])
|
||||
await idle
|
||||
await first.dispose()
|
||||
|
||||
host = await startHost({ boot: { persistenceRoot, provider: 'scripted', model: 'test-model' } })
|
||||
host.ctx.llm.registerAdapter(['scripted'], new ScriptedAdapter([]))
|
||||
expect(host.ctx.agents.get(sessionId)).toBeUndefined()
|
||||
const [a, b] = await Promise.all([
|
||||
host.api.sessions.history(request({ sessionId })),
|
||||
host.api.sessions.history(request({ sessionId })),
|
||||
])
|
||||
for (const response of [a, b]) {
|
||||
const value = expectOk(response)
|
||||
expect(value.events.some(entry => entry.event.type === 'assistant/message')).toBe(true)
|
||||
}
|
||||
expect(host.ctx.agents.get(sessionId)).toBeDefined()
|
||||
expect(host.ctx.agents.list()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('errors session-not-found when resume fails, deduplicating concurrent resumes', async () => {
|
||||
const { api } = await boot()
|
||||
const ghost = 'session-ghost' as SessionId
|
||||
const [first, second] = await Promise.all([
|
||||
api.sessions.history(request({ sessionId: ghost })),
|
||||
api.sessions.history(request({ sessionId: ghost })),
|
||||
])
|
||||
for (const response of [first, second]) {
|
||||
expect(response.result.ok).toBe(false)
|
||||
if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found')
|
||||
}
|
||||
})
|
||||
|
||||
it('paginates backwards on message boundaries with hasMore', async () => {
|
||||
const running = await boot([textResponse('a1'), textResponse('a2'), textResponse('a3')])
|
||||
const { api, ctx } = running
|
||||
const { sessionId } = expectOk(await api.sessions.create(request({})))
|
||||
const agent = ctx.agents.get(sessionId) as Agent
|
||||
for (const text of ['q1', 'q2', 'q3']) {
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
agent.send([{ type: 'text', text }])
|
||||
await idle
|
||||
}
|
||||
|
||||
const all = expectOk(await api.sessions.history(request({ sessionId })))
|
||||
expect(all.hasMore).toBe(false)
|
||||
const messageCount = all.events.filter(entry => entry.event.type === 'user/message' || entry.event.type === 'assistant/message').length
|
||||
expect(messageCount).toBe(6)
|
||||
|
||||
const lastPage = expectOk(await api.sessions.history(request({ sessionId, maxMessages: 1 })))
|
||||
expect(lastPage.hasMore).toBe(true)
|
||||
expect(lastPage.events.filter(entry => entry.event.type === 'assistant/message')).toHaveLength(1)
|
||||
expect(lastPage.events.filter(entry => entry.event.type === 'user/message')).toHaveLength(0)
|
||||
|
||||
const firstSeq = lastPage.events[0]?.event.seq as number
|
||||
const olderPage = expectOk(await api.sessions.history(request({ sessionId, beforeSeq: firstSeq, maxMessages: 2 })))
|
||||
expect(olderPage.events.at(-1)?.event.seq).toBeLessThan(firstSeq)
|
||||
expect(olderPage.hasMore).toBe(true)
|
||||
expect(olderPage.events.filter(entry => entry.event.type === 'user/message' || entry.event.type === 'assistant/message').length).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('events streams', () => {
|
||||
it('mux: a pending pull wakes when a frame arrives (waiter path)', async () => {
|
||||
const running = await boot()
|
||||
const { api } = running
|
||||
const ac = new AbortController()
|
||||
const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]()
|
||||
// no sessions yet: next() must pend on the queue's waiter, not the buffer
|
||||
const pending = stream.next()
|
||||
const { sessionId } = expectOk(await api.sessions.create(request({})))
|
||||
const frame = (await pending).value as RpcRequest<MuxFrame>
|
||||
expect(frame.payload).toMatchObject({ type: 'session/subscribed', sessionId })
|
||||
ac.abort()
|
||||
expect((await stream.next()).done).toBe(true)
|
||||
})
|
||||
|
||||
it('lists fork lineage and announces it on the host stream', async () => {
|
||||
const running = await boot()
|
||||
const { api, ctx } = running
|
||||
const { sessionId: parent } = expectOk(await api.sessions.create(request({})))
|
||||
const ac = new AbortController()
|
||||
const stream = api.events.host(request({}), ac.signal)[Symbol.asyncIterator]()
|
||||
const child = `session-child-${String(Date.now())}` as SessionId
|
||||
const handle = await ctx.agents.create({ sessionId: child, meta: { parentSession: parent }, agentOptions: { provider: 'scripted', model: 'test-model' } })
|
||||
expect(handle.agent.id).toBe(child)
|
||||
const added = (await stream.next()).value as RpcRequest<HostFrame>
|
||||
expect(added.payload).toMatchObject({ type: 'host/session-added', sessionId: child, parentSessionId: parent })
|
||||
const { items } = expectOk(await api.sessions.list(request({})))
|
||||
expect(items.find(item => item.sessionId === child)?.parentSessionId).toBe(parent)
|
||||
|
||||
await handle.dispose()
|
||||
let frame: RpcRequest<HostFrame>
|
||||
do frame = (await stream.next()).value as RpcRequest<HostFrame>
|
||||
while (frame.payload.type !== 'host/session-removed')
|
||||
expect(frame.payload).toMatchObject({ type: 'host/session-removed', sessionId: child })
|
||||
ac.abort()
|
||||
})
|
||||
|
||||
it('mux: emits subscribed baselines, live session events, and new-session subscriptions until abort', async () => {
|
||||
const running = await boot([textResponse('live')])
|
||||
const { api, ctx } = running
|
||||
const { sessionId } = expectOk(await api.sessions.create(request({})))
|
||||
|
||||
const ac = new AbortController()
|
||||
const stream = api.events.mux(request({}), ac.signal)[Symbol.asyncIterator]()
|
||||
const baseline = await stream.next()
|
||||
expect((baseline.value as RpcRequest<MuxFrame>).payload).toMatchObject({ type: 'session/subscribed', sessionId })
|
||||
|
||||
const agent = ctx.agents.get(sessionId) as Agent
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
agent.send([{ type: 'text', text: 'go' }])
|
||||
await idle
|
||||
const live = await stream.next()
|
||||
expect((live.value as RpcRequest<MuxFrame>).payload.type).toBe('session/event')
|
||||
|
||||
const other = expectOk(await api.sessions.create(request({}))).sessionId
|
||||
let frame: RpcRequest<MuxFrame>
|
||||
do frame = (await stream.next()).value as RpcRequest<MuxFrame>
|
||||
while (!(frame.payload.type === 'session/subscribed' && frame.payload.sessionId === other))
|
||||
|
||||
ac.abort()
|
||||
expect((await stream.next()).done).toBe(true)
|
||||
})
|
||||
|
||||
it('host: session lifecycle, status flips (disposed suppressed), and agent errors', async () => {
|
||||
const running = await boot([textResponse('x')])
|
||||
const { api, ctx } = running
|
||||
const ac = new AbortController()
|
||||
const stream = api.events.host(request({}), ac.signal)[Symbol.asyncIterator]()
|
||||
|
||||
const { sessionId } = expectOk(await api.sessions.create(request({})))
|
||||
const added = await stream.next()
|
||||
expect((added.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/session-added', sessionId })
|
||||
|
||||
const agent = ctx.agents.get(sessionId) as Agent
|
||||
const idle = waitForIdle(ctx, agent)
|
||||
agent.send([{ type: 'text', text: 'run' }])
|
||||
await idle
|
||||
const runningFrame = await stream.next()
|
||||
expect((runningFrame.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/session-status', running: true })
|
||||
const idleFrame = await stream.next()
|
||||
expect((idleFrame.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/session-status', running: false })
|
||||
|
||||
// Raw ctx.emit lacks the scope carrier the mounted invariants plugin now
|
||||
// enforces; dispatch the way the loop does.
|
||||
agentEvents(ctx, agent).emit('agent/error', 1, 1, new Error('boom'))
|
||||
const errorFrame = await stream.next()
|
||||
expect((errorFrame.value as RpcRequest<HostFrame>).payload).toMatchObject({ type: 'host/agent-error', message: 'Error: boom' })
|
||||
|
||||
ac.abort()
|
||||
// Push-after-done: an event landing between abort and generator wind-down
|
||||
// must be dropped silently, not crash the queue.
|
||||
agentEvents(ctx, agent).emit('agent/error', 1, 1, new Error('late'))
|
||||
expect((await stream.next()).done).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('respond stub', () => {
|
||||
it('always reports not-pending (step2 registry pending)', async () => {
|
||||
const { api } = await boot()
|
||||
const receipt = await api.respond({ type: 'client-response', rpcId: RpcId('r'), result: { ok: true, value: null } })
|
||||
expect(receipt).toEqual({ accepted: false, reason: 'not-pending' })
|
||||
})
|
||||
})
|
||||
71
packages/host/runtime/tests/web-plugins.e2e.ts
Normal file
71
packages/host/runtime/tests/web-plugins.e2e.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Web UI plugin assembly: the in-memory Loader tree mounts all eight UI
|
||||
* packages (node halves), and the webserver registry built over it yields the
|
||||
* full __DSH_BOOT__ manifest — the P-I config-source bar end to end.
|
||||
*
|
||||
* The Loader imports plugin packages through their exports maps (lib/), so
|
||||
* this is a built-artifact e2e: it skips until the workspace build has run
|
||||
* (`pnpm run build`), like the other built-* e2e suites.
|
||||
*/
|
||||
import { existsSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { createHostWebPluginRegistry } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { WEB_UI_PLUGINS, mountWebPlugins } from '../src/web-plugins.ts'
|
||||
|
||||
const nodeRequire = createRequire(import.meta.url)
|
||||
const built = WEB_UI_PLUGINS.every((name) => {
|
||||
try {
|
||||
return existsSync(nodeRequire.resolve(name))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
let root: Context | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await root?.fiber.dispose()
|
||||
root = undefined
|
||||
})
|
||||
|
||||
describe.skipIf(!built)('mountWebPlugins + registry', () => {
|
||||
it('mounts the eight-package in-memory Loader tree and projects the boot manifest', async () => {
|
||||
root = new Context()
|
||||
const mounted = await mountWebPlugins(root)
|
||||
const registry = createHostWebPluginRegistry({
|
||||
ctx: root,
|
||||
loader: mounted.loader,
|
||||
resolvePkgJson: mounted.resolvePkgJson,
|
||||
onError: (err) => { throw err },
|
||||
})
|
||||
const rows = registry.snapshot()
|
||||
expect(rows.map(r => r.id)).toEqual([...WEB_UI_PLUGINS])
|
||||
// The infra four are the early-load group; the UI four are not.
|
||||
const immediate = rows.filter(r => r.immediately === true).map(r => r.id)
|
||||
expect(immediate).toEqual([
|
||||
'@deepseek-ai/dsh-client-connection',
|
||||
'@deepseek-ai/dsh-client-runtime',
|
||||
'@deepseek-ai/dsh-client-ui-theme',
|
||||
'@deepseek-ai/dsh-client-i18n',
|
||||
])
|
||||
// Every row resolves a client path under its own package dist/.
|
||||
for (const row of rows) {
|
||||
expect(registry.clientPath(row.id)).toMatch(/dist[/\\]client\.js$/)
|
||||
expect(row.url).toBe(`/plugins/${row.id}/client.js`)
|
||||
}
|
||||
registry.dispose()
|
||||
})
|
||||
|
||||
it('is idempotent: a second mount reuses the loader and creates no duplicate entries', async () => {
|
||||
root = new Context()
|
||||
await mountWebPlugins(root)
|
||||
const second = await mountWebPlugins(root)
|
||||
// ctx.loader hands out a fresh traced proxy per access, so loader identity
|
||||
// is not assertable; the observable contract is a single entry per package.
|
||||
const names = [...second.loader.entries()].map(e => e.options.name)
|
||||
.filter(n => (WEB_UI_PLUGINS as readonly string[]).includes(n))
|
||||
expect(names.length).toBe(WEB_UI_PLUGINS.length)
|
||||
})
|
||||
})
|
||||
114
packages/host/runtime/tests/web-plugins.spec.ts
Normal file
114
packages/host/runtime/tests/web-plugins.spec.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* mountWebPlugins unit coverage (keyless; the real eight-package walk is the
|
||||
* built-artifact e2e). The Loader-facing behavior — baseUrl anchoring, entry
|
||||
* creation with idempotent reuse, the fiber-less fail-loud sweep, and the
|
||||
* resolver seam — is exercised against a stubbed loader service so it runs
|
||||
* without built lib/ artifacts.
|
||||
*/
|
||||
import { Context } from 'cordis'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { WEB_UI_PLUGINS, mountWebPlugins } from '../src/web-plugins.ts'
|
||||
|
||||
interface FakeEntry {
|
||||
options: { name: string }
|
||||
fiber?: unknown
|
||||
disabled: boolean
|
||||
}
|
||||
|
||||
/** Loader stub provided under the real service name (mountWebPlugins skips ctx.plugin(Loader) when present). */
|
||||
class FakeLoader {
|
||||
readonly created: string[] = []
|
||||
awaited = 0
|
||||
constructor(private readonly entriesList: FakeEntry[], private readonly onCreate?: (name: string) => void) {}
|
||||
entries(): Iterable<FakeEntry> {
|
||||
return this.entriesList
|
||||
}
|
||||
async create(options: { name: string }): Promise<void> {
|
||||
this.created.push(options.name)
|
||||
this.onCreate?.(options.name)
|
||||
}
|
||||
async await(): Promise<void> {
|
||||
this.awaited += 1
|
||||
}
|
||||
}
|
||||
|
||||
let root: Context | undefined
|
||||
|
||||
afterEach(async () => {
|
||||
await root?.fiber.dispose()
|
||||
root = undefined
|
||||
})
|
||||
|
||||
function withLoader(entriesList: FakeEntry[], onCreate?: (name: string) => void): { ctx: Context; loader: FakeLoader } {
|
||||
root = new Context()
|
||||
const loader = new FakeLoader(entriesList, onCreate)
|
||||
root.reflect.provide('loader', loader)
|
||||
return { ctx: root, loader }
|
||||
}
|
||||
|
||||
describe('mountWebPlugins (stubbed loader)', () => {
|
||||
it('creates one entry per UI plugin, awaits the tree, and returns the loader view + resolver', async () => {
|
||||
const entriesList: FakeEntry[] = []
|
||||
const { ctx, loader } = withLoader(entriesList, (name) => {
|
||||
entriesList.push({ options: { name }, fiber: {}, disabled: false })
|
||||
})
|
||||
const mounted = await mountWebPlugins(ctx)
|
||||
expect(loader.created).toEqual([...WEB_UI_PLUGINS])
|
||||
expect(loader.awaited).toBe(1)
|
||||
expect([...mounted.loader.entries()].map(e => e.options.name)).toEqual([...WEB_UI_PLUGINS])
|
||||
// The resolver resolves this package's own manifest through real module resolution.
|
||||
expect(mounted.resolvePkgJson('@deepseek-ai/dsh-host-runtime')).toMatch(/package\.json$/)
|
||||
expect(ctx.baseUrl).toBeDefined()
|
||||
})
|
||||
|
||||
it('reuses existing entries (idempotent mount creates no duplicates)', async () => {
|
||||
const preexisting: FakeEntry[] = WEB_UI_PLUGINS.map(name => ({ options: { name }, fiber: {}, disabled: false }))
|
||||
const { ctx, loader } = withLoader(preexisting)
|
||||
await mountWebPlugins(ctx)
|
||||
expect(loader.created).toEqual([])
|
||||
})
|
||||
|
||||
it('throws listing every fiber-less entry (silent import failure must not drop a UI plugin)', async () => {
|
||||
const entriesList: FakeEntry[] = []
|
||||
const { ctx } = withLoader(entriesList, (name) => {
|
||||
// First two load; the rest stay fiber-less (import failed silently).
|
||||
entriesList.push({ options: { name }, fiber: entriesList.length < 2 ? {} : undefined, disabled: false })
|
||||
})
|
||||
await expect(mountWebPlugins(ctx)).rejects.toThrow(/UI plugin\(s\) failed to load: .*dsh-client-ui-theme/)
|
||||
})
|
||||
|
||||
it('skips disabled entries in the fail-loud sweep (disabled is the one valid fiber-less state)', async () => {
|
||||
const entriesList: FakeEntry[] = WEB_UI_PLUGINS.map(name => ({ options: { name }, fiber: undefined, disabled: true }))
|
||||
const { ctx } = withLoader(entriesList)
|
||||
await expect(mountWebPlugins(ctx)).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('mounts the real Loader when none is present (the ctx.plugin(Loader) branch)', async () => {
|
||||
root = new Context()
|
||||
// Environment-dependent outcome: with built lib/ the eight imports load
|
||||
// and the mount resolves; without them every entry stays fiber-less and
|
||||
// the sweep throws its loud list. Either way the branch under test is the
|
||||
// Loader auto-mount. Manual try/catch keeps cordis-traced proxies out of
|
||||
// expect()'s formatting path (pretty-format probes throw on them).
|
||||
// Plain string: the success sentinel and error text share one channel.
|
||||
let outcome: string
|
||||
try {
|
||||
await mountWebPlugins(root)
|
||||
outcome = 'resolved'
|
||||
} catch (error) {
|
||||
outcome = error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
expect(outcome === 'resolved' || /UI plugin\(s\) failed to load/.test(outcome)).toBe(true)
|
||||
expect(root.get('loader') !== undefined).toBe(true)
|
||||
}, 30_000) // built-env run imports eight real plugin packages through the Loader
|
||||
|
||||
it('keeps a caller-set baseUrl (anchors only when absent)', async () => {
|
||||
const entriesList: FakeEntry[] = []
|
||||
const { ctx } = withLoader(entriesList, (name) => {
|
||||
entriesList.push({ options: { name }, fiber: {}, disabled: false })
|
||||
})
|
||||
ctx.baseUrl = 'file:///caller/anchor/'
|
||||
await mountWebPlugins(ctx)
|
||||
expect(ctx.baseUrl).toBe('file:///caller/anchor/')
|
||||
})
|
||||
})
|
||||
144
packages/host/runtime/tsconfig.json
Normal file
144
packages/host/runtime/tsconfig.json
Normal file
@@ -0,0 +1,144 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/timer"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm-deepseek"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tasks"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent-loop"
|
||||
},
|
||||
{
|
||||
"path": "../../session-persistence/session-persistence-jsonl"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/bash-local"
|
||||
},
|
||||
{
|
||||
"path": "../../bash/tool-bash"
|
||||
},
|
||||
{
|
||||
"path": "../../compact/compact-basic"
|
||||
},
|
||||
{
|
||||
"path": "../../fs/fs-local"
|
||||
},
|
||||
{
|
||||
"path": "../../fs/fs-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../fs/tool-fs"
|
||||
},
|
||||
{
|
||||
"path": "../../fs/tool-fs-search"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/token-meter"
|
||||
},
|
||||
{
|
||||
"path": "../../skill/skill"
|
||||
},
|
||||
{
|
||||
"path": "../../skill/skill-local"
|
||||
},
|
||||
{
|
||||
"path": "../../skill/tool-skill"
|
||||
},
|
||||
{
|
||||
"path": "../../spill/spill-local"
|
||||
},
|
||||
{
|
||||
"path": "../../spill/spill-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/subagent-fork"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/subagent-spawn"
|
||||
},
|
||||
{
|
||||
"path": "../../subagent/tool-subagent"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../tasks/tool-tasks"
|
||||
},
|
||||
{
|
||||
"path": "../../timeout/timeout-policy"
|
||||
},
|
||||
{
|
||||
"path": "../../todo/tool-todo"
|
||||
},
|
||||
{
|
||||
"path": "../../workflow/tool-workflow"
|
||||
},
|
||||
{
|
||||
"path": "../../workflow/workflow-workerthread"
|
||||
},
|
||||
{
|
||||
"path": "../apiproxy"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/loader"
|
||||
},
|
||||
{
|
||||
"path": "../../client/connection"
|
||||
},
|
||||
{
|
||||
"path": "../../client/runtime"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-theme"
|
||||
},
|
||||
{
|
||||
"path": "../../client/i18n"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-layout"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-sidebar"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-conversation"
|
||||
},
|
||||
{
|
||||
"path": "../../client/ui-trajectory"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user