Merge remote-tracking branch 'origin/master' into codex/docs-graph-brainstorm
# Conflicts: # docs/module-graph.md # package.json # packages/core/tools/tests/gen-tool-catalog.spec.ts
This commit is contained in:
@@ -824,7 +824,7 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
|
||||
const ctx = await setup()
|
||||
const present = ctx.tools.get('bash')!.presentResult!(
|
||||
{ command: 'x', description: 'x' },
|
||||
{ content: [{ type: 'image', url: 'https://x/y.png' }], isError: false },
|
||||
{ content: [{ type: 'reasoning', text: 'unexpected' }], isError: false },
|
||||
)
|
||||
expect(present).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -8,10 +8,10 @@ This is the implementation tier of the compaction capability — see the [interf
|
||||
|
||||
The abstract contract states only WHAT compaction does; this backend owns every HOW decision:
|
||||
|
||||
- **Token estimation** — `estimateContentTokens()`: chars divided by the `charsPerToken` config (default 4) with per-block structural overhead (`text`/`reasoning` = `ceil(len/charsPerToken) + 4`, `tool-call` from name + arguments, `tool-result` recursive, `image` = 85, unknown blocks via JSON length).
|
||||
- **Token estimation** — `estimateContentTokens()`: chars divided by the `charsPerToken` config (default 4) with per-block structural overhead (`text`/`reasoning` = `ceil(len/charsPerToken) + 4`, `tool-call` from name + arguments, `tool-result` recursive, unknown blocks via JSON length).
|
||||
- **Retention policy** — `compactIfNeeded()` walks the surface nodes tail→head summing per-node token estimates, and retains the smallest tail-run of WHOLE units (a closed step, or a single no-step node such as a pre-step `user/message` or inter-step `steering/message`) whose total reaches `retainTokens`; everything older is compacted. Retention is **turn-agnostic** — turn boundaries play no role, so a single runaway turn that alone exceeds the window compacts its OWN early closed steps rather than being retained verbatim (the failure mode that motivated dropping turn-protection: a tool-heavy turn must stay compactable or the harness dies exactly when compaction is needed). The only structural guard is **tool-pairing balance**: the compacted region's edges are balanced cuts on the surface (no unanswered tool-call crosses either edge), so it never splits a step's `assistant/message` tool-calls from their `tool/result`s. When the only compactable content left is an un-splittable open tail step, it declines (returns `null`) and retries once an older step closes. **Single-unit overflow is out of scope, by design**: if one retained unit (a single closed step, or a large pasted `user/message`) ALONE exceeds the budget, compaction cannot help and the call may go out over-budget — bounding an individual unit's size is a separate concern. `compactRegion()` enforces tool-pairing balance strictly, throwing on a boundary that would split a step. `dsh-session` exports `isToolPairingBalanced` for the check.
|
||||
- **Dynamic convergence** — no static summary-length config pretends to bound what the model will write. If framing/estimator/system overhead leaves the compacted surface above threshold, `compactIfNeeded()` re-compacts the head checkpoint up to `compactionRetries` extra times; if it still cannot get below threshold, it throws. A summary whose estimated stored size is not smaller than the shadowed content fails closed before it mutates the surface.
|
||||
- **Summarization** — `summarize()`: a `GenerateOptions` request assembled via `BlockAssembler` with a fixed system prompt that asks for a structured checkpoint (Primary Request and Intent · Key Technical Concepts · Files and Code · Errors and Fixes · Pending Tasks · Current Work · Next Step · Critical Context), every section mandatory, exact paths/commands/identifiers preserved. The request runs through the `agent/request` waterfall before `ctx.llm.stream()`, so router agents that choose the concrete model there also route compaction summaries. `maxTokens` is the provider-side generation cap; only text blocks from the model's reply are kept before the checkpoint is stored (reasoning is dropped so private chain-of-thought never leaks into the durable summary, and a stray `tool-call` is dropped so the synthesized `user/message` summary cannot land an orphaned call with no matching `tool-result`). The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (image, tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`, …) so the summarizer is told what existed rather than silently dropping it.
|
||||
- **Summarization** — `summarize()`: a `GenerateOptions` request assembled via `BlockAssembler` with a fixed system prompt that asks for a structured checkpoint (Primary Request and Intent · Key Technical Concepts · Files and Code · Errors and Fixes · Pending Tasks · Current Work · Next Step · Critical Context), every section mandatory, exact paths/commands/identifiers preserved. The request runs through the `agent/request` waterfall before `ctx.llm.stream()`, so router agents that choose the concrete model there also route compaction summaries. `maxTokens` is the provider-side generation cap; only text blocks from the model's reply are kept before the checkpoint is stored (reasoning is dropped so private chain-of-thought never leaks into the durable summary, and a stray `tool-call` is dropped so the synthesized `user/message` summary cannot land an orphaned call with no matching `tool-result`). The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[tool-call: name(args)]`, `[tool-result: …]`, …) so the summarizer is told what existed rather than silently dropping it.
|
||||
- **Checkpoint framing** — the raw summary is not landed directly. `compactRegion()` wraps it in a checkpoint preamble (so a resuming model reads it as a checkpoint, not a fresh user request, and builds on the captured context rather than restating it) plus `<compacted-summary>…</compacted-summary>` tags. Because region compaction can be invoked manually, a surface may hold several checkpoints, so the framing does not claim everything after it is recent or verbatim. The tags make a prior checkpoint detectable in the transcript on the next compaction cycle: the summarization prompt then instructs the model to merge it in place (preserve still-true facts, drop stale ones) rather than re-summarize it verbatim — a cheap incremental merge that needs no extra log/event machinery. The unframed summary stays on the `compact/summary` provenance event.
|
||||
- **Surface mutation** — `compactRegion()` appends the `compact/start` → `compact/summary` → `compact/end` log records and the single `user/message` replace node carrying the framed summary (see the interface README).
|
||||
- **Auto-compaction** — an `agent/pre-step` listener delegates to `compactIfNeeded()` before every step (not just a turn's first — a tool-heavy turn grows the surface mid-turn, so a runaway turn still compacts, and per-step firing is the only moment to rescue it before overflow). `agent/pre-step` is a serial (awaited, in-order) surface-mutation checkpoint that fires after `turn/start` and BEFORE the step opens (`step/start`) and its request history is derived, so compaction mutates the surface — with its log-only `compact/*` records landing cleanly outside any step — and the loop derives once from the result: no double-derive, and the listener cannot see (or need to rewrite) an already-assembled `messages` array. The listener owns no threshold logic of its own (the single token-pressure check lives in `compactIfNeeded()`); because Cordis `serial` bails early on non-void return values, the listener returns `void` and does not use the dispatcher's bail channel as a veto surface.
|
||||
|
||||
@@ -45,9 +45,6 @@ export { resolveConfig } from './types.ts'
|
||||
/** Per-block structural overhead for JSON framing / type tag. */
|
||||
const BLOCK_OVERHEAD = 4
|
||||
|
||||
/** Heuristic token count for an image block (~85 tokens for low-res URL). */
|
||||
const IMAGE_TOKEN_COST = 85
|
||||
|
||||
/** Role-field framing overhead added per message in {@link BasicCompactService.estimateTokens}. */
|
||||
const ROLE_OVERHEAD = 4
|
||||
|
||||
@@ -236,9 +233,6 @@ export class BasicCompactService extends CompactService {
|
||||
case 'tool-result':
|
||||
tokens += this.estimateContentTokens(block.content) + BLOCK_OVERHEAD
|
||||
break
|
||||
case 'image':
|
||||
tokens += IMAGE_TOKEN_COST
|
||||
break
|
||||
default:
|
||||
// Unknown block types (merge-extensible ContentBlockMap):
|
||||
// estimate conservatively via JSON stringify.
|
||||
@@ -712,10 +706,10 @@ export class BasicCompactService extends CompactService {
|
||||
/**
|
||||
* Render content blocks to a single plain-text string for the summarization
|
||||
* prompt. Text and reasoning contribute their text; every other block type
|
||||
* contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`,
|
||||
* …) so the summarizer is told what non-text content existed in the region
|
||||
* rather than silently losing it. Blocks join with newlines; empty-text
|
||||
* blocks contribute nothing.
|
||||
* contributes a type-tagged placeholder (`[tool-call: name(args)]`,
|
||||
* `[tool-result: …]`, …) so the summarizer is told what non-text content
|
||||
* existed in the region rather than silently losing it. Blocks join with
|
||||
* newlines; empty-text blocks contribute nothing.
|
||||
*/
|
||||
private _blocksToText(blocks: readonly ContentBlock[]): string {
|
||||
const parts: string[] = []
|
||||
@@ -735,9 +729,6 @@ export class BasicCompactService extends CompactService {
|
||||
parts.push(inner ? `[tool-result: ${inner}]` : '[tool-result]')
|
||||
break
|
||||
}
|
||||
case 'image':
|
||||
parts.push('[image]')
|
||||
break
|
||||
// ContentBlockMap is merge-extensible — render an unknown block as a
|
||||
// bare type-tagged placeholder so a plugin-added block type is still
|
||||
// signalled to the summarizer rather than dropped.
|
||||
|
||||
@@ -811,11 +811,6 @@ describe('BasicCompactService token estimation (char/4 heuristic)', () => {
|
||||
])).toBe(10)
|
||||
})
|
||||
|
||||
it('estimates image blocks at fixed 85 tokens', () => {
|
||||
const svc = new BasicCompactService(new Context(), cfg({ auto: false }))
|
||||
expect(svc.estimateContentTokens([{ type: 'image', url: 'https://example.com/img.png' }])).toBe(85)
|
||||
})
|
||||
|
||||
it('returns 0 for empty content blocks', () => {
|
||||
const svc = new BasicCompactService(new Context(), cfg({ auto: false }))
|
||||
expect(svc.estimateContentTokens([])).toBe(0)
|
||||
@@ -1341,7 +1336,7 @@ describe('BasicCompactService edge cases', () => {
|
||||
s.append('assistant/message', {
|
||||
turn: 1, step: 1,
|
||||
content: [
|
||||
{ type: 'tool-result', toolCallId: CallId('n1'), content: [{ type: 'image', url: 'https://x/n.png' }] },
|
||||
{ type: 'tool-result', toolCallId: CallId('n1'), content: [{ type: 'chart', data: 'x' } as unknown as ContentBlock] },
|
||||
{ type: 'custom-widget', payload: 'x' } as unknown as ContentBlock,
|
||||
{ type: 'tool-call', id: CallId('b1'), name: 'bash', arguments: '{}' },
|
||||
],
|
||||
@@ -1360,7 +1355,7 @@ describe('BasicCompactService edge cases', () => {
|
||||
const nodes = s.surface.nodes
|
||||
await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm')
|
||||
const { text } = svc.summarizeCalls[0]!
|
||||
expect(text).toContain('[tool-result: [image]]') // nested tool-result with content
|
||||
expect(text).toContain('[tool-result: [chart]]') // nested tool-result with content
|
||||
expect(text).toContain('[custom-widget]') // unknown block placeholder
|
||||
expect(text).toContain('Tool result (call b1): [tool-result]') // empty nested → bare placeholder
|
||||
})
|
||||
@@ -1527,25 +1522,28 @@ describe('BasicCompactService edge cases', () => {
|
||||
it('renders non-text blocks as type-tagged placeholders across all message kinds', async () => {
|
||||
const svc = createTestService()
|
||||
const s = new Session(SessionId('placeholders'))
|
||||
// A plugin-added block type (merge-extensible ContentBlockMap) — the
|
||||
// placeholder path must cover every message kind, not just assistant.
|
||||
const chart = (id: string): ContentBlock => ({ type: 'chart', data: id } as unknown as ContentBlock)
|
||||
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
s.append('step/start', { turn: 1, step: 1 })
|
||||
// user/message with only an image block → '[image]' placeholder.
|
||||
s.append('user/message', { content: [{ type: 'image', url: 'https://x/y.png' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
// assistant/message with an image block AND the tool-call its tool/result
|
||||
// answers (so the surface is tool-pairing balanced) → '[image]' placeholder.
|
||||
// user/message with only a plugin-added block → '[chart]' placeholder.
|
||||
s.append('user/message', { content: [chart('y')], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
// assistant/message with a plugin-added block AND the tool-call its
|
||||
// tool/result answers (so the surface is tool-pairing balanced).
|
||||
s.append('assistant/message', {
|
||||
turn: 1, step: 1,
|
||||
content: [
|
||||
{ type: 'image', url: 'https://x/z.png' },
|
||||
chart('z'),
|
||||
{ type: 'tool-call', id: CallId('e1'), name: 'bash', arguments: '{}' },
|
||||
],
|
||||
}, { surfaceOp: 'append' })
|
||||
// tool/result with an image block → '[image]' placeholder.
|
||||
// tool/result with a plugin-added block → '[chart]' placeholder.
|
||||
s.append('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'bash', arguments: '{}' })
|
||||
s.append('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'image', url: 'https://x/r.png' }], isError: false }, { surfaceOp: 'append' })
|
||||
// context/message and steering/message with image content.
|
||||
s.append('context/message', { content: [{ type: 'image', url: 'https://x/c.png' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('steering/message', { turn: 1, content: [{ type: 'image', url: 'https://x/s.png' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [chart('r')], isError: false }, { surfaceOp: 'append' })
|
||||
// context/message and steering/message with plugin-added content.
|
||||
s.append('context/message', { content: [chart('c')], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('steering/message', { turn: 1, content: [chart('s')], source: { kind: 'user' } }, { surfaceOp: 'append' })
|
||||
s.append('step/end', { turn: 1, step: 1 })
|
||||
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
@@ -1554,11 +1552,11 @@ describe('BasicCompactService edge cases', () => {
|
||||
await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm')
|
||||
const { text } = svc.summarizeCalls[0]!
|
||||
// Every non-text block surfaces as a placeholder rather than being dropped.
|
||||
expect(text).toContain('User: [image]')
|
||||
expect(text).toContain('Assistant: [image]')
|
||||
expect(text).toContain('Tool result (call e1): [image]')
|
||||
expect(text).toContain('[Context: [image]]')
|
||||
expect(text).toContain('[Steering: [image]]')
|
||||
expect(text).toContain('User: [chart]')
|
||||
expect(text).toContain('Assistant: [chart]')
|
||||
expect(text).toContain('Tool result (call e1): [chart]')
|
||||
expect(text).toContain('[Context: [chart]]')
|
||||
expect(text).toContain('[Steering: [chart]]')
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@@ -43,13 +43,7 @@ Compaction is serialized via a log-recorded lock: `compactRegion` refuses to sta
|
||||
|
||||
## Events
|
||||
|
||||
The `compact/*` events extend `SessionEventMap` (merge-extensible) via declaration merging — they are session events, not cordis `Events`:
|
||||
|
||||
| Event | Payload | On surface? |
|
||||
|---|---|---|
|
||||
| `compact/start` | `{ turn }` | no (log-only) |
|
||||
| `compact/summary` | `{ summary, shadowedRange, shadowedSeqs, shadowedTokenCount }` | no (log-only) |
|
||||
| `compact/end` | `{ turn, error? }` | no (log-only) |
|
||||
The `compact/*` events extend `SessionEventMap` (merge-extensible) via declaration merging — they are session events, not cordis `Events`, and all three are log-only (no `surfaceOp`). Per-event payloads and semantics are in the generated [persistence log event catalog](../../../docs/persistence-catalog/log-events.md).
|
||||
|
||||
## Implementing a backend
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ export interface LoopHandle {
|
||||
* → dispatch → tools/post-execute
|
||||
* session('tool/result')
|
||||
* append buffered post-execute additionalContext → session('context/message')(s)
|
||||
* drain steering → session('steering/message'); emit agent/steering
|
||||
* drain steering → session('steering/message')
|
||||
* session('step/end') ⟵ durable step boundary (no agent/* mirror)
|
||||
* cont = waterfall agent/turn-continuation ⟵ ContinuationDecision; default
|
||||
* {action: hadToolCalls||steered ? 'continue':'stop'}; a continue.reason is
|
||||
@@ -420,7 +420,7 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
|
||||
|
||||
// Steering from the previous round's continuation listeners joins before
|
||||
// the request.
|
||||
drainSteering(ctx, agent, turn)
|
||||
drainSteering(agent, turn)
|
||||
|
||||
// The step's AbortController exists BEFORE any async pre-step work so a
|
||||
// dispose() or cancel() — in a synchronous turn-start listener or an
|
||||
@@ -529,7 +529,7 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
|
||||
if (stepReason) reason = stepReason
|
||||
|
||||
// Steering that arrived during streaming/tool execution.
|
||||
const steered = drainSteering(ctx, agent, turn)
|
||||
const steered = drainSteering(agent, turn)
|
||||
|
||||
if (closeStep()) break
|
||||
|
||||
@@ -635,11 +635,10 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
|
||||
}
|
||||
|
||||
/** Drain the steering queue into the session. Returns whether any arrived. */
|
||||
function drainSteering(ctx: Context, agent: ReactLoopAgent, turn: number): boolean {
|
||||
function drainSteering(agent: ReactLoopAgent, turn: number): boolean {
|
||||
const messages = agent.inbox.drainSteering()
|
||||
for (const message of messages) {
|
||||
agent.session.append('steering/message', { turn, content: message.content, source: message.source }, { surfaceOp: 'append' })
|
||||
ctx.emit('agent/steering', agent, turn, message.content, message.source)
|
||||
}
|
||||
return messages.length > 0
|
||||
}
|
||||
|
||||
@@ -410,7 +410,7 @@ describe('MEDIUM: misc registry and config fixes', () => {
|
||||
expect(agent.session.deriveMessages().at(-1)?.content).toEqual([{ type: 'text', text: 'routed' }])
|
||||
})
|
||||
|
||||
it('agent/queued carries the resolved source; agent/steering carries its source', async () => {
|
||||
it('agent/queued carries the resolved source; steering/message records its source', async () => {
|
||||
const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')])
|
||||
const ctx = await harness(adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
@@ -425,15 +425,16 @@ describe('MEDIUM: misc registry and config fixes', () => {
|
||||
}))
|
||||
|
||||
const queuedSources: { source: MessageSource; steering: boolean }[] = []
|
||||
const steeringSources: MessageSource[] = []
|
||||
ctx.on('agent/queued', (_agent, _content, info) => void queuedSources.push(info))
|
||||
ctx.on('agent/steering', (_agent, _turn, _content, source) => void steeringSources.push(source))
|
||||
|
||||
send(agent, 'go') // no explicit source → default {kind:'user'} must be visible
|
||||
await waitForIdle(ctx, agent)
|
||||
|
||||
expect(queuedSources[0]).toEqual({ source: { kind: 'user' }, steering: false })
|
||||
expect(queuedSources[1]).toEqual({ source: { kind: 'plugin', plugin: 'goal' }, steering: true })
|
||||
// The drain appends the durable steering/message with the caller's source
|
||||
// intact — the log, not a transient emit, is where consumers read it.
|
||||
const steeringSources = agent.session.events.flatMap(e => e.type === 'steering/message' ? [e.data.source] : [])
|
||||
expect(steeringSources).toEqual([{ kind: 'plugin', plugin: 'goal' }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -50,9 +50,8 @@ Turn and step boundaries are NOT mirrored as `agent/*` emits: a consumer that ne
|
||||
|
||||
Tool interception is the `tools/pre-execute` / `tools/post-execute` pair in [`dsh-tools`](../tools/README.md) (`PreToolDecision` allow/deny/ask, `PostToolDecision` accept/block) — same typed-Decision idiom, owned there because it is the tool registry's seam.
|
||||
|
||||
#### Live control notifications (emit)
|
||||
#### Error notifications (emit)
|
||||
|
||||
- `agent/steering` — steering content injected mid-turn
|
||||
- `agent/error` — step/turn error
|
||||
|
||||
The model's token stream is NOT an `agent/*` event: read it off the durable `session/event` feed as `assistant/chunk` (the same feed persistence and the ACP bridge use).
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
* `agent/request`/`agent/step-result`/`agent/turn-continuation` waterfalls and
|
||||
* the serial `agent/pre-step`) that mutate/veto, and TRANSIENT emits
|
||||
* (`agent/status`, `agent/error`, `agent/created`/
|
||||
* `agent/disposed`, `agent/queued`, `agent/steering`, `agent/session-start`)
|
||||
* `agent/disposed`, `agent/queued`, `agent/session-start`)
|
||||
* that notify with the `Agent` in hand. Turn/step boundaries are NOT here —
|
||||
* they are durable `session/event` records. Answers "right now, with the agent
|
||||
* object — intercept or observe."
|
||||
@@ -367,16 +367,7 @@ declare module 'cordis' {
|
||||
*/
|
||||
'agent/turn-continuation'(agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
|
||||
|
||||
// ---- streaming + tool notifications (emit) ----
|
||||
/**
|
||||
* Steering content was injected into a running turn.
|
||||
* @param agent - the agent that absorbed the steering.
|
||||
* @param turn - the running turn that received it.
|
||||
* @param content - the injected blocks.
|
||||
* @param source - the steering message's resolved source.
|
||||
* @mode emit
|
||||
*/
|
||||
'agent/steering'(agent: Agent, turn: number, content: ContentBlock[], source: MessageSource): void
|
||||
// ---- error notifications (emit) ----
|
||||
/**
|
||||
* A step or turn errored. The loop reports a failure here (plus the logger)
|
||||
* even when the error has no in-turn position for a session `error` event.
|
||||
|
||||
@@ -49,9 +49,9 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
|
||||
|
||||
### Session event vocabulary (`types.ts`)
|
||||
|
||||
The append-only log: `turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `prompt/blocked`, `assistant/message`, `assistant/chunk`, `tool/call`, `tool/result`, `steering/message`, `context/message`, `todo/write`. Token usage rides on `assistant/message.usage`; an operational error's step is on `turn/end.reason` for `kind: 'error'`.
|
||||
The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog/log-events.md). Token usage rides on `assistant/message.usage`; an operational error's step is on `turn/end.reason` for `kind: 'error'`.
|
||||
|
||||
Merge-extensible via `SessionEventMap` — the compaction seam adds `compact/start`, `compact/summary`, and `compact/end`.
|
||||
Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, the hook bridges' `hook/*`); merged members appear in the same catalog.
|
||||
|
||||
Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings).
|
||||
|
||||
|
||||
@@ -91,7 +91,6 @@ export interface CreateSessionOptions {
|
||||
*/
|
||||
export interface TurnTriggerMap {
|
||||
message: { kind: 'message'; source: MessageSource }
|
||||
continuation: { kind: 'continuation' }
|
||||
/**
|
||||
* An out-of-band context injection (`agent.inject()`) made while the agent
|
||||
* was idle. The loop wraps the injected `context/message` in a one-shot turn
|
||||
@@ -198,9 +197,22 @@ export interface TodoItem {
|
||||
* the invariants plugin checks, is a breaking change to the on-disk format.
|
||||
*/
|
||||
export interface SessionEventMap {
|
||||
/**
|
||||
* Opens turn `turn`. `trigger` records what started it — a drained message
|
||||
* batch or an idle-time injection. The turn is the durability/replay
|
||||
* boundary: every event sits between a `turn/start` and its matching
|
||||
* `turn/end` (the turn-enclosure invariant).
|
||||
*/
|
||||
'turn/start': { turn: number; trigger: TurnTrigger }
|
||||
/**
|
||||
* Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop
|
||||
* fires the awaited `session/flush` checkpoint at every turn end, so the turn
|
||||
* boundary is also the durable-commit boundary.
|
||||
*/
|
||||
'turn/end': { turn: number; reason: TurnEndReason }
|
||||
/** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */
|
||||
'step/start': { turn: number; step: number }
|
||||
/** Closes step `step` of turn `turn`. */
|
||||
'step/end': { turn: number; step: number }
|
||||
/** A user-visible prompt (queued message drained at turn start). */
|
||||
'user/message': { content: ContentBlock[]; source: MessageSource }
|
||||
@@ -230,6 +242,11 @@ export interface SessionEventMap {
|
||||
* usage record). `usage` is absent when the adapter reported none.
|
||||
*/
|
||||
'assistant/message': { turn: number; step: number; content: ContentBlock[]; usage?: TokenUsage }
|
||||
/**
|
||||
* The model requested one tool invocation: `name` with the raw `arguments`
|
||||
* JSON string exactly as the model produced it (unparsed). `callId` pairs the
|
||||
* call with its `tool/result`.
|
||||
*/
|
||||
'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string }
|
||||
/**
|
||||
* A completed tool call's model-facing result, plus an optional tool-private
|
||||
|
||||
225
packages/core/session/tests/gen-persistence-catalog.spec.ts
Normal file
225
packages/core/session/tests/gen-persistence-catalog.spec.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Negative-path tests for the persistence log catalog generator
|
||||
* (`scripts/gen-persistence-catalog.ts`).
|
||||
*
|
||||
* The generated catalog is frozen by a regenerate-and-diff freshness gate, so
|
||||
* the freshness half is exercised by `pnpm run verify-persistence-catalog` in
|
||||
* CI. What a freshness diff CANNOT prove is that the generator REJECTS
|
||||
* malformed source the way it promises to — a member without description
|
||||
* prose, a forbidden `@mode` tag, a non-literal member name, a duplicate event
|
||||
* declaration, a missing or ambiguous `SurfaceEventType` union, a stale union
|
||||
* member. These tests drive the exported collectors against synthetic fixture
|
||||
* packages to prove each guard fires (and that well-formed declarations pass),
|
||||
* mirroring the gen-cordis-catalog negative tests.
|
||||
*/
|
||||
|
||||
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
annotateSurface,
|
||||
collectLogEvents,
|
||||
collectSurfaceEventTypes,
|
||||
render,
|
||||
} from '../../../../scripts/gen-persistence-catalog.ts'
|
||||
|
||||
/** Create a fixture scan root; `files` maps `packages/…`-relative paths to source. */
|
||||
function fixtureRoot(files: Record<string, string>): string {
|
||||
const root = mkdtempSync(join(tmpdir(), 'persistence-catalog-'))
|
||||
for (const [rel, source] of Object.entries(files)) {
|
||||
const abs = join(root, rel)
|
||||
mkdirSync(join(abs, '..'), { recursive: true })
|
||||
writeFileSync(abs, source)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
const roots: string[] = []
|
||||
const make = (files: Record<string, string>): string => {
|
||||
const r = fixtureRoot(files)
|
||||
roots.push(r)
|
||||
return r
|
||||
}
|
||||
|
||||
/** A merge-form declaration file wrapping `members` in the session module. */
|
||||
const merge = (members: string): string =>
|
||||
`declare module '@deepseek-ai/dsh-session' {\n interface SessionEventMap {\n${members}\n }\n}\n`
|
||||
|
||||
afterEach(() => {
|
||||
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** The manifest that marks a fixture package as the owning session package. */
|
||||
const OWNER_MANIFEST = '{ "name": "@deepseek-ai/dsh-session" }\n'
|
||||
|
||||
describe('gen-persistence-catalog collectLogEvents', () => {
|
||||
it('extracts a documented member of the owning top-level interface', () => {
|
||||
const events = collectLogEvents(make({
|
||||
'packages/core/fix/package.json': OWNER_MANIFEST,
|
||||
'packages/core/fix/src/types.ts':
|
||||
'export interface SessionEventMap {\n /** A thing was recorded. */\n \'fix/happened\': { turn: number }\n}\n',
|
||||
}))
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]).toMatchObject({
|
||||
name: 'fix/happened',
|
||||
scope: 'fix',
|
||||
doc: 'A thing was recorded.',
|
||||
payload: '{ turn: number }',
|
||||
source: 'packages/core/fix/src/types.ts:3',
|
||||
})
|
||||
})
|
||||
|
||||
it('hard-errors on a top-level interface outside the owning package', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/group/alien/package.json': '{ "name": "@deepseek-ai/dsh-alien" }\n',
|
||||
'packages/group/alien/src/types.ts':
|
||||
'export interface SessionEventMap {\n /** Not the real vocabulary. */\n \'alien/event\': { turn: number }\n}\n',
|
||||
}))).toThrow(/top-level interface SessionEventMap .* is outside @deepseek-ai\/dsh-session \(package @deepseek-ai\/dsh-alien\)/)
|
||||
})
|
||||
|
||||
it('hard-errors on a non-exported top-level interface even in the owning package', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/core/fix/package.json': OWNER_MANIFEST,
|
||||
'packages/core/fix/src/helper.ts':
|
||||
'interface SessionEventMap {\n /** A local helper, not the vocabulary. */\n \'fix/local\': { turn: number }\n}\nexport const use: SessionEventMap | null = null\n',
|
||||
}))).toThrow(/is not exported; the owning vocabulary is the single exported declaration/)
|
||||
})
|
||||
|
||||
it('hard-errors when the owning interface is exported from two files', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/core/fix/package.json': OWNER_MANIFEST,
|
||||
'packages/core/fix/src/a.ts': 'export interface SessionEventMap {\n /** First home. */\n \'fix/a\': { turn: number }\n}\n',
|
||||
'packages/core/fix/src/b.ts': 'export interface SessionEventMap {\n /** Second home. */\n \'fix/b\': { turn: number }\n}\n',
|
||||
}))).toThrow(/is already declared at packages\/core\/fix\/src\/a\.ts:1; the owning vocabulary has exactly one home/)
|
||||
})
|
||||
|
||||
it('hard-errors on an extends clause (inherited keys would escape the catalog)', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/group/fix/src/types.ts':
|
||||
'interface Extra { \'fix/hidden\': { turn: number } }\ndeclare module \'@deepseek-ai/dsh-session\' {\n interface SessionEventMap extends Extra {\n /** Declared directly. */\n \'fix/direct\': { turn: number }\n }\n}\n',
|
||||
}))).toThrow(/uses extends; inherited keys would join keyof SessionEventMap without a catalog row/)
|
||||
})
|
||||
|
||||
it('extracts a member declaration-merged via the session module', () => {
|
||||
const events = collectLogEvents(make({
|
||||
'packages/group/fix/src/types.ts': merge(' /** Merged provenance. */\n \'fix/merged\': { id: string }'),
|
||||
}))
|
||||
expect(events).toHaveLength(1)
|
||||
expect(events[0]).toMatchObject({ name: 'fix/merged', doc: 'Merged provenance.' })
|
||||
})
|
||||
|
||||
it('collapses a newline-separated multi-line payload to a valid one-line fragment', () => {
|
||||
const events = collectLogEvents(make({
|
||||
'packages/group/fix/src/types.ts': merge(
|
||||
' /** Wide payload. */\n \'fix/wide\': {\n alpha: string[]\n range: { start: number; end: number }\n count: number\n }',
|
||||
),
|
||||
}))
|
||||
expect(events[0]?.payload).toBe('{ alpha: string[]; range: { start: number; end: number }; count: number }')
|
||||
})
|
||||
|
||||
it('hard-errors on a member with no description prose', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/group/fix/src/types.ts': merge(' \'fix/undocumented\': { turn: number }'),
|
||||
}))).toThrow(/no description prose/)
|
||||
})
|
||||
|
||||
it('hard-errors on an @mode tag (a log event has no dispatch mode)', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/group/fix/src/types.ts': merge(' /**\n * Documented, but mistagged.\n * @mode emit\n */\n \'fix/tagged\': { turn: number }'),
|
||||
}))).toThrow(/carries an @mode tag/)
|
||||
})
|
||||
|
||||
it('hard-errors on an extra-indented @mode tag (does not leak into prose)', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/group/fix/src/types.ts': merge(' /**\n * Documented, but mistagged.\n * @mode emit\n */\n \'fix/indented\': { turn: number }'),
|
||||
}))).toThrow(/carries an @mode tag/)
|
||||
})
|
||||
|
||||
it('hard-errors on a method-form member (it still joins keyof SessionEventMap)', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/group/fix/src/types.ts': merge(' /** Documented, wrong shape. */\n \'fix/method\'(turn: number): void'),
|
||||
}))).toThrow(/not a property signature with an explicit payload type/)
|
||||
})
|
||||
|
||||
it('hard-errors on a property member with no payload type annotation', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/group/fix/src/types.ts': merge(' /** Documented, no payload. */\n \'fix/bare\''),
|
||||
}))).toThrow(/not a property signature with an explicit payload type/)
|
||||
})
|
||||
|
||||
it('hard-errors on a non-literal member name', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/group/fix/src/types.ts': merge(' /** Not a literal. */\n unquoted: { turn: number }'),
|
||||
}))).toThrow(/non-literal name/)
|
||||
})
|
||||
|
||||
it('hard-errors when the same event is declared twice', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/group/fix/src/a.ts': merge(' /** First. */\n \'fix/dup\': { turn: number }'),
|
||||
'packages/group/fix/src/b.ts': merge(' /** Second. */\n \'fix/dup\': { turn: number }'),
|
||||
}))).toThrow(/already declared at packages\/group\/fix\/src\/a\.ts/)
|
||||
})
|
||||
|
||||
it('aggregates every violation into one error instead of failing fast', () => {
|
||||
expect(() => collectLogEvents(make({
|
||||
'packages/group/fix/src/types.ts': merge(' \'fix/one\': { turn: number }\n \'fix/two\': { turn: number }'),
|
||||
}))).toThrow(/2 JSDoc completeness violation\(s\)[\s\S]*fix\/one[\s\S]*fix\/two/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gen-persistence-catalog collectSurfaceEventTypes', () => {
|
||||
it('parses the literal union', () => {
|
||||
const types = collectSurfaceEventTypes(make({
|
||||
'packages/core/fix/src/types.ts': 'export type SurfaceEventType = \'fix/a\' | \'fix/b\'\n',
|
||||
}))
|
||||
expect(types).toEqual(['fix/a', 'fix/b'])
|
||||
})
|
||||
|
||||
it('hard-errors when no union is declared', () => {
|
||||
expect(() => collectSurfaceEventTypes(make({
|
||||
'packages/core/fix/src/types.ts': 'export const unrelated = 1\n',
|
||||
}))).toThrow(/no SurfaceEventType union found/)
|
||||
})
|
||||
|
||||
it('hard-errors when the union is declared more than once', () => {
|
||||
expect(() => collectSurfaceEventTypes(make({
|
||||
'packages/core/fix/src/a.ts': 'export type SurfaceEventType = \'fix/a\'\n',
|
||||
'packages/core/fix/src/b.ts': 'export type SurfaceEventType = \'fix/b\'\n',
|
||||
}))).toThrow(/declared more than once/)
|
||||
})
|
||||
|
||||
it('hard-errors on a non-string-literal union member', () => {
|
||||
expect(() => collectSurfaceEventTypes(make({
|
||||
'packages/core/fix/src/types.ts': 'export type SurfaceEventType = \'fix/a\' | number\n',
|
||||
}))).toThrow(/non-string-literal member/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('gen-persistence-catalog annotateSurface + render', () => {
|
||||
const entry = (name: string) => ({
|
||||
name,
|
||||
scope: name.split('/')[0] ?? name,
|
||||
payload: '{ turn: number }',
|
||||
doc: `Records ${name}.`,
|
||||
source: 'packages/core/fix/src/types.ts:3',
|
||||
})
|
||||
|
||||
it('badges union members surface and everything else log-only', () => {
|
||||
const annotated = annotateSurface([entry('fix/message'), entry('fix/marker')], ['fix/message'])
|
||||
expect(annotated.map(e => [e.name, e.surface])).toEqual([['fix/message', true], ['fix/marker', false]])
|
||||
})
|
||||
|
||||
it('hard-errors on a union member naming no declared event', () => {
|
||||
expect(() => annotateSurface([entry('fix/marker')], ['fix/ghost']))
|
||||
.toThrow(/'fix\/ghost' name no declared log event/)
|
||||
})
|
||||
|
||||
it('renders badges, payload fences, and the generated-file header', () => {
|
||||
const out = render(annotateSurface([entry('fix/message'), entry('fix/marker')], ['fix/message']))
|
||||
expect(out).toContain('Generated by scripts/gen-persistence-catalog.ts')
|
||||
expect(out).toContain('#### `fix/message` — surface')
|
||||
expect(out).toContain('#### `fix/marker` — log-only')
|
||||
expect(out).toContain('```ts persistence-catalog\n\'fix/marker\': { turn: number }\n```')
|
||||
})
|
||||
})
|
||||
@@ -317,21 +317,20 @@ export class ToolRegistry extends Service {
|
||||
|
||||
/**
|
||||
* Return all registered tool schemas — exactly the model-facing fields
|
||||
* (`name`, `description`, `parameters`, and `strict` when set), as sent to the
|
||||
* model via the system-prompt assembly. Constructed EXPLICITLY rather than by
|
||||
* stripping known non-schema members: a `ToolDefinition` also carries
|
||||
* `execute` and the optional `presentCall`/`presentResult` UI callbacks, and
|
||||
* those (especially the functions) must never leak into a model request. An
|
||||
* allowlist can't drift when a new non-schema member is added to the
|
||||
* definition; a denylist (rest-destructure) would silently leak it.
|
||||
* (`name`, `description`, `parameters`), as sent to the model via the
|
||||
* system-prompt assembly. Constructed EXPLICITLY rather than by stripping
|
||||
* known non-schema members: a `ToolDefinition` also carries `execute` and the
|
||||
* optional `presentCall`/`presentResult` UI callbacks, and those (especially
|
||||
* the functions) must never leak into a model request. An allowlist can't
|
||||
* drift when a new non-schema member is added to the definition; a denylist
|
||||
* (rest-destructure) would silently leak it.
|
||||
* @returns one deep-cloned schema per registered tool, in registration order.
|
||||
*/
|
||||
schemas(): ToolSchema[] {
|
||||
return [...this.store.values()].map(({ name, description, parameters, strict }): ToolSchema => ({
|
||||
return [...this.store.values()].map(({ name, description, parameters }): ToolSchema => ({
|
||||
name,
|
||||
description,
|
||||
parameters: structuredClone(parameters),
|
||||
...strict !== undefined ? { strict } : {},
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -312,8 +312,6 @@ export interface DefineToolOptions<S extends SchemaSpec> {
|
||||
* free for the same replay reason. See {@link ToolResultView}.
|
||||
*/
|
||||
presentResult?(args: InferArgs<S>, result: ToolResult): ToolResultView | undefined
|
||||
/** Whether the tool requires structured output (default false). */
|
||||
strict?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -355,7 +353,6 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
|
||||
name: options.name,
|
||||
description: options.description,
|
||||
parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>,
|
||||
...options.strict !== undefined ? { strict: options.strict } : {},
|
||||
async execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn> {
|
||||
// Validate the model-generated args before the typed body runs. On
|
||||
// mismatch we throw ToolArgsError; the registry turns it into an
|
||||
|
||||
@@ -106,17 +106,4 @@ describe('gen-tool-catalog render', () => {
|
||||
expect(md).toContain('```json')
|
||||
expect(md).toContain('Source: [`packages/demo/tool-demo/src/index.ts`]')
|
||||
})
|
||||
|
||||
it('renders the strict flag when a schema sets it', () => {
|
||||
const catalog: ToolCatalog = [
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-demo',
|
||||
source: 'packages/demo/tool-demo/src/index.ts',
|
||||
requires: ['ctx.tools'],
|
||||
writes: ['tool/result'],
|
||||
schemas: [{ name: 'demo', description: '', parameters: { type: 'object', properties: {} }, strict: true }],
|
||||
},
|
||||
]
|
||||
expect(render(catalog)).toContain('Strict: `true`')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -62,18 +62,6 @@ describe('ToolRegistry', () => {
|
||||
expect(schema.execute).toBeUndefined()
|
||||
})
|
||||
|
||||
it('schemas() preserves `strict` when set (allowlist keeps the model-facing fields)', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'strict-tool',
|
||||
description: 'd',
|
||||
parameters: { x: { type: 'string', required: true } },
|
||||
strict: true,
|
||||
async execute() { return [] },
|
||||
}))
|
||||
expect(ctx.tools.schemas()[0]).toMatchObject({ name: 'strict-tool', strict: true })
|
||||
})
|
||||
|
||||
it('executes a tool and returns its content', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
@@ -611,44 +599,6 @@ describe('schema DSL edge cases', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('defineTool passes through strict flag when set to true', () => {
|
||||
const tool = defineTool({
|
||||
name: 'strict-tool',
|
||||
description: 'A strict tool',
|
||||
parameters: { input: { type: 'string' } },
|
||||
strict: true,
|
||||
async execute(args) {
|
||||
return [{ type: 'text' as const, text: args.input ?? '' }]
|
||||
},
|
||||
})
|
||||
expect(tool.strict).toBe(true)
|
||||
})
|
||||
|
||||
it('defineTool omits strict when not provided', () => {
|
||||
const tool = defineTool({
|
||||
name: 'non-strict-tool',
|
||||
description: 'A non-strict tool',
|
||||
parameters: { input: { type: 'string' } },
|
||||
async execute(args) {
|
||||
return [{ type: 'text' as const, text: args.input ?? '' }]
|
||||
},
|
||||
})
|
||||
expect('strict' in tool).toBe(false)
|
||||
})
|
||||
|
||||
it('defineTool strict=false is included', () => {
|
||||
const tool = defineTool({
|
||||
name: 'explicitly-non-strict',
|
||||
description: 'Explicitly non-strict',
|
||||
parameters: { input: { type: 'string' } },
|
||||
strict: false,
|
||||
async execute(args) {
|
||||
return [{ type: 'text' as const, text: args.input ?? '' }]
|
||||
},
|
||||
})
|
||||
expect(tool.strict).toBe(false)
|
||||
})
|
||||
|
||||
it('handles enum and default together in one property', () => {
|
||||
const spec = {
|
||||
level: { type: 'string', enum: ['low', 'high'], default: 'low' },
|
||||
|
||||
@@ -82,8 +82,9 @@ function versionOf(info: Stats): FsVersion {
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seam: lets specs pin the temp-file name (to prove exclusive-open
|
||||
* behavior) without a name race.
|
||||
* Test seam: lets specs pin the atomic-write temp names (to prove
|
||||
* exclusive-open behavior without a name race) and observe the staged temp
|
||||
* file before it is renamed over the target.
|
||||
*/
|
||||
export interface FsIoInternals {
|
||||
/** Override the generated private staging-dir name (relative to the target dir). */
|
||||
|
||||
@@ -104,7 +104,7 @@ export class LocalFileSystem extends FileSystem {
|
||||
|
||||
override async resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget> {
|
||||
const local = await resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)
|
||||
return { inputPath: path, targetKey: local.targetKey, displayPath: local.displayPath }
|
||||
return { targetKey: local.targetKey, displayPath: local.displayPath }
|
||||
}
|
||||
|
||||
override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
|
||||
@@ -127,7 +127,7 @@ export class LocalFileSystem extends FileSystem {
|
||||
return entries.map(entry => ({
|
||||
name: entry.name,
|
||||
type: entry.type,
|
||||
target: { inputPath: entry.name, targetKey: entry.target.targetKey, displayPath: entry.target.displayPath },
|
||||
target: { targetKey: entry.target.targetKey, displayPath: entry.target.displayPath },
|
||||
...(entry.version !== undefined ? { version: entry.version } : {}),
|
||||
...(entry.size !== undefined ? { size: entry.size } : {}),
|
||||
}))
|
||||
@@ -210,8 +210,6 @@ export class LocalFileSystem extends FileSystem {
|
||||
|
||||
const after = await probe(target.targetKey)
|
||||
return {
|
||||
replacements: edited.replacements,
|
||||
replaceAll: edit.replaceAll,
|
||||
version: this.versionAfterWrite(after, target),
|
||||
// The LF-normalized before/after text (the applied-hunk diff basis);
|
||||
// line-ending restoration is a storage detail the diff ignores.
|
||||
|
||||
@@ -138,12 +138,6 @@ describe('listDir', () => {
|
||||
join(dir, 'skills', 'dir-skill'),
|
||||
join(dir, 'skills', 'zeta.md'),
|
||||
])
|
||||
expect(entries.map(entry => entry.target.inputPath)).toEqual([
|
||||
'alpha.md',
|
||||
'broken-link',
|
||||
'dir-skill',
|
||||
'zeta.md',
|
||||
])
|
||||
const materializedEntries = entries.filter(entry => entry.version !== undefined)
|
||||
expect(materializedEntries.map(entry => entry.target.targetKey))
|
||||
.toEqual(await Promise.all(materializedEntries.map(entry => realpath(entry.target.displayPath))))
|
||||
@@ -335,7 +329,7 @@ describe('editText', () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'hello world')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const outcome = await fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false }, { version: await versionOf(target) })
|
||||
expect(outcome.replacements).toBe(1)
|
||||
expect(outcome.after).toBe('hello there')
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
|
||||
})
|
||||
|
||||
@@ -365,7 +359,7 @@ describe('editText', () => {
|
||||
const target = await fs.resolve('a.txt')
|
||||
// No version guard: any current content is edited, regardless of version.
|
||||
const outcome = await fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false })
|
||||
expect(outcome.replacements).toBe(1)
|
||||
expect(outcome.after).toBe('hello there')
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
|
||||
})
|
||||
|
||||
@@ -411,7 +405,7 @@ describe('editText', () => {
|
||||
await writeFile(join(dir, 'a.txt'), 'a a a')
|
||||
const target = await fs.resolve('a.txt')
|
||||
const outcome = await fs.editText(target, { oldString: 'a', newString: 'b', replaceAll: true }, { version: await versionOf(target) })
|
||||
expect(outcome.replacements).toBe(3)
|
||||
expect(outcome.after).toBe('b b b')
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('b b b')
|
||||
})
|
||||
|
||||
@@ -457,7 +451,7 @@ describe('editText', () => {
|
||||
// The version the first edit returned is a valid guard for a second edit —
|
||||
// no intervening re-stat needed.
|
||||
const second = await fs.editText(target, { oldString: 'two', newString: 'TWO', replaceAll: false }, { version: first.version })
|
||||
expect(second.replacements).toBe(1)
|
||||
expect(second.after).toBe('ONE TWO')
|
||||
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('ONE TWO')
|
||||
})
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
|
||||
import type { FsPolicyExec } from '@deepseek-ai/dsh-fs-policy'
|
||||
|
||||
function target(path: string): FsTarget {
|
||||
return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path }
|
||||
return { targetKey: FsTargetKey(path), displayPath: path }
|
||||
}
|
||||
const ownerExec = (session: object): FsPolicyExec => ({ agent: { session } })
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ A backend subclasses `FileSystem` and implements seven primitives.
|
||||
|
||||
| Member | Semantics |
|
||||
|---|---|
|
||||
| `resolve(path, opts?)` | Resolve a path into a stable `FsTarget` (`inputPath`, opaque `targetKey`, `displayPath`). `opts.cwd` is the base a relative `path` resolves against (a caller supplies its session workspace; absolute paths ignore it; omitted ⇒ the backend default). Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. |
|
||||
| `resolve(path, opts?)` | Resolve a path into a stable `FsTarget` (opaque `targetKey`, `displayPath`). `opts.cwd` is the base a relative `path` resolves against (a caller supplies its session workspace; absolute paths ignore it; omitted ⇒ the backend default). Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. |
|
||||
| `stat(target, signal?)` | Return `FsInfo` metadata (`version`, `type`, optional `size`), or `undefined` when the target is absent. Never content. |
|
||||
| `readText(target, signal?)` | Read the whole regular text file as one decoded string. Owns regular-file checks, UTF-8 decoding, binary/NUL rejection (`FS_NOT_TEXT`). |
|
||||
| `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here). |
|
||||
|
||||
@@ -52,8 +52,6 @@ export function FsVersion(v: string): FsVersion {
|
||||
* this; every other operation takes it.
|
||||
*/
|
||||
export interface FsTarget {
|
||||
/** The original model/plugin-supplied path, for diagnostics only. */
|
||||
inputPath: string
|
||||
/** Opaque key for stale guards and target lookup. */
|
||||
targetKey: FsTargetKey
|
||||
/**
|
||||
@@ -142,10 +140,6 @@ export interface FsEditRequest {
|
||||
|
||||
/** Outcome of a literal edit. */
|
||||
export interface FsEditOutcome {
|
||||
/** Number of literal replacements applied. */
|
||||
replacements: number
|
||||
/** Whether every match was replaced. */
|
||||
replaceAll: boolean
|
||||
/** Opaque version of the file after the edit. */
|
||||
version: FsVersion
|
||||
/**
|
||||
|
||||
@@ -23,7 +23,7 @@ class FakeFileSystem extends FileSystem {
|
||||
files = new Map<string, string>()
|
||||
|
||||
override async resolve(path: string): Promise<FsTarget> {
|
||||
return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path }
|
||||
return { targetKey: FsTargetKey(path), displayPath: path }
|
||||
}
|
||||
override async stat(target: FsTarget): Promise<FsInfo | undefined> {
|
||||
const content = this.files.get(target.targetKey)
|
||||
@@ -45,7 +45,7 @@ class FakeFileSystem extends FileSystem {
|
||||
{
|
||||
name: 'alpha.md',
|
||||
type: 'file',
|
||||
target: { inputPath: 'skills/alpha.md', targetKey: FsTargetKey('skills/alpha.md'), displayPath: 'skills/alpha.md' },
|
||||
target: { targetKey: FsTargetKey('skills/alpha.md'), displayPath: 'skills/alpha.md' },
|
||||
size: 2,
|
||||
version: FsVersion('v1'),
|
||||
},
|
||||
@@ -60,7 +60,7 @@ class FakeFileSystem extends FileSystem {
|
||||
const content = this.files.get(target.targetKey) ?? ''
|
||||
const after = content.split(edit.oldString).join(edit.newString)
|
||||
this.files.set(target.targetKey, after)
|
||||
return { replacements: 1, replaceAll: edit.replaceAll, version: FsVersion('v3'), before: content, after }
|
||||
return { version: FsVersion('v3'), before: content, after }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,7 +108,7 @@ describe('FileSystem provider seam', () => {
|
||||
expect(entries).toEqual([{
|
||||
name: 'alpha.md',
|
||||
type: 'file',
|
||||
target: { inputPath: 'skills/alpha.md', targetKey: 'skills/alpha.md', displayPath: 'skills/alpha.md' },
|
||||
target: { targetKey: 'skills/alpha.md', displayPath: 'skills/alpha.md' },
|
||||
size: 2,
|
||||
version: 'v1',
|
||||
}])
|
||||
|
||||
@@ -16,7 +16,6 @@ import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { FsEditOutcome } from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-fs'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts'
|
||||
@@ -43,9 +42,9 @@ export function parseEditArgs(args: { file_path: string; old_string: string; new
|
||||
}
|
||||
}
|
||||
|
||||
/** Format an edit outcome as a Claude-style model-facing success message. */
|
||||
export function formatEditOutput(displayPath: string, outcome: FsEditOutcome): string {
|
||||
return outcome.replaceAll
|
||||
/** Format an edit success (single-match or replace-all) as a Claude-style model-facing message. */
|
||||
export function formatEditOutput(displayPath: string, replaceAll: boolean): string {
|
||||
return replaceAll
|
||||
? `The file ${displayPath} has been updated. All occurrences were successfully replaced.`
|
||||
: `The file ${displayPath} has been updated successfully.`
|
||||
}
|
||||
@@ -91,7 +90,7 @@ export function applyEditTool(ctx: Context): void {
|
||||
// relativizes it).
|
||||
const diffs = computeHunkDiffs(input.filePath, outcome.before, outcome.after)
|
||||
return {
|
||||
content: [{ type: 'text', text: formatEditOutput(target.displayPath, outcome) }],
|
||||
content: [{ type: 'text', text: formatEditOutput(target.displayPath, input.replaceAll) }],
|
||||
meta: { diffs },
|
||||
}
|
||||
},
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
*/
|
||||
|
||||
import { FsError } from '@deepseek-ai/dsh-fs'
|
||||
import type { FsVersion } from '@deepseek-ai/dsh-fs'
|
||||
|
||||
/** Default maximum characters returned for a single line (the `readMaxLineLength` config). */
|
||||
export const READ_MAX_LINE_LENGTH = 2000
|
||||
@@ -59,16 +58,12 @@ export interface WindowResult {
|
||||
export interface FileReadOutcome {
|
||||
/** 1-based first line requested. */
|
||||
offset: number
|
||||
/** Maximum number of lines requested. */
|
||||
limit: number
|
||||
/** Returned lines, already numbered. */
|
||||
lines: FileTextLine[]
|
||||
/** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */
|
||||
totalLines: number
|
||||
/** Whether selected output hit the byte cap before EOF or the requested limit. */
|
||||
truncatedByBytes?: true
|
||||
/** Opaque version of the file at read time. */
|
||||
version: FsVersion
|
||||
}
|
||||
|
||||
interface WindowAccumulator {
|
||||
|
||||
@@ -109,10 +109,8 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
|
||||
|
||||
const outcome: FileReadOutcome = {
|
||||
offset: input.offset,
|
||||
limit: input.limit,
|
||||
lines: window.lines,
|
||||
totalLines: window.totalLines,
|
||||
version: info.version,
|
||||
...window.truncatedByBytes ? { truncatedByBytes: true } : {},
|
||||
}
|
||||
// Record the observed version (a no-op when no policy plugin listens). The
|
||||
|
||||
@@ -40,7 +40,7 @@ class FakeFs extends FileSystem {
|
||||
}
|
||||
|
||||
override async resolve(path: string): Promise<FsTarget> {
|
||||
return { inputPath: path, targetKey: FsTargetKey(`key:${path}`), displayPath: `/abs/${path}` }
|
||||
return { targetKey: FsTargetKey(`key:${path}`), displayPath: `/abs/${path}` }
|
||||
}
|
||||
override async stat(target: FsTarget): Promise<FsInfo | undefined> {
|
||||
this.throwIfArmed()
|
||||
@@ -71,7 +71,7 @@ class FakeFs extends FileSystem {
|
||||
const content = this.files.get(target.targetKey) ?? ''
|
||||
const after = content.split(edit.oldString).join(edit.newString)
|
||||
this.files.set(target.targetKey, after)
|
||||
return { replacements: 1, replaceAll: edit.replaceAll, version: FsVersion('v3'), before: content, after }
|
||||
return { version: FsVersion('v3'), before: content, after }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ describe('read tool', () => {
|
||||
})
|
||||
|
||||
describe('formatReadOutput footer variants', () => {
|
||||
const base: FileReadOutcome = { offset: 1, limit: 2000, lines: [{ number: 1, text: 'x' }], totalLines: 1, version: FsVersion('v') }
|
||||
const base: FileReadOutcome = { offset: 1, lines: [{ number: 1, text: 'x' }], totalLines: 1 }
|
||||
|
||||
it('reports a byte-capped read', () => {
|
||||
const out = formatReadOutput('/f', { ...base, totalLines: 99, truncatedByBytes: true })
|
||||
|
||||
@@ -12,21 +12,18 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud
|
||||
| Run a hook | `runHook(bash, hook, opts, now)` — stdin payload + env via `ctx.bash`, decode | builds the per-event stdin **payload** + the dialect's **env** |
|
||||
| Decode output | `parseHookOutput(exit, stdout, stderr)` → neutral `HookOutput` | maps the neutral `HookOutput` onto a seam-specific typed Decision |
|
||||
| Merge N hooks | `mergeHookOutputs(outputs)` → most-restrictive `MergedHookOutcome` | — |
|
||||
| Durable record | `appendHookInvoked` / `appendHookResult` (`hook/*` session events) | calls them around each invocation |
|
||||
| Durable record | `appendHookInvoked` / `appendHookResult` (`hook/*` session events; the result's `decision`/`stderrSummary` derive from the `HookOutput` here) | calls them around each invocation |
|
||||
|
||||
## Primitives
|
||||
|
||||
- **`matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. An invalid regex matches nothing (never throws).
|
||||
- **`runHook(bash, hook, options, now)`** — serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `defaultTimeoutMs`), and decode the result (threading `options.expectedEventName` to the codec). Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations.
|
||||
- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** — the exit-code + structured-stdout codec. Exit `0` → parse JSON stdout (lenient: non-JSON is left for the bridge); exit `2` → blocking error, `stderr` is the block reason (surfaced as `decision: 'block'`); other → non-blocking error. `hookSpecificOutput.permissionDecision` (allow/deny/ask) overrides a legacy top-level `decision`; `additionalContext`/`updatedInput`/`systemMessage`/`continue`/`stopReason`/`suppressOutput` are parsed too. The schemas key the `hookSpecificOutput` block by `hookEventName`, so passing `expectedEventName` (the firing event) DISCARDS a block whose `hookEventName` names a different event — or omits it entirely — its event-scoped fields don't take effect (a `PreToolUse` block on a `Stop` hook is malformed, and so is a discriminator-less block that would otherwise apply to any event), while the event-agnostic top-level fields still apply. Pure and total.
|
||||
- **`runHook(bash, hook, options, now)`** — serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `options.defaultTimeoutMs` — the bridge owns the default, its config defaulting to the lib's `DEFAULT_HOOK_TIMEOUT_MS` 10-minute reference), and decode the result (threading `options.expectedEventName` to the codec). Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations.
|
||||
- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** — the exit-code + structured-stdout codec. Exit `0` → parse JSON stdout (lenient: non-JSON is left for the bridge); exit `2` → blocking error, `stderr` is the block reason (surfaced as `decision: 'block'`); other → non-blocking error. `hookSpecificOutput.permissionDecision` (allow/deny/ask) overrides a legacy top-level `decision`; `additionalContext`/`updatedInput`/`systemMessage`/`continue`/`stopReason` are parsed too. The schemas key the `hookSpecificOutput` block by `hookEventName`, so passing `expectedEventName` (the firing event) DISCARDS a block whose `hookEventName` names a different event — or omits it entirely — its event-scoped fields don't take effect (a `PreToolUse` block on a `Stop` hook is malformed, and so is a discriminator-less block that would otherwise apply to any event), while the event-agnostic top-level fields still apply. Pure and total.
|
||||
- **`mergeHookOutputs(outputs)`** — fold the results of every hook that matched one point: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined with `\n\n`, `additionalContext`/`systemMessages` accumulated in order.
|
||||
|
||||
## `hook/*` session events
|
||||
|
||||
Declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT a `SurfaceEventType`, no `surfaceOp`):
|
||||
|
||||
- `hook/invoked` — `{ turn, point, dialect, matcher?, handlerId }`: a hook command ran.
|
||||
- `hook/result` — `{ turn, point, handlerId, decision, exitCode?, stderrSummary?, durationMs }`: its outcome, paired by `handlerId`.
|
||||
Declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT a `SurfaceEventType`, no `surfaceOp`): `hook/invoked` (a hook command ran) and `hook/result` (its outcome, paired by `handlerId`, with `appendHookResult` owning the decision rule). Payloads and per-event JSDoc are in the generated [persistence log event catalog](../../../docs/persistence-catalog/log-events.md); `stderrSummary` is truncated to the record's `stderrSummaryMaxChars` (the bridge's config, reference default `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500; omitted when empty).
|
||||
|
||||
Like every event they must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn by construction; `SessionStart` gets no `hook/*` record (its injected `context/message` is the durable evidence) — see the hooks RFC.
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
import type { HookOutput } from './types.ts'
|
||||
|
||||
/** The exit code a hook uses to signal a blocking error (stderr → model). */
|
||||
export const BLOCKING_EXIT_CODE = 2
|
||||
const BLOCKING_EXIT_CODE = 2
|
||||
|
||||
/** Read a string field from a parsed object, or `undefined` if absent/wrong type. */
|
||||
function str(obj: Record<string, unknown>, key: string): string | undefined {
|
||||
@@ -72,7 +72,7 @@ function permissionDecisionOf(value: string | undefined): HookOutput['decision']
|
||||
* `additionalContext`/`updatedInput`) are DISCARDED — a `PreToolUse` block on a
|
||||
* `Stop` hook must not deny the `Stop`. The block's `hookEventName` is still
|
||||
* surfaced (for the log/diagnostics), and the event-agnostic top-level fields
|
||||
* (`decision`/`reason`/`continue`/`stopReason`/`suppressOutput`/`systemMessage`)
|
||||
* (`decision`/`reason`/`continue`/`stopReason`/`systemMessage`)
|
||||
* are unaffected. Omit `expectedEventName` (or pass a matching one) to apply the
|
||||
* block as-is — a caller that doesn't key by event opts out of the check.
|
||||
*/
|
||||
@@ -125,8 +125,6 @@ function applyStructured(output: HookOutput, parsed: Record<string, unknown>, ex
|
||||
if (cont !== undefined) output.continue = cont
|
||||
const stopReason = str(parsed, 'stopReason')
|
||||
if (stopReason !== undefined) output.stopReason = stopReason
|
||||
const suppress = bool(parsed, 'suppressOutput')
|
||||
if (suppress !== undefined) output.suppressOutput = suppress
|
||||
const sysMsg = str(parsed, 'systemMessage')
|
||||
if (sysMsg !== undefined) output.systemMessage = sysMsg
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
|
||||
import type { Session } from '@deepseek-ai/dsh-session'
|
||||
import type { HookDialect } from './types.ts'
|
||||
import type { HookDialect, HookOutput } from './types.ts'
|
||||
|
||||
/** What identifies a hook invocation across its invoked/result pair. */
|
||||
export interface HookInvocation {
|
||||
@@ -37,16 +37,42 @@ export interface HookResultRecord {
|
||||
turn: number
|
||||
point: string
|
||||
handlerId: string
|
||||
/** The dialect-neutral decision the bridge resolved (`deny`/`allow`/`block`/…). */
|
||||
decision: string
|
||||
/** The process exit code (absent when the hook could not run). */
|
||||
exitCode?: number
|
||||
/** A truncated stderr summary (the block-reason source on exit 2). */
|
||||
stderrSummary?: string
|
||||
/** Wall-clock duration of the run. */
|
||||
/**
|
||||
* The decoded outcome the run produced. {@link appendHookResult} derives the
|
||||
* durable `decision`/`exitCode`/`stderrSummary` fields from it, so the shared
|
||||
* event's semantics live here, in the lib that declares it, not per-bridge.
|
||||
*/
|
||||
output: HookOutput
|
||||
/**
|
||||
* Character cap for the derived `stderrSummary`. The bound is the bridge's
|
||||
* to own (its `stderrSummaryMaxChars` config) and is passed in explicitly —
|
||||
* {@link DEFAULT_STDERR_SUMMARY_MAX_CHARS} is the reference default.
|
||||
*/
|
||||
stderrSummaryMaxChars: number
|
||||
/** Wall-clock duration of the run (from `runHook`) — durable audit timing. */
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The reference default for {@link HookResultRecord.stderrSummaryMaxChars}
|
||||
* (both bridges' config default). It lives here, once, next to the truncation
|
||||
* rule it bounds, so the bridges cannot drift apart on the shared event's
|
||||
* default cap.
|
||||
*/
|
||||
export const DEFAULT_STDERR_SUMMARY_MAX_CHARS = 500
|
||||
|
||||
/**
|
||||
* Truncate a hook's stderr for {@link HookResultRecord.stderrSummary}: trimmed,
|
||||
* `undefined` when empty, cut at `maxChars` with an ellipsis when over. The
|
||||
* bound is a parameter — like `runHook`'s `defaultTimeoutMs`, each bridge owns
|
||||
* the config default and passes it in.
|
||||
*/
|
||||
export function summarizeStderr(stderr: string, maxChars: number): string | undefined {
|
||||
const t = stderr.trim()
|
||||
if (t.length === 0) return undefined
|
||||
return t.length > maxChars ? t.slice(0, maxChars) + '…' : t
|
||||
}
|
||||
|
||||
/** Append a `hook/invoked` provenance event to `session`. */
|
||||
export function appendHookInvoked(session: Session, invocation: HookInvocation): void {
|
||||
session.append('hook/invoked', {
|
||||
@@ -59,26 +85,23 @@ export function appendHookInvoked(session: Session, invocation: HookInvocation):
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate a hook's stderr for {@link HookResultRecord.stderrSummary}: trimmed,
|
||||
* `undefined` when empty, cut at `maxChars` with an ellipsis when over. The
|
||||
* bound is a parameter — like `runHook`'s `defaultTimeoutMs`, each bridge owns
|
||||
* the config default and passes it in.
|
||||
* Append a `hook/result` outcome event to `session` (pairs with a prior
|
||||
* `hook/invoked`). Owns the durable event's semantics: `decision` is the hook's
|
||||
* parsed decision, else `'stop'` when it asked to halt (`continue: false`),
|
||||
* else `'pass'`; `stderrSummary` is the trimmed stderr truncated to
|
||||
* `record.stderrSummaryMaxChars` characters (omitted when empty); `exitCode`
|
||||
* is omitted when the hook never ran.
|
||||
*/
|
||||
export function summarizeStderr(stderr: string, maxChars: number): string | undefined {
|
||||
const t = stderr.trim()
|
||||
if (t.length === 0) return undefined
|
||||
return t.length > maxChars ? t.slice(0, maxChars) + '…' : t
|
||||
}
|
||||
|
||||
/** Append a `hook/result` outcome event to `session` (pairs with a prior `hook/invoked`). */
|
||||
export function appendHookResult(session: Session, record: HookResultRecord): void {
|
||||
const { output } = record
|
||||
const stderrSummary = summarizeStderr(output.stderr, record.stderrSummaryMaxChars)
|
||||
session.append('hook/result', {
|
||||
turn: record.turn,
|
||||
point: record.point,
|
||||
handlerId: record.handlerId,
|
||||
decision: record.decision,
|
||||
...record.exitCode !== undefined ? { exitCode: record.exitCode } : {},
|
||||
...record.stderrSummary !== undefined ? { stderrSummary: record.stderrSummary } : {},
|
||||
decision: output.decision ?? (output.continue === false ? 'stop' : 'pass'),
|
||||
...output.exitCode !== undefined ? { exitCode: output.exitCode } : {},
|
||||
...stderrSummary !== undefined ? { stderrSummary } : {},
|
||||
durationMs: record.durationMs,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@
|
||||
* - {@link mergeHookOutputs} — fold multiple matched hooks into one
|
||||
* most-restrictive {@link MergedHookOutcome} (deny > ask > allow).
|
||||
* - {@link appendHookInvoked} / {@link appendHookResult} — the log-only `hook/*`
|
||||
* session-event helpers (declaration-merged into `SessionEventMap`).
|
||||
* session-event helpers (declaration-merged into `SessionEventMap`);
|
||||
* `appendHookResult` derives the durable `decision`/`stderrSummary` from the
|
||||
* {@link HookOutput} so the shared event's semantics live in one place.
|
||||
*
|
||||
* Each bridge owns what genuinely DIFFERS: building the per-event stdin payload
|
||||
* (CC vs Codex field sets), the dialect's env/substitution, and mapping the
|
||||
@@ -29,10 +31,10 @@ export type {
|
||||
MatcherMode,
|
||||
} from './types.ts'
|
||||
export { matchesMatcher } from './matcher.ts'
|
||||
export { BLOCKING_EXIT_CODE, parseHookOutput } from './codec.ts'
|
||||
export { runHook } from './runner.ts'
|
||||
export { parseHookOutput } from './codec.ts'
|
||||
export { DEFAULT_HOOK_TIMEOUT_MS, runHook } from './runner.ts'
|
||||
export type { RunHookOptions, RunHookResult } from './runner.ts'
|
||||
export { mergeHookOutputs } from './merge.ts'
|
||||
export type { MergedDecision, MergedHookOutcome } from './merge.ts'
|
||||
export { appendHookInvoked, appendHookResult, summarizeStderr } from './events.ts'
|
||||
export { appendHookInvoked, appendHookResult, DEFAULT_STDERR_SUMMARY_MAX_CHARS, summarizeStderr } from './events.ts'
|
||||
export type { HookInvocation, HookResultRecord } from './events.ts'
|
||||
|
||||
@@ -17,6 +17,15 @@ import type { BashExecutor } from '@deepseek-ai/dsh-bash'
|
||||
import { parseHookOutput } from './codec.ts'
|
||||
import type { CommandHook, HookOutput } from './types.ts'
|
||||
|
||||
/**
|
||||
* The reference default per-hook timeout, in ms (10 minutes) — the value both
|
||||
* Claude Code and Codex apply to a hook whose config sets no `timeout`. It
|
||||
* lives here, once, as the protocol's default; the bridges' `defaultTimeoutMs`
|
||||
* config defaults to it, and a per-hook {@link CommandHook.timeoutSec} is the
|
||||
* override surface.
|
||||
*/
|
||||
export const DEFAULT_HOOK_TIMEOUT_MS = 600_000
|
||||
|
||||
/** Everything a single hook invocation needs beyond its command line. */
|
||||
export interface RunHookOptions {
|
||||
/** The JSON payload object written to the hook's stdin (the bridge builds it). */
|
||||
@@ -27,10 +36,14 @@ export interface RunHookOptions {
|
||||
cwd?: string
|
||||
/** Abort signal — cancels the hook run when fired (the parent step aborts). */
|
||||
signal?: AbortSignal
|
||||
/** Default timeout (ms) when the hook config sets none. */
|
||||
defaultTimeoutMs: number
|
||||
/** Whether to append a trailing newline to the stdin payload (CC yes, Codex no). */
|
||||
trailingNewline: boolean
|
||||
/**
|
||||
* Timeout applied when the hook's config sets no `timeout` of its own. The
|
||||
* bridge owns the default (its `defaultTimeoutMs` config, reference default
|
||||
* {@link DEFAULT_HOOK_TIMEOUT_MS}) and passes it in explicitly.
|
||||
*/
|
||||
defaultTimeoutMs: number
|
||||
/**
|
||||
* The event this hook is firing for (e.g. `'PreToolUse'`). When set, a
|
||||
* structured `hookSpecificOutput` block whose `hookEventName` names a DIFFERENT
|
||||
@@ -43,18 +56,20 @@ export interface RunHookOptions {
|
||||
/** The {@link HookOutput} plus the wall-clock duration of the run (for `hook/result`). */
|
||||
export interface RunHookResult {
|
||||
output: HookOutput
|
||||
/** Wall-clock duration of the run, from `now` — durable on the `hook/result` event. */
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `hook` via `bash` with `options.payload` serialized to its stdin, then
|
||||
* decode the result. `now` is injected (a monotonic-ms source) so the duration
|
||||
* is testable without a real clock. The hook's configured `timeoutSec` (wire
|
||||
* unit: seconds) overrides `defaultTimeoutMs`. The command runs with the
|
||||
* dialect's `env` merged after the executor's credential scrub (the trusted-
|
||||
* plugin path). NEVER throws: an infrastructure failure (the executor rejecting)
|
||||
* is surfaced as a {@link HookOutput} with `exitCode: undefined`, so the caller's
|
||||
* merge logic treats it as a non-blocking error rather than crashing the turn.
|
||||
* decode the result into a {@link HookOutput}. The hook's configured
|
||||
* `timeoutSec` (wire unit: seconds) overrides `options.defaultTimeoutMs`.
|
||||
* The command runs with the dialect's `env` merged after the executor's
|
||||
* credential scrub (the trusted-plugin path). NEVER throws: an infrastructure
|
||||
* failure (the executor rejecting) is surfaced as a {@link HookOutput} with
|
||||
* `exitCode: undefined`, so the caller's merge logic treats it as a
|
||||
* non-blocking error rather than crashing the turn. `now` is injected for
|
||||
* testable durations.
|
||||
*/
|
||||
export async function runHook(
|
||||
bash: BashExecutor,
|
||||
|
||||
@@ -18,12 +18,11 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
/**
|
||||
* A hook command was invoked at a hook point — log-only provenance (like
|
||||
* `compact/*`; NOT a {@link SurfaceEventType}, carries no `surfaceOp`).
|
||||
* `dialect` is the bridge that ran it (`claude`/`codex`/`native`), `point`
|
||||
* `dialect` is the bridge that ran it (`claude`/`codex`), `point`
|
||||
* the hook point (`PreToolUse`, `Stop`, …), `matcher` the matcher-group
|
||||
* pattern that selected it (absent for match-all), `handlerId` a stable id
|
||||
* for the command (so an invoked/result pair correlates). `turn` is the open
|
||||
* turn the invocation lives inside.
|
||||
* @mode emit
|
||||
*/
|
||||
'hook/invoked': {
|
||||
turn: number
|
||||
@@ -34,12 +33,14 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
}
|
||||
/**
|
||||
* A hook command's outcome — log-only, paired with a prior `hook/invoked`
|
||||
* (same `handlerId`). `decision` is the resolved dialect-neutral outcome the
|
||||
* bridge mapped it to (`allow`/`deny`/`ask`/`block`/`continue`/`stop`/`pass`),
|
||||
* `exitCode` the process exit (absent if it never ran), `stderrSummary` a
|
||||
* truncated stderr (the block reason source on exit 2), `durationMs` the wall
|
||||
* time. `turn` matches the `hook/invoked`.
|
||||
* @mode emit
|
||||
* (same `handlerId`). `decision` is the dialect-neutral outcome derived by
|
||||
* `appendHookResult` (which owns the rule): the hook's parsed decision
|
||||
* (`approve`/`allow`/`block`/`deny`/`ask`), else `'stop'` when it asked to
|
||||
* halt via `continue:false`, else `'pass'`. `exitCode` is the process exit
|
||||
* (absent if it never ran), `stderrSummary` the trimmed stderr truncated to
|
||||
* the bridge's configured cap (the block reason source on exit 2),
|
||||
* `durationMs` the wall-clock runtime (audit timing; snapshot replay
|
||||
* normalizes it). `turn` matches the `hook/invoked`.
|
||||
*/
|
||||
'hook/result': {
|
||||
turn: number
|
||||
@@ -53,8 +54,12 @@ declare module '@deepseek-ai/dsh-session' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Which protocol dialect a hook config / invocation belongs to. */
|
||||
export type HookDialect = 'claude' | 'codex' | 'native'
|
||||
/**
|
||||
* The bridge that ran a hook — the CC bridge stamps `'claude'`, the Codex
|
||||
* bridge `'codex'`. A native plugin on the interception seams is not a bridge
|
||||
* and writes no `hook/*` provenance (see the interception-seams RFC).
|
||||
*/
|
||||
export type HookDialect = 'claude' | 'codex'
|
||||
|
||||
/**
|
||||
* One configured command hook (the `{ type: 'command', command, timeout? }`
|
||||
@@ -115,8 +120,6 @@ export interface HookOutput {
|
||||
continue?: boolean
|
||||
/** Human-readable reason shown when {@link continue} is `false`. */
|
||||
stopReason?: string
|
||||
/** Hide the hook's stdout from the transcript (CC `suppressOutput`). */
|
||||
suppressOutput?: boolean
|
||||
/**
|
||||
* The neutral blocking decision a hook expressed, folded from the two channels
|
||||
* the reference protocols keep DISTINCT: the legacy top-level `decision`
|
||||
|
||||
@@ -38,13 +38,12 @@ describe('parseHookOutput — exit code semantics', () => {
|
||||
})
|
||||
|
||||
describe('parseHookOutput — structured stdout (exit 0 only)', () => {
|
||||
it('parses top-level continue/stopReason/suppressOutput/systemMessage', () => {
|
||||
it('parses top-level continue/stopReason/systemMessage', () => {
|
||||
const out = parseHookOutput(0, JSON.stringify({
|
||||
continue: false, stopReason: 'budget exceeded', suppressOutput: true, systemMessage: 'heads up',
|
||||
continue: false, stopReason: 'budget exceeded', systemMessage: 'heads up',
|
||||
}), '')
|
||||
expect(out.continue).toBe(false)
|
||||
expect(out.stopReason).toBe('budget exceeded')
|
||||
expect(out.suppressOutput).toBe(true)
|
||||
expect(out.systemMessage).toBe('heads up')
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { appendHookInvoked, appendHookResult, summarizeStderr } from '@deepseek-ai/dsh-hook-protocol'
|
||||
import { appendHookInvoked, appendHookResult, summarizeStderr, type HookOutput } from '@deepseek-ai/dsh-hook-protocol'
|
||||
|
||||
/** A {@link HookOutput} with the required stream fields defaulted. */
|
||||
function output(over: Partial<HookOutput> = {}): HookOutput {
|
||||
return { exitCode: 0, stderr: '', stdout: '', ...over }
|
||||
}
|
||||
|
||||
describe('hook/* session events', () => {
|
||||
it('appendHookInvoked records a log-only hook/invoked (with matcher when present)', () => {
|
||||
@@ -18,7 +23,7 @@ describe('hook/* session events', () => {
|
||||
|
||||
it('omits matcher when absent (match-all hook)', () => {
|
||||
const session = new Session(SessionId('s'))
|
||||
appendHookInvoked(session, { turn: 2, point: 'Stop', dialect: 'native', handlerId: 'h2' })
|
||||
appendHookInvoked(session, { turn: 2, point: 'Stop', dialect: 'codex', handlerId: 'h2' })
|
||||
|
||||
const ev = [...session.events].find(e => e.type === 'hook/invoked')
|
||||
if (ev?.type === 'hook/invoked') {
|
||||
@@ -26,32 +31,72 @@ describe('hook/* session events', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('appendHookResult records the decided outcome, omitting absent optionals', () => {
|
||||
it('appendHookResult derives decision/exitCode/stderrSummary from the output', () => {
|
||||
const session = new Session(SessionId('s'))
|
||||
appendHookResult(session, {
|
||||
turn: 1, point: 'PreToolUse', handlerId: 'h1', decision: 'deny',
|
||||
exitCode: 2, stderrSummary: 'blocked', durationMs: 12,
|
||||
turn: 1, point: 'PreToolUse', handlerId: 'h1',
|
||||
stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'blocked', decision: 'deny' }),
|
||||
})
|
||||
const full = [...session.events].find(e => e.type === 'hook/result')
|
||||
if (full?.type === 'hook/result') {
|
||||
expect(full.data).toMatchObject({ turn: 1, point: 'PreToolUse', handlerId: 'h1', decision: 'deny', exitCode: 2, stderrSummary: 'blocked', durationMs: 12 })
|
||||
expect(full.data).toEqual({ turn: 1, point: 'PreToolUse', handlerId: 'h1', decision: 'deny', exitCode: 2, stderrSummary: 'blocked', durationMs: 5 })
|
||||
}
|
||||
|
||||
// A result with no exit code / no stderr (e.g. a hook that could not run) omits both keys.
|
||||
const session2 = new Session(SessionId('s2'))
|
||||
appendHookResult(session2, { turn: 1, point: 'Stop', handlerId: 'h3', decision: 'allow', durationMs: 3 })
|
||||
appendHookResult(session2, {
|
||||
turn: 1, point: 'Stop', handlerId: 'h3',
|
||||
stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: undefined, decision: 'allow' }),
|
||||
})
|
||||
const sparse = [...session2.events].find(e => e.type === 'hook/result')
|
||||
if (sparse?.type === 'hook/result') {
|
||||
expect('exitCode' in sparse.data).toBe(false)
|
||||
expect('stderrSummary' in sparse.data).toBe(false)
|
||||
expect(sparse.data.durationMs).toBe(3)
|
||||
expect(sparse.data.decision).toBe('allow')
|
||||
}
|
||||
})
|
||||
|
||||
it('the decision falls back to stop on continue:false, else pass', () => {
|
||||
const session = new Session(SessionId('s'))
|
||||
appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'halt', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ continue: false }) })
|
||||
appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'noop', stderrSummaryMaxChars: 500, durationMs: 5, output: output() })
|
||||
// An explicit decision wins over the continue:false fallback.
|
||||
appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'both', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ continue: false, decision: 'block' }) })
|
||||
|
||||
const decisions = [...session.events]
|
||||
.filter(e => e.type === 'hook/result')
|
||||
.map(e => e.type === 'hook/result' ? [e.data.handlerId, e.data.decision] : [])
|
||||
expect(decisions).toEqual([['halt', 'stop'], ['noop', 'pass'], ['both', 'block']])
|
||||
})
|
||||
|
||||
it('stderrSummary is trimmed and truncated to 500 characters with an ellipsis', () => {
|
||||
const session = new Session(SessionId('s'))
|
||||
appendHookResult(session, {
|
||||
turn: 1, point: 'PreToolUse', handlerId: 'long',
|
||||
stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: ` ${'x'.repeat(600)} ` }),
|
||||
})
|
||||
const ev = [...session.events].find(e => e.type === 'hook/result')
|
||||
if (ev?.type === 'hook/result') {
|
||||
expect(ev.data.stderrSummary).toBe('x'.repeat(500) + '…')
|
||||
}
|
||||
})
|
||||
|
||||
it('a 500-character stderr is kept verbatim (the cap is exclusive)', () => {
|
||||
const session = new Session(SessionId('s'))
|
||||
appendHookResult(session, {
|
||||
turn: 1, point: 'PreToolUse', handlerId: 'edge',
|
||||
stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'y'.repeat(500) }),
|
||||
})
|
||||
const ev = [...session.events].find(e => e.type === 'hook/result')
|
||||
if (ev?.type === 'hook/result') {
|
||||
expect(ev.data.stderrSummary).toBe('y'.repeat(500))
|
||||
}
|
||||
})
|
||||
|
||||
it('an invoked/result pair correlates by handlerId', () => {
|
||||
const session = new Session(SessionId('s'))
|
||||
appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude', handlerId: 'pair-1' })
|
||||
appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'pair-1', decision: 'allow', exitCode: 0, durationMs: 7 })
|
||||
appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'pair-1', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ decision: 'allow' }) })
|
||||
|
||||
const invoked = [...session.events].find(e => e.type === 'hook/invoked')
|
||||
const result = [...session.events].find(e => e.type === 'hook/result')
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { BashExecRequest, BashExecSpec, BashExecutor, BashRunResult } from '@deepseek-ai/dsh-bash'
|
||||
import { runHook } from '@deepseek-ai/dsh-hook-protocol'
|
||||
import { DEFAULT_HOOK_TIMEOUT_MS, runHook } from '@deepseek-ai/dsh-hook-protocol'
|
||||
|
||||
/**
|
||||
* A minimal stand-in for the bits of {@link BashExecutor} that {@link runHook}
|
||||
@@ -89,6 +89,7 @@ describe('runHook — payload + env + stdin plumbing', () => {
|
||||
const { bash, specs } = recordingBash(async () => result())
|
||||
await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 60000, trailingNewline: true }, clock())
|
||||
expect(specs[0]!.timeoutMs).toBe(60000)
|
||||
expect(DEFAULT_HOOK_TIMEOUT_MS).toBe(600_000) // the CC/Codex reference default (10 minutes)
|
||||
})
|
||||
|
||||
it('passes the abort signal through', async () => {
|
||||
|
||||
@@ -26,7 +26,7 @@ In a `cordis.yml`:
|
||||
projectDir: .
|
||||
```
|
||||
|
||||
The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only `type: 'command'` hooks run; a `prompt`/`agent`/HTTP hook is parsed-and-skipped with a warning.
|
||||
The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only `type: 'command'` hooks run; a `prompt`/`agent`/HTTP hook is parsed-and-skipped with a warning. A hook with no per-hook `timeout` runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes — the CC default).
|
||||
|
||||
The hooks **themselves** run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` (the `session/new.cwd`) as the hook process's working directory, so a hook's `pwd`/relative-path/marker operates in the user's project tree, not the server launch dir.
|
||||
|
||||
|
||||
@@ -31,10 +31,11 @@ import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionRes
|
||||
import {
|
||||
appendHookInvoked,
|
||||
appendHookResult,
|
||||
DEFAULT_HOOK_TIMEOUT_MS,
|
||||
DEFAULT_STDERR_SUMMARY_MAX_CHARS,
|
||||
matchesMatcher,
|
||||
mergeHookOutputs,
|
||||
runHook,
|
||||
summarizeStderr,
|
||||
type HookOutput,
|
||||
type MatcherGroup,
|
||||
type MergedHookOutcome,
|
||||
@@ -82,8 +83,8 @@ export const Config: z<Config> = z.object({
|
||||
configPath: z.string().required(),
|
||||
pluginRoot: z.string(),
|
||||
projectDir: z.string(),
|
||||
defaultTimeoutMs: z.number().default(600_000),
|
||||
stderrSummaryMaxChars: z.number().default(500),
|
||||
defaultTimeoutMs: z.number().default(DEFAULT_HOOK_TIMEOUT_MS),
|
||||
stderrSummaryMaxChars: z.number().default(DEFAULT_STDERR_SUMMARY_MAX_CHARS),
|
||||
})
|
||||
|
||||
/** A stable per-handler id so an invoked/result pair correlates in the log. */
|
||||
@@ -105,8 +106,9 @@ function assertPositiveInteger(name: string, value: number): void {
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
// Validate the cap BEFORE the config-file parse: a bad value must fail the
|
||||
// load loudly, not be skipped by the parse-failure early return.
|
||||
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? 500
|
||||
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS
|
||||
assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars)
|
||||
const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS
|
||||
// --- Parse the config ONCE at load. A read/parse failure is contained: the
|
||||
// bridge logs and registers nothing rather than crashing boot (a typo'd path
|
||||
// must not take the agent down). ---
|
||||
@@ -126,8 +128,6 @@ export function apply(ctx: Context, config: Config): void {
|
||||
return
|
||||
}
|
||||
|
||||
const defaultTimeoutMs = config.defaultTimeoutMs ?? 600_000
|
||||
|
||||
/**
|
||||
* Run every command hook configured for `point` whose matcher selects
|
||||
* `matchQuery`, with the per-event `payload` on stdin, and fold the results.
|
||||
@@ -173,10 +173,10 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
const { output, durationMs } = await runHook(ctx.bash, hook, {
|
||||
payload,
|
||||
defaultTimeoutMs,
|
||||
...hookEnv ? { env: hookEnv } : {},
|
||||
...workdir !== undefined ? { cwd: workdir } : {},
|
||||
...opts.signal ? { signal: opts.signal } : {},
|
||||
defaultTimeoutMs,
|
||||
trailingNewline: true,
|
||||
// Discard a `hookSpecificOutput` block whose `hookEventName` names a
|
||||
// different event than the one firing (the schemas key it by event).
|
||||
@@ -190,14 +190,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
ctx.logger.warn(`hooks-claude: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`)
|
||||
}
|
||||
if (session && opts.turn !== undefined) {
|
||||
const stderrSummary = summarizeStderr(output.stderr, stderrSummaryMaxChars)
|
||||
appendHookResult(session, {
|
||||
turn: opts.turn, point, handlerId,
|
||||
decision: output.decision ?? (output.continue === false ? 'stop' : 'pass'),
|
||||
...output.exitCode !== undefined ? { exitCode: output.exitCode } : {},
|
||||
...stderrSummary !== undefined ? { stderrSummary } : {},
|
||||
durationMs,
|
||||
})
|
||||
appendHookResult(session, { turn: opts.turn, point, handlerId, output, stderrSummaryMaxChars, durationMs })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,8 +322,8 @@ describe('hooks-claude coverage — more default/sparse arms', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('hooks-claude coverage — schema-bypass default + unspawnable hook', () => {
|
||||
it('a direct apply() (schema bypass) defaults the timeout and runs', async () => {
|
||||
describe('hooks-claude coverage — schema-bypass apply + unspawnable hook', () => {
|
||||
it('a direct apply() (schema bypass) with only configPath runs', async () => {
|
||||
const d = dir()
|
||||
const marker = join(d, 'ran')
|
||||
const s = sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`)
|
||||
@@ -337,8 +337,9 @@ describe('hooks-claude coverage — schema-bypass default + unspawnable hook', (
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
// Direct apply with only configPath — bypasses schemastery's defaults, so the
|
||||
// runtime `defaultTimeoutMs ?? 600_000` fallback is exercised.
|
||||
// Direct apply with only configPath — bypasses schemastery's defaults, so
|
||||
// the bridge must run on the raw minimal config (the per-hook timeout is
|
||||
// the protocol lib's reference default, not a config knob).
|
||||
HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
@@ -32,7 +32,7 @@ In a `cordis.yml`:
|
||||
model: deepseek-v4
|
||||
```
|
||||
|
||||
The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing). Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias. Events outside the five Codex points are dropped at parse.
|
||||
The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing). Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias; one that sets neither runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes). Events outside the five Codex points are dropped at parse.
|
||||
|
||||
The hooks themselves run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` as the hook process's working directory, so a hook operates in the user's project tree, not the server launch dir.
|
||||
|
||||
|
||||
@@ -24,10 +24,11 @@ import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionRes
|
||||
import {
|
||||
appendHookInvoked,
|
||||
appendHookResult,
|
||||
DEFAULT_HOOK_TIMEOUT_MS,
|
||||
DEFAULT_STDERR_SUMMARY_MAX_CHARS,
|
||||
matchesMatcher,
|
||||
mergeHookOutputs,
|
||||
runHook,
|
||||
summarizeStderr,
|
||||
type HookOutput,
|
||||
type MatcherGroup,
|
||||
type MergedHookOutcome,
|
||||
@@ -57,8 +58,8 @@ export interface Config {
|
||||
export const Config: z<Config> = z.object({
|
||||
configPath: z.string().required(),
|
||||
model: z.string().default(''),
|
||||
defaultTimeoutMs: z.number().default(600_000),
|
||||
stderrSummaryMaxChars: z.number().default(500),
|
||||
defaultTimeoutMs: z.number().default(DEFAULT_HOOK_TIMEOUT_MS),
|
||||
stderrSummaryMaxChars: z.number().default(DEFAULT_STDERR_SUMMARY_MAX_CHARS),
|
||||
})
|
||||
|
||||
let handlerCounter = 0
|
||||
@@ -78,8 +79,9 @@ function assertPositiveInteger(name: string, value: number): void {
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
// Validate the cap BEFORE the config-file parse: a bad value must fail the
|
||||
// load loudly, not be skipped by the parse-failure early return.
|
||||
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? 500
|
||||
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS
|
||||
assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars)
|
||||
const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS
|
||||
let parsed: CodexHookConfig = {}
|
||||
try {
|
||||
const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8'))
|
||||
@@ -93,7 +95,6 @@ export function apply(ctx: Context, config: Config): void {
|
||||
return
|
||||
}
|
||||
|
||||
const defaultTimeoutMs = config.defaultTimeoutMs ?? 600_000
|
||||
const model = config.model ?? ''
|
||||
|
||||
async function runPoint(
|
||||
@@ -122,9 +123,9 @@ export function apply(ctx: Context, config: Config): void {
|
||||
}
|
||||
const { output, durationMs } = await runHook(ctx.bash, hook, {
|
||||
payload,
|
||||
defaultTimeoutMs,
|
||||
...workdir !== undefined ? { cwd: workdir } : {},
|
||||
...opts.signal ? { signal: opts.signal } : {},
|
||||
defaultTimeoutMs,
|
||||
trailingNewline: false, // Codex writes stdin WITHOUT a trailing newline.
|
||||
// Discard a `hookSpecificOutput` block naming a different event.
|
||||
expectedEventName: point,
|
||||
@@ -149,14 +150,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
ctx.logger.warn(`hooks-codex: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`)
|
||||
}
|
||||
if (session && opts.turn !== undefined) {
|
||||
const stderrSummary = summarizeStderr(output.stderr, stderrSummaryMaxChars)
|
||||
appendHookResult(session, {
|
||||
turn: opts.turn, point, handlerId,
|
||||
decision: output.decision ?? (output.continue === false ? 'stop' : 'pass'),
|
||||
...output.exitCode !== undefined ? { exitCode: output.exitCode } : {},
|
||||
...stderrSummary !== undefined ? { stderrSummary } : {},
|
||||
durationMs,
|
||||
})
|
||||
appendHookResult(session, { turn: opts.turn, point, handlerId, output, stderrSummaryMaxChars, durationMs })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
|
||||
expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…')
|
||||
})
|
||||
|
||||
it('warns on a skipped async hook and a direct apply() defaults the timeout', async () => {
|
||||
it('warns on a skipped async hook and a direct apply() (schema bypass) runs', async () => {
|
||||
const d = dir()
|
||||
const marker = join(d, 'ran')
|
||||
hooks(d, { UserPromptSubmit: [{ hooks: [
|
||||
@@ -243,7 +243,7 @@ describe('hooks-codex coverage — decision mapping paths', () => {
|
||||
await ctx.plugin(ToolRegistry); await ctx.plugin(AgentRegistry); await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
ctx.logger.warn = warn as never
|
||||
// Direct apply (schema bypass) → defaultTimeoutMs ?? 600_000 + model ?? '' fallbacks.
|
||||
// Direct apply (schema bypass) → the `model ?? ''` fallback is exercised.
|
||||
HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') })
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
|
||||
|
||||
@@ -23,18 +23,19 @@ A second, independent implementation of the same seam exists in `@deepseek-ai/ds
|
||||
|
||||
`thinking`/`reasoningEffort` are adapter-level request defaults serialized as the official top-level `thinking: {type}` / `reasoning_effort` wire fields. They live in adapter config (not `GenerateOptions`) to keep the core vocabulary provider-neutral.
|
||||
|
||||
## App attribution
|
||||
|
||||
Every request carries the shared attribution header from dsh-llm's `attributionHeaders()` - the mandatory `User-Agent` baseline identifying the harness (see [dsh-llm § App attribution](../llm/README.md#app-attribution-attributionts)). Direct DeepSeek requests and OpenAI-compatible gateway requests get no provider-specific app-attribution headers under this adapter contract; OpenRouter app attribution is deferred to a future explicit OpenRouter adapter or mode.
|
||||
|
||||
## Wire-format notes (verified live + against the official docs)
|
||||
|
||||
- Streaming only (`stream_options.include_usage` always on). `usage` may arrive attached to the finish chunk or as a trailing usage-only chunk — the translator defers both to `[DONE]`, so `usage` always precedes `finish` and nothing follows `finish`.
|
||||
- The first thinking-mode chunk carries `reasoning_content: ""` — handled (no spurious reasoning block).
|
||||
- **Reasoning passback rule**: on assistant turns that carried tool calls, `reasoning_content` is serialized back in history (required by the API in thinking mode); on tool-call-free turns it is dropped (ignored anyway — saves tokens).
|
||||
- `strict` on tool schemas passes through (officially Beta; the public API wants the `/beta` base URL for it, the internal endpoint accepts it directly).
|
||||
- Cache accounting: `cacheReadTokens` ← `prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`; DeepSeek reports no cache-write metric.
|
||||
|
||||
## Limitations (MVP, documented deliberately)
|
||||
|
||||
- `prefill` throws `LlmError('UNSUPPORTED')` — DeepSeek's chat-prefix completion is a Beta feature on the `/beta` base URL; future work.
|
||||
- `image` blocks are skipped (no vision support on these models).
|
||||
- `tool_choice` is not mapped (not part of the core vocabulary).
|
||||
|
||||
## Errors
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* @module dsh-llm-deepseek/adapter
|
||||
*/
|
||||
|
||||
import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { serializeRequest } from './serialize.ts'
|
||||
import type { RequestDefaults } from './serialize.ts'
|
||||
@@ -21,13 +21,6 @@ export interface DeepSeekAdapterOptions {
|
||||
defaults?: RequestDefaults
|
||||
}
|
||||
|
||||
/**
|
||||
* Attribution header sent on every request so the provider can identify the
|
||||
* client. Bump in lockstep with this package's version (no build-time version
|
||||
* injection is wired in this repo yet).
|
||||
*/
|
||||
const USER_AGENT = 'deepseek-harness/0.0.1'
|
||||
|
||||
/** Map an HTTP status to a stable LlmError code. */
|
||||
export function httpErrorCode(status: number): string {
|
||||
if (status === 401 || status === 403) return 'AUTH'
|
||||
@@ -67,7 +60,7 @@ export class DeepSeekAdapter extends LlmAdapter {
|
||||
'authorization': `Bearer ${this.options.apiKey}`,
|
||||
'content-type': 'application/json',
|
||||
'accept': 'text/event-stream',
|
||||
'user-agent': USER_AGENT,
|
||||
...attributionHeaders(),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
...options.signal ? { signal: options.signal } : {},
|
||||
|
||||
@@ -11,12 +11,10 @@
|
||||
* rule for thinking mode — required there, ignored elsewhere, so we save
|
||||
* the tokens elsewhere); `tool-call` → `tool_calls[]`
|
||||
* - `tool-result` → its own `{role: 'tool'}` message (text flattened)
|
||||
* - `image` → skipped (MVP limitation, documented in the README)
|
||||
*
|
||||
* @module dsh-llm-deepseek/serialize
|
||||
*/
|
||||
|
||||
import { LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
||||
import type { WireMessage, WireRequest, WireTool } from './types.ts'
|
||||
|
||||
@@ -99,19 +97,8 @@ export function serializeMessages(messages: Message[]): WireMessage[] {
|
||||
return wire
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full wire request. Throws `LlmError('UNSUPPORTED')` for
|
||||
* `prefill` (DeepSeek's chat-prefix completion is a Beta feature on a
|
||||
* different base URL — see README).
|
||||
*/
|
||||
/** Build the full wire request. */
|
||||
export function serializeRequest(options: GenerateOptions, defaults: RequestDefaults = {}): WireRequest {
|
||||
if (options.prefill !== undefined) {
|
||||
throw new LlmError(
|
||||
'prefill is not supported by the DeepSeek adapter (Beta chat-prefix completion is future work)',
|
||||
'UNSUPPORTED',
|
||||
)
|
||||
}
|
||||
|
||||
const messages: WireMessage[] = []
|
||||
if (options.system !== undefined) {
|
||||
messages.push({ role: 'system', content: options.system })
|
||||
@@ -124,8 +111,6 @@ export function serializeRequest(options: GenerateOptions, defaults: RequestDefa
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
// strict is officially supported (Beta); pass the tool author's choice.
|
||||
...tool.strict !== undefined ? { strict: tool.strict } : {},
|
||||
},
|
||||
}))
|
||||
|
||||
|
||||
@@ -78,8 +78,6 @@ export interface WireTool {
|
||||
name: string
|
||||
description: string
|
||||
parameters: Record<string, unknown>
|
||||
/** Beta: strict schema adherence (official: requires the /beta base URL). */
|
||||
strict?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createServer } from 'node:http'
|
||||
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import LlmService, { LlmError, userAgent } from '@deepseek-ai/dsh-llm'
|
||||
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import { DeepSeekAdapter, httpErrorCode } from '@deepseek-ai/dsh-llm-deepseek'
|
||||
import { assemble } from './assemble.ts'
|
||||
@@ -109,8 +109,12 @@ describe('DeepSeekAdapter against a mock server', () => {
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
})
|
||||
// Attribution header identifies the harness to the provider.
|
||||
expect(server.headers[0]?.['user-agent']).toMatch(/^deepseek-harness\//)
|
||||
// Attribution reaches the wire: the exact shared User-Agent, and no
|
||||
// provider-specific headers under the User-Agent-only contract.
|
||||
expect(server.headers[0]?.['user-agent']).toBe(userAgent())
|
||||
expect(server.headers[0]).not.toHaveProperty('http-referer')
|
||||
expect(server.headers[0]).not.toHaveProperty('x-openrouter-title')
|
||||
expect(server.headers[0]).not.toHaveProperty('x-openrouter-categories')
|
||||
})
|
||||
|
||||
it('streams raw chunks through ctx.llm.stream', async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CallId, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
|
||||
import { serializeMessages, serializeRequest } from '@deepseek-ai/dsh-llm-deepseek'
|
||||
|
||||
function request(overrides: Partial<GenerateOptions> = {}): GenerateOptions {
|
||||
@@ -110,11 +110,17 @@ describe('serializeMessages', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('skips image blocks (documented MVP limitation)', () => {
|
||||
it('skips plugin-added block types (merge-extensible ContentBlockMap)', () => {
|
||||
const wire = serializeMessages([
|
||||
{ role: 'user', content: [{ type: 'image', url: 'data:image/png;base64,x' }, { type: 'text', text: 'see image' }] },
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'chart', data: 'x' } as unknown as ContentBlock,
|
||||
{ type: 'text', text: 'see chart' },
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(wire).toEqual([{ role: 'user', content: 'see image' }])
|
||||
expect(wire).toEqual([{ role: 'user', content: 'see chart' }])
|
||||
})
|
||||
|
||||
it('emits an empty user message rather than dropping block-less messages', () => {
|
||||
@@ -149,17 +155,17 @@ describe('serializeRequest', () => {
|
||||
expect(wire.stop).toEqual(['END'])
|
||||
})
|
||||
|
||||
it('maps tools with strict passthrough', () => {
|
||||
it('maps tools to the wire function shape', () => {
|
||||
const wire = serializeRequest(request({
|
||||
messages: history,
|
||||
tools: [
|
||||
{ name: 'a', description: 'A', parameters: { type: 'object', properties: {} } },
|
||||
{ name: 'b', description: 'B', parameters: { type: 'object', properties: {} }, strict: true },
|
||||
{ name: 'b', description: 'B', parameters: { type: 'object', properties: { x: { type: 'string' } } } },
|
||||
],
|
||||
}))
|
||||
expect(wire.tools).toEqual([
|
||||
{ type: 'function', function: { name: 'a', description: 'A', parameters: { type: 'object', properties: {} } } },
|
||||
{ type: 'function', function: { name: 'b', description: 'B', parameters: { type: 'object', properties: {} }, strict: true } },
|
||||
{ type: 'function', function: { name: 'b', description: 'B', parameters: { type: 'object', properties: { x: { type: 'string' } } } } },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -179,17 +185,6 @@ describe('serializeRequest', () => {
|
||||
expect(wire.thinking).toBeUndefined()
|
||||
expect(wire.reasoning_effort).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects prefill with an UNSUPPORTED LlmError', () => {
|
||||
expect(() => serializeRequest(request({ prefill: [{ type: 'text', text: 'Sure' }] })))
|
||||
.toThrow(LlmError)
|
||||
try {
|
||||
serializeRequest(request({ prefill: [] }))
|
||||
expect.unreachable()
|
||||
} catch (error) {
|
||||
expect((error as LlmError).code).toBe('UNSUPPORTED')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('review fixes: assistant content shapes', () => {
|
||||
|
||||
@@ -9,7 +9,7 @@ DeepSeek adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](ht
|
||||
- pi-ai hands tool-call `arguments` around as **parsed objects**; the harness keeps raw JSON strings. The adapter patches replay payloads back to the original raw strings before sending them, and re-stringifies parsed output tool calls at `block-end`.
|
||||
- pi-ai reports failures as **in-stream error events** (it never throws mid-stream); these map to `finish {kind:'error'|'aborted'}` chunks — the protocol's other sanctioned error path besides throwing (which llm-deepseek uses).
|
||||
- pi-ai folds reasoning tokens into `usage.output`; there is no separate reasoning count to map.
|
||||
- pi-ai's options omit some DeepSeek/OpenAI-compatible details; the adapter uses its `onPayload` hook to preserve the harness contract (`stop`, per-tool `strict`, omitted reasoning effort, raw replayed tool arguments).
|
||||
- pi-ai's options omit some DeepSeek/OpenAI-compatible details; the adapter uses its `onPayload` hook to preserve the harness contract (`stop`, scrubbing pi-ai's own per-tool `strict` default — the hand-rolled twin sends no such field — omitted reasoning effort, raw replayed tool arguments).
|
||||
|
||||
## Config
|
||||
|
||||
@@ -25,13 +25,17 @@ Same shape as llm-deepseek (one-line swap in cordis.yml), with pi-ai's thinking-
|
||||
reasoning: high # off | high | xhigh (xhigh → wire 'max')
|
||||
```
|
||||
|
||||
## App attribution
|
||||
|
||||
Every request carries the shared attribution header from dsh-llm's `attributionHeaders()`, passed through pi-ai's `headers` stream option (pi-ai merges caller headers last, so it always reaches the wire - the unit suite asserts arrival on the mock server, same as llm-deepseek). OpenRouter-specific app attribution headers are intentionally not sent by this adapter contract; they are deferred to a future explicit OpenRouter adapter or mode. See [dsh-llm § App attribution](../llm/README.md#app-attribution-attributionts).
|
||||
|
||||
## Dependency weight
|
||||
|
||||
pi-ai declares the openai/anthropic/google/mistral/AWS SDKs as install-time dependencies. They are lazy-loaded — only the openai SDK actually loads for this adapter — but they do land in `node_modules`. Accepted for a package whose purpose is design verification.
|
||||
|
||||
## Limitations
|
||||
|
||||
Same MVP contract as llm-deepseek: `prefill` throws `UNSUPPORTED`, images are not representable, `tool_choice` is not mapped.
|
||||
Same MVP contract as llm-deepseek: `tool_choice` is not mapped.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@
|
||||
|
||||
import { stream as piStream } from '@earendil-works/pi-ai'
|
||||
import type { Model } from '@earendil-works/pi-ai'
|
||||
import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import { attributionHeaders, LlmAdapter } from '@deepseek-ai/dsh-llm'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk, ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { toPiContext, toStreamChunks } from './convert.ts'
|
||||
|
||||
/** Reasoning levels surfaced by this adapter (DeepSeek wire: high|max). */
|
||||
@@ -61,7 +61,7 @@ export function buildModel(modelId: string, options: PiAiAdapterOptions): Model<
|
||||
}
|
||||
|
||||
type Payload = {
|
||||
tools?: { function?: { name?: unknown; strict?: unknown } }[]
|
||||
tools?: { function?: { strict?: unknown } }[]
|
||||
messages?: {
|
||||
role?: unknown
|
||||
tool_calls?: { id?: unknown; function?: { arguments?: unknown } }[]
|
||||
@@ -81,10 +81,6 @@ function rawToolArguments(options: GenerateOptions): Map<CallId, string> {
|
||||
return raw
|
||||
}
|
||||
|
||||
function strictByToolName(tools: ToolSchema[] | undefined): Map<string, boolean | undefined> {
|
||||
return new Map((tools ?? []).map(tool => [tool.name, tool.strict]))
|
||||
}
|
||||
|
||||
function patchPayload(payload: unknown, options: GenerateOptions, reasoning: PiAiReasoning | undefined): unknown {
|
||||
/* v8 ignore next -- pi-ai onPayload always receives an object; tolerate unusual future hooks defensively */
|
||||
if (typeof payload !== 'object' || payload === null) return payload
|
||||
@@ -97,16 +93,13 @@ function patchPayload(payload: unknown, options: GenerateOptions, reasoning: PiA
|
||||
body.stop = options.stop
|
||||
}
|
||||
|
||||
const strictByName = strictByToolName(options.tools)
|
||||
// pi-ai stamps its own `strict` default on every serialized tool; the
|
||||
// harness tool contract has no strict field and the hand-rolled twin sends
|
||||
// none, so scrub it for wire parity.
|
||||
for (const tool of body.tools ?? []) {
|
||||
/* v8 ignore next -- malformed pi-ai payload guard: real tool entries always carry function */
|
||||
if (tool.function === undefined) continue
|
||||
const name = tool.function.name
|
||||
/* v8 ignore next -- malformed pi-ai payload guard: real function entries always carry a string name */
|
||||
if (typeof name !== 'string') continue
|
||||
const strict = strictByName.get(name)
|
||||
if (strict === undefined) delete tool.function.strict
|
||||
else tool.function.strict = strict
|
||||
delete tool.function.strict
|
||||
}
|
||||
|
||||
const rawById = rawToolArguments(options)
|
||||
@@ -131,9 +124,9 @@ function patchPayload(payload: unknown, options: GenerateOptions, reasoning: PiA
|
||||
*
|
||||
* Implementation notes:
|
||||
* - `onPayload` patches provider payload details pi-ai cannot express directly:
|
||||
* stop sequences, per-tool strict, omitted reasoning effort, and raw replayed
|
||||
* tool-call arguments.
|
||||
* - `prefill` throws UNSUPPORTED (same contract as dsh-llm-deepseek).
|
||||
* stop sequences, scrubbing pi-ai's own per-tool `strict` default (the
|
||||
* hand-rolled twin sends no such field), omitted reasoning effort, and raw
|
||||
* replayed tool-call arguments.
|
||||
* - pi-ai reports request failures as in-stream error events; convert.ts
|
||||
* maps them to `finish {kind:'error'|'aborted'}` chunks rather than
|
||||
* throwing — both are sanctioned StreamChunk error paths.
|
||||
@@ -144,13 +137,6 @@ export class PiAiAdapter extends LlmAdapter {
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
if (options.prefill !== undefined) {
|
||||
throw new LlmError(
|
||||
'prefill is not supported by the pi-ai adapter',
|
||||
'UNSUPPORTED',
|
||||
)
|
||||
}
|
||||
|
||||
const model = buildModel(options.model, this.options)
|
||||
// Undefined config means "provider default" (DeepSeek: thinking ENABLED),
|
||||
// matching llm-deepseek's omission semantics. pi-ai derives the wire
|
||||
@@ -171,6 +157,9 @@ export class PiAiAdapter extends LlmAdapter {
|
||||
try {
|
||||
const events = piStream(model, toPiContext(options), {
|
||||
apiKey: this.options.apiKey,
|
||||
// pi-ai merges caller headers last over its provider defaults, so the
|
||||
// harness attribution always reaches the wire.
|
||||
headers: attributionHeaders(),
|
||||
...options.temperature !== undefined ? { temperature: options.temperature } : {},
|
||||
...options.maxTokens !== undefined ? { maxTokens: options.maxTokens } : {},
|
||||
signal: controller.signal,
|
||||
|
||||
@@ -93,7 +93,7 @@ export function toPiContext(options: GenerateOptions): PiContext {
|
||||
})
|
||||
break
|
||||
default:
|
||||
// image / plugin-added block types: not representable here.
|
||||
// plugin-added block types: not representable here.
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createServer } from 'node:http'
|
||||
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { CallId, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import LlmService, { CallId, userAgent } from '@deepseek-ai/dsh-llm'
|
||||
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
import { buildModel, PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
import { assemble } from './assemble.ts'
|
||||
@@ -11,6 +11,8 @@ import { assemble } from './assemble.ts'
|
||||
interface MockServer {
|
||||
url: string
|
||||
requests: unknown[]
|
||||
/** Header bags of received requests, in order (parallel to `requests`). */
|
||||
headers: IncomingMessage['headers'][]
|
||||
close(): Promise<void>
|
||||
}
|
||||
|
||||
@@ -22,11 +24,13 @@ afterEach(async () => {
|
||||
|
||||
async function mockServer(script: { status?: number; events?: string[]; body?: string }[]): Promise<MockServer> {
|
||||
const requests: unknown[] = []
|
||||
const headers: IncomingMessage['headers'][] = []
|
||||
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
|
||||
let body = ''
|
||||
request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') })
|
||||
request.on('end', () => {
|
||||
requests.push(JSON.parse(body))
|
||||
headers.push(request.headers)
|
||||
const behavior = script.shift() ?? { status: 500, body: 'script exhausted' }
|
||||
if (behavior.status !== undefined && behavior.status !== 200) {
|
||||
response.writeHead(behavior.status, { 'content-type': 'application/json' })
|
||||
@@ -45,6 +49,7 @@ async function mockServer(script: { status?: number; events?: string[]; body?: s
|
||||
return {
|
||||
url: `http://127.0.0.1:${address.port}`,
|
||||
requests,
|
||||
headers,
|
||||
close: () => new Promise(resolve => server.close(() => { resolve() })),
|
||||
}
|
||||
}
|
||||
@@ -91,6 +96,14 @@ describe('PiAiAdapter against a mock server', () => {
|
||||
expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
|
||||
expect(result.finish).toEqual({ kind: 'stop' })
|
||||
expect(result.usage).toMatchObject({ inputTokens: 3, outputTokens: 1 })
|
||||
|
||||
// Attribution reaches the wire through pi-ai's headers hook: the exact
|
||||
// shared User-Agent, and no provider-specific headers under the
|
||||
// User-Agent-only contract.
|
||||
expect(server.headers[0]?.['user-agent']).toBe(userAgent())
|
||||
expect(server.headers[0]).not.toHaveProperty('http-referer')
|
||||
expect(server.headers[0]).not.toHaveProperty('x-openrouter-title')
|
||||
expect(server.headers[0]).not.toHaveProperty('x-openrouter-categories')
|
||||
})
|
||||
|
||||
it('streams tool calls with re-stringified arguments', async () => {
|
||||
@@ -149,26 +162,26 @@ describe('PiAiAdapter against a mock server', () => {
|
||||
expect(server.requests[0]).toMatchObject({ stop: ['END'] })
|
||||
})
|
||||
|
||||
it('preserves per-tool strict exactly through onPayload', async () => {
|
||||
it('scrubs pi-ai\'s own per-tool strict default through onPayload', async () => {
|
||||
const server = await mockServer([{ events: textEvents }])
|
||||
const ctx = await harness(server.url)
|
||||
await assemble(ctx,{
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [],
|
||||
tools: [
|
||||
{ name: 'strict_true', description: 'true', parameters: {}, strict: true },
|
||||
{ name: 'strict_false', description: 'false', parameters: {}, strict: false },
|
||||
{ name: 'strict_omitted', description: 'omitted', parameters: {} },
|
||||
{ name: 'alpha', description: 'a', parameters: {} },
|
||||
{ name: 'beta', description: 'b', parameters: {} },
|
||||
],
|
||||
})
|
||||
|
||||
// pi-ai stamps `strict` on every serialized tool function; the harness
|
||||
// contract has none and the hand-rolled twin sends no such field, so the
|
||||
// payload fixup must have deleted it from every tool.
|
||||
const request = server.requests[0] as { tools: { function: { name: string; strict?: boolean } }[] }
|
||||
expect(request.tools.map(tool => [tool.function.name, tool.function.strict])).toEqual([
|
||||
['strict_true', true],
|
||||
['strict_false', false],
|
||||
['strict_omitted', undefined],
|
||||
])
|
||||
expect('strict' in request.tools[2]!.function).toBe(false)
|
||||
expect(request.tools.map(tool => tool.function.name)).toEqual(['alpha', 'beta'])
|
||||
for (const tool of request.tools) {
|
||||
expect('strict' in tool.function).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves raw replayed tool-call arguments in the provider payload', async () => {
|
||||
@@ -209,15 +222,6 @@ describe('PiAiAdapter against a mock server', () => {
|
||||
expect(result.finish).toMatchObject({ kind: 'error', code })
|
||||
})
|
||||
|
||||
it('rejects prefill with UNSUPPORTED', async () => {
|
||||
const ctx = await harness('http://127.0.0.1:1')
|
||||
await expect(assemble(ctx,{
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [],
|
||||
prefill: [{ type: 'text', text: 'Sure' }],
|
||||
})).rejects.toThrow(LlmError)
|
||||
})
|
||||
|
||||
it('registers/unregisters models on the llm service (HMR safety)', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { StreamChunk } 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 { mapStopReason, mapUsage, toPiContext, toStreamChunks } from '@deepseek-ai/dsh-llm-pi-ai'
|
||||
|
||||
@@ -171,13 +171,13 @@ describe('toPiContext', () => {
|
||||
expect(context.messages.map(message => message.role)).toEqual(['user', 'user', 'toolResult'])
|
||||
})
|
||||
|
||||
it('skips image and unknown blocks in assistant content', () => {
|
||||
it('skips plugin-added (unknown) blocks in assistant content', () => {
|
||||
const context = toPiContext({
|
||||
model: 'm',
|
||||
messages: [{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'image', url: 'data:,x' },
|
||||
{ type: 'chart', data: 'x' } as unknown as ContentBlock,
|
||||
{ type: 'text', text: 'visible' },
|
||||
],
|
||||
}],
|
||||
|
||||
@@ -25,10 +25,14 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
|
||||
|
||||
### Content-block vocabulary (`types.ts`)
|
||||
|
||||
Messages are arrays of typed content blocks: `text`, `reasoning`, `tool-call`, `tool-result`, `image`. The union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging.
|
||||
Messages are arrays of typed content blocks: `text`, `reasoning`, `tool-call`, `tool-result`. The union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging. The core set is limited to blocks every shipping path honors — multimodal content (images, audio, …) has no core block type; a feature that needs one adds it via the map together with the adapter/UI/compaction support that honors it.
|
||||
|
||||
Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages.
|
||||
|
||||
### App attribution (`attribution.ts`)
|
||||
|
||||
Every product adapter must identify the application on every provider HTTP request - attribution is part of the adapter contract, not an adapter-local nicety. `attributionHeaders(identity?)` builds the standard `User-Agent` header (`product/version (+url)`, from `userAgent()`) for every request. The default `APP_IDENTITY` carries only static public product facts (its version is read from this package's manifest); a white-label deployment passes its own `AppIdentity`, and omission falls back to the default - nothing can suppress attribution. OpenRouter-specific app attribution headers are intentionally not supported by this contract. An adapter proves compliance with a wire-level test: a mock server asserting the received header (or, for a library-backed adapter, that the library's header hook delivers the same value). Policy and rationale: [Mandatory `User-Agent` attribution](../../../docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md).
|
||||
|
||||
### Classes
|
||||
|
||||
- `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`.
|
||||
|
||||
71
packages/llm/llm/src/attribution.ts
Normal file
71
packages/llm/llm/src/attribution.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* App-attribution vocabulary for provider requests.
|
||||
*
|
||||
* Every product LLM adapter must identify the application on every provider
|
||||
* HTTP request (see the adapter contract on {@link ../index.ts LlmAdapter}):
|
||||
* a static, non-secret product identity, sent as the standard `User-Agent`.
|
||||
* Adapters obtain the headers from {@link attributionHeaders} instead of
|
||||
* hand-copying constants, so the identity cannot drift between
|
||||
* implementations. The policy and its rationale are pinned in
|
||||
* docs/rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-llm/attribution
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
// The package's own manifest is the single source of the version so the
|
||||
// User-Agent cannot drift from what is published (`./package.json` is an
|
||||
// export of this package; the relative path resolves from both `src/` and
|
||||
// the bundled `lib/`).
|
||||
const { version } = createRequire(import.meta.url)('../package.json') as { version: string }
|
||||
|
||||
/**
|
||||
* Static public application identity sent to LLM providers.
|
||||
*
|
||||
* Every field is a public product fact, safe on every request: no secrets,
|
||||
* local paths, session ids, prompt text, or per-user identifiers belong here,
|
||||
* and nothing per-request may influence the values.
|
||||
*/
|
||||
export interface AppIdentity {
|
||||
/** `User-Agent` product token (lowercase, hyphenated). */
|
||||
product: string
|
||||
/** Product version; sourced from package metadata, never hand-copied. */
|
||||
version: string
|
||||
/** Public home URL of the app, used as the `User-Agent` comment. */
|
||||
url: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The harness's own identity: the default every adapter sends. Deployments
|
||||
* that need a white-label identity pass their own {@link AppIdentity} to
|
||||
* {@link attributionHeaders} — omission falls back to this default; nothing
|
||||
* can suppress attribution entirely.
|
||||
*/
|
||||
export const APP_IDENTITY: AppIdentity = {
|
||||
product: 'deepseek-harness',
|
||||
version,
|
||||
// FIXME: create the public deepseek-ai/deepseek-harness-sdk repository this
|
||||
// URL promises before the first release ships attribution pointing at it.
|
||||
url: 'https://github.com/deepseek-ai/deepseek-harness-sdk',
|
||||
}
|
||||
|
||||
/**
|
||||
* The standard `User-Agent` value: `product/version (+url)`. The
|
||||
* parenthesized `+url` comment is the conventional self-identification form
|
||||
* (RFC 9110 §10.1.5 product + comment syntax).
|
||||
*/
|
||||
export function userAgent(identity: AppIdentity = APP_IDENTITY): string {
|
||||
return `${identity.product}/${identity.version} (+${identity.url})`
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the attribution headers an adapter must send on every provider
|
||||
* request. Header names are lowercase (HTTP field names are case-insensitive
|
||||
* on the wire).
|
||||
*/
|
||||
export function attributionHeaders(
|
||||
identity: AppIdentity = APP_IDENTITY,
|
||||
): Record<string, string> {
|
||||
return { 'user-agent': userAgent(identity) }
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { Context, Service } from 'cordis'
|
||||
import type { GenerateOptions, StreamChunk } from './types.ts'
|
||||
import { HarnessError } from './error.ts'
|
||||
|
||||
export * from './attribution.ts'
|
||||
export * from './brand.ts'
|
||||
export * from './never.ts'
|
||||
export * from './error.ts'
|
||||
@@ -57,6 +58,13 @@ export class LlmError extends HarnessError {
|
||||
* fetch/SSE) and `@deepseek-ai/dsh-llm-pi-ai` (pi-ai-backed) — two
|
||||
* deliberately different internals over the same contract; see the
|
||||
* adapter contract documented on `StreamChunk` in `./types.ts`.
|
||||
*
|
||||
* App attribution is part of the adapter contract: every HTTP request to a
|
||||
* provider carries the headers from `attributionHeaders()` (`./attribution.ts`)
|
||||
* — the standard `User-Agent` baseline everywhere. An adapter proves it with
|
||||
* a wire-level test (a mock server asserting the received header), or, for a
|
||||
* library-backed adapter, by asserting the library's header hook delivers the
|
||||
* same value to the wire.
|
||||
*/
|
||||
export abstract class LlmAdapter {
|
||||
/** Stream one model call as raw chunks. The only required method. */
|
||||
|
||||
@@ -22,14 +22,10 @@
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
import type { CallId } from './brand.ts'
|
||||
|
||||
/** Cache hint attached to a content block (provider-interpreted). */
|
||||
export type CacheHint = 'ephemeral'
|
||||
|
||||
/** Plain text visible to the end user. */
|
||||
export interface TextBlock {
|
||||
type: 'text'
|
||||
text: string
|
||||
cache?: CacheHint
|
||||
}
|
||||
|
||||
/** Reasoning / thinking content, distinct from visible text. */
|
||||
@@ -54,27 +50,24 @@ export interface ToolResultBlock {
|
||||
toolCallId: CallId
|
||||
content: ContentBlock[]
|
||||
isError?: boolean
|
||||
cache?: CacheHint
|
||||
}
|
||||
|
||||
/** An image, by URL or data URL. */
|
||||
export interface ImageBlock {
|
||||
type: 'image'
|
||||
url: string
|
||||
mimeType?: string
|
||||
cache?: CacheHint
|
||||
}
|
||||
|
||||
/**
|
||||
* All known content block shapes, keyed by their `type` tag.
|
||||
* Merge-extensible: plugins add new block types via declaration merging.
|
||||
*
|
||||
* The core set is deliberately limited to blocks every shipping path honors.
|
||||
* Multimodal content (images, audio, …) has no core block type: a feature
|
||||
* that needs one adds it via declaration merging in the same coordinated
|
||||
* change that maps it in the adapters, surfaces it in the UI bridges, and
|
||||
* prices it in compaction — a producer never lands without its consumers
|
||||
* (see docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md).
|
||||
*/
|
||||
export interface ContentBlockMap {
|
||||
'text': TextBlock
|
||||
'reasoning': ReasoningBlock
|
||||
'tool-call': ToolCallBlock
|
||||
'tool-result': ToolResultBlock
|
||||
'image': ImageBlock
|
||||
}
|
||||
|
||||
export type ContentBlockType = keyof ContentBlockMap
|
||||
@@ -93,7 +86,6 @@ export interface Message {
|
||||
export interface MessageSourceMap {
|
||||
user: { kind: 'user' }
|
||||
plugin: { kind: 'plugin'; plugin: string }
|
||||
agent: { kind: 'agent'; agentId: string }
|
||||
}
|
||||
|
||||
export type MessageSource = MessageSourceMap[keyof MessageSourceMap]
|
||||
@@ -171,7 +163,6 @@ export interface ToolSchema {
|
||||
description: string
|
||||
/** JSON Schema object for the arguments. */
|
||||
parameters: Record<string, unknown>
|
||||
strict?: boolean
|
||||
}
|
||||
|
||||
/** A single model request, fully assembled. */
|
||||
@@ -182,8 +173,6 @@ export interface GenerateOptions {
|
||||
system?: string
|
||||
/** Tool schemas (adapters map to the provider's `tools` field). */
|
||||
tools?: ToolSchema[]
|
||||
/** Assistant prefix continuation (prefill). */
|
||||
prefill?: ContentBlock[]
|
||||
temperature?: number
|
||||
maxTokens?: number
|
||||
/**
|
||||
|
||||
@@ -63,12 +63,12 @@ describe('BlockAssembler', () => {
|
||||
|
||||
it('throws from assemble() when a partial has an unhandled blockType', () => {
|
||||
const assembler = new BlockAssembler()
|
||||
// Directly push a block-end for an image block whose block-start never
|
||||
// called ensure — but the image block-type flows through normally.
|
||||
// What we really need is a partial whose blockType is not text/reasoning/tool-call.
|
||||
// We can achieve this via a block-start for 'image' followed by blocks().
|
||||
assembler.push({ type: 'block-start', index: 0, blockType: 'image' } as unknown as StreamChunk)
|
||||
expect(() => assembler.blocks()).toThrow('cannot assemble incomplete block of type "image"')
|
||||
// A partial whose blockType is not text/reasoning/tool-call cannot be
|
||||
// assembled without its block-end. A plugin-added block type (here
|
||||
// 'video', via the merge-extensible ContentBlockMap) opened by a
|
||||
// block-start with no closing block-end exercises that throw.
|
||||
assembler.push({ type: 'block-start', index: 0, blockType: 'video' } as unknown as StreamChunk)
|
||||
expect(() => assembler.blocks()).toThrow('cannot assemble incomplete block of type "video"')
|
||||
})
|
||||
|
||||
it('mustGet throws when an index is missing from the partials map (invariant violation)', () => {
|
||||
|
||||
51
packages/llm/llm/tests/attribution.spec.ts
Normal file
51
packages/llm/llm/tests/attribution.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { createRequire } from 'node:module'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { APP_IDENTITY, attributionHeaders, userAgent } from '@deepseek-ai/dsh-llm'
|
||||
import type { AppIdentity } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
const manifest = createRequire(import.meta.url)('../package.json') as { version: string }
|
||||
|
||||
/** A white-label identity exercising every override seam. */
|
||||
const forkIdentity: AppIdentity = {
|
||||
product: 'fork-agent',
|
||||
version: '9.9.9',
|
||||
url: 'https://example.com/fork-agent',
|
||||
}
|
||||
|
||||
describe('APP_IDENTITY', () => {
|
||||
it('sources the version from the package manifest, never a hand-copied constant', () => {
|
||||
expect(APP_IDENTITY.version).toBe(manifest.version)
|
||||
})
|
||||
|
||||
it('carries only static public product facts', () => {
|
||||
expect(APP_IDENTITY).toEqual({
|
||||
product: 'deepseek-harness',
|
||||
version: manifest.version,
|
||||
url: 'https://github.com/deepseek-ai/deepseek-harness-sdk',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('userAgent', () => {
|
||||
it('renders product/version with the +url comment', () => {
|
||||
expect(userAgent()).toBe(
|
||||
`deepseek-harness/${manifest.version} (+https://github.com/deepseek-ai/deepseek-harness-sdk)`,
|
||||
)
|
||||
})
|
||||
|
||||
it('renders a custom identity', () => {
|
||||
expect(userAgent(forkIdentity)).toBe('fork-agent/9.9.9 (+https://example.com/fork-agent)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('attributionHeaders', () => {
|
||||
it('defaults to the provider-neutral baseline: User-Agent and nothing else', () => {
|
||||
expect(attributionHeaders()).toEqual({ 'user-agent': userAgent() })
|
||||
})
|
||||
|
||||
it('maps a custom identity onto the User-Agent header only', () => {
|
||||
expect(attributionHeaders(forkIdentity)).toEqual({
|
||||
'user-agent': 'fork-agent/9.9.9 (+https://example.com/fork-agent)',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -81,7 +81,7 @@ describe('BlockAssembler properties', () => {
|
||||
fc.assert(fc.property(streamArb, (chunks) => {
|
||||
const blocks = feed(chunks).blocks()
|
||||
for (const block of blocks) {
|
||||
expect(['text', 'reasoning', 'tool-call', 'tool-result', 'image']).toContain(block.type)
|
||||
expect(['text', 'reasoning', 'tool-call', 'tool-result']).toContain(block.type)
|
||||
}
|
||||
}))
|
||||
})
|
||||
|
||||
@@ -118,7 +118,7 @@ describe('deriveReplayScript', () => {
|
||||
it('ignores non-assistant/chunk events', () => {
|
||||
let seq = 1
|
||||
const events: SessionEvent[] = [
|
||||
{ type: 'turn/start', seq: seq++, time: 0, data: { turn: 1, trigger: { kind: 'continuation' } } },
|
||||
{ type: 'turn/start', seq: seq++, time: 0, data: { turn: 1, trigger: { kind: 'injection', source: { kind: 'user' } } } },
|
||||
...TEXT_CHUNKS.map(c => chunkEvent(seq++, 1, 1, c)),
|
||||
{ type: 'turn/end', seq: seq++, time: 0, data: { turn: 1, reason: { kind: 'completed' } } },
|
||||
]
|
||||
|
||||
@@ -7,6 +7,7 @@ Integrations that expose the agent to an external editor or client. These are **
|
||||
| `acp/` | Agent Client Protocol bridge: serves the agent to an ACP editor (Zed) over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) |
|
||||
| `stdio-agent/` | Terminal stdio chat APP: the agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) |
|
||||
| `acp-agent/` | ACP server APP: the agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) |
|
||||
| `app-boot/` | Shared boot glue for the two app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) |
|
||||
|
||||
A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The readline UI is the unstructured analogue of the `acp` bridge and lives INSIDE the stdio app (the `stdio-chat` module of [`stdio-agent/`](stdio-agent/README.md)): it is scaffolding for that one front door, not an independently swappable integration, so it carries no package boundary of its own.
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"peerDependencies": {
|
||||
"@cordisjs/plugin-include": "^1.0.4",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
|
||||
"@deepseek-ai/dsh-app-boot": "^0.0.1",
|
||||
"@deepseek-ai/dsh-acp": "^0.0.1",
|
||||
"@deepseek-ai/dsh-agent-core": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
|
||||
@@ -41,6 +42,7 @@
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-include": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
"@deepseek-ai/dsh-acp": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-core": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
|
||||
@@ -2,167 +2,45 @@
|
||||
/**
|
||||
* The `dsh-acp-agent` bin: boot the ACP server from a leaf `cordis.yml` that
|
||||
* loads the {@link @deepseek-ai/dsh-acp-agent} app plugin (plus an LLM adapter
|
||||
* and a bash executor), speaking ACP JSON-RPC on stdio.
|
||||
* and a bash executor), speaking ACP JSON-RPC on stdio. The shared boot glue —
|
||||
* `.env` loading, the fail-loud Loader guards, snapshot-aware config
|
||||
* resolution, the settle-the-tree boot sequence — lives in
|
||||
* {@link @deepseek-ai/dsh-app-boot}; this bin owns only the ACP-specific
|
||||
* lifecycle:
|
||||
*
|
||||
* Owns the ACP-specific boot glue the example's `start.ts` once held:
|
||||
* - `.env` loading (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`) — SKIPPED in
|
||||
* snapshot REPLAY so a stray key can never trigger a live model call.
|
||||
* - snapshot-mode config selection: `DSH_SNAPSHOT=replay` swaps the given
|
||||
* `cordis.yml` for its sibling `cordis.snapshot.yml` (the keyless replay
|
||||
* tree: `llm-replay` in place of `llm-deepseek`).
|
||||
* - the stdin-dispose lifecycle: in a snapshot run the harness closes stdin
|
||||
* when done, so dispose the context (flushing persistence) and exit cleanly.
|
||||
* - `.env` loading is SKIPPED in snapshot REPLAY so a stray key can never
|
||||
* trigger a live model call.
|
||||
* - `DSH_SNAPSHOT=replay` swaps the given `cordis.yml` for its sibling
|
||||
* `cordis.snapshot.yml` (the keyless replay tree: `llm-replay` in place of
|
||||
* `llm-deepseek`).
|
||||
* - In a snapshot run the harness closes stdin when done, so dispose the
|
||||
* context (flushing persistence) and exit cleanly. In a normal editor
|
||||
* session stdin stays open for the connection's lifetime (the editor kills
|
||||
* the process), so the EOF handler never fires.
|
||||
*
|
||||
* IMPORTANT: stdout is the ACP JSON-RPC channel. This bin writes diagnostics to
|
||||
* STDERR only; the app plugin loads no stdout logger. A stray stdout write
|
||||
* corrupts the protocol frames.
|
||||
* STDERR only (the app plugin loads no stdout logger, and the shared guards
|
||||
* write to stderr); a stray stdout write corrupts the protocol frames.
|
||||
*
|
||||
* Usage: `dsh-acp-agent [path-to-cordis.yml]` (default `./cordis.yml`).
|
||||
*
|
||||
* @module @deepseek-ai/dsh-acp-agent/bin
|
||||
*/
|
||||
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { basename, dirname, resolve } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
|
||||
|
||||
/**
|
||||
* Resolve the config to boot, honoring snapshot REPLAY. Given the requested
|
||||
* path, replay mode swaps a `cordis.yml` basename for `cordis.snapshot.yml` in
|
||||
* the SAME directory (the keyless replay tree). Other modes use the path as-is.
|
||||
* Returns an absolute path resolved from the cwd.
|
||||
*/
|
||||
export function resolveConfigPath(configPath: string, snapshotMode: string | undefined): string {
|
||||
const absolute = resolve(process.cwd(), configPath)
|
||||
if (snapshotMode !== 'replay') return absolute
|
||||
const dir = dirname(absolute)
|
||||
const replayName = basename(absolute).replace(/cordis\.ya?ml$/, 'cordis.snapshot.yml')
|
||||
return resolve(dir, replayName)
|
||||
}
|
||||
const NAME = 'dsh-acp-agent'
|
||||
|
||||
/**
|
||||
* Load `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL` from a gitignored `.env` in the
|
||||
* cwd (Node native). Diagnostics go to STDERR (stdout is the protocol). In
|
||||
* REPLAY mode the caller skips this entirely — replay must never reach the
|
||||
* network, so a present `.env` must not enable a live call.
|
||||
*/
|
||||
function loadEnv(): void {
|
||||
try {
|
||||
process.loadEnvFile(resolve(process.cwd(), '.env'))
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') {
|
||||
process.stderr.write(`dsh-acp-agent: failed to load .env: ${String(error)}\n`)
|
||||
}
|
||||
// ENOENT (no .env) is fine — rely on the ambient environment.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a load failure fail loud with a clear message on stderr. Covers the
|
||||
* failure path the entry-tree check below cannot: when the include's
|
||||
* `[Service.init]` throws (e.g. a config FILE missing in a real directory), the
|
||||
* cordis Loader surfaces it as an unhandled promise rejection AFTER `boot()`
|
||||
* resolves — `loader.await()` does NOT rethrow it (`EntryTree.await()` uses
|
||||
* `Promise.allSettled`, which swallows rejections). Node's default handler
|
||||
* already exits non-zero on an unhandled rejection, so this does not change the
|
||||
* exit code; it replaces the noisy stack dump with a single labelled line (on
|
||||
* STDERR — stdout is the ACP JSON-RPC channel) and guarantees `process.exit(1)`.
|
||||
* Install before `boot()`.
|
||||
*/
|
||||
export function installFailLoud(): void {
|
||||
process.on('unhandledRejection', (err: unknown) => {
|
||||
process.stderr.write(`dsh-acp-agent: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
|
||||
process.exit(1)
|
||||
/* v8 ignore start -- thin self-executing composition over the unit-tested
|
||||
dsh-app-boot helpers; exercised end-to-end by the snapshot suite and the
|
||||
built-bin smoke */
|
||||
installFailLoud(NAME)
|
||||
const snapshotMode = process.env['DSH_SNAPSHOT']
|
||||
if (snapshotMode !== 'replay') loadEnv(NAME)
|
||||
const ctx = await boot(NAME, resolveConfigPath(process.argv[2] ?? './cordis.yml', snapshotMode))
|
||||
if (snapshotMode !== undefined) {
|
||||
process.stdin.on('end', () => {
|
||||
void ctx.fiber.dispose().then(() => { process.exit(0) })
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* After the tree settles, assert every loader entry actually started. This is
|
||||
* the load-bearing guard against the SILENT-exit-0 bug: a plugin module that
|
||||
* fails to IMPORT (e.g. a config path in a non-existent directory) is caught and
|
||||
* only LOGGED by the cordis Loader (`entry._init`), leaving the entry with no
|
||||
* `fiber` and producing no rejection — so the process would otherwise exit 0. A
|
||||
* started entry has a `fiber`; throw on any entry still missing one so `boot()`
|
||||
* rejects.
|
||||
*
|
||||
* A `disabled` entry is the one legitimate fiber-less state: `Entry.refresh()`
|
||||
* deliberately skips `init()` for it, so it settles without a fiber by design —
|
||||
* a valid "plugin turned off" config, not a failed import. Exclude it.
|
||||
*/
|
||||
function assertEntriesLoaded(ctx: Context): void {
|
||||
const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled)
|
||||
if (failed.length > 0) {
|
||||
const names = failed.map(entry => entry.options.name).join(', ')
|
||||
throw new Error(`dsh-acp-agent: plugin(s) failed to load: ${names} (see the error(s) logged above)`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the Loader against `absoluteConfigPath`. The include is handed the
|
||||
* config's ABSOLUTE `file://` URL as its `path`, so resolution never depends on
|
||||
* `ctx.baseUrl` (an absolute URL ignores the base) and can never fall back to
|
||||
* the cwd. `baseUrl` is still pinned to the config's directory so the config's
|
||||
* OWN relative plugin/include paths resolve against it. Returns the root context
|
||||
* once the whole tree has settled.
|
||||
*
|
||||
* The `await ctx.loader.await()` is load-bearing: `loader.create()` returns once
|
||||
* the include ENTRY is registered, but the include then loads its child plugins
|
||||
* asynchronously. Without awaiting the tree, `boot()` would resolve while the ACP
|
||||
* bridge is still mounting — the process would have no stdin handle attached yet
|
||||
* and could exit 0 silently. Awaiting keeps the process alive until the bridge
|
||||
* is up.
|
||||
*
|
||||
* `loader.await()` does NOT rethrow load errors (`EntryTree.await()` uses
|
||||
* `Promise.allSettled`), so failures are surfaced two ways: a plugin that fails
|
||||
* to IMPORT leaves an entry with no fiber, caught here by
|
||||
* {@link assertEntriesLoaded} (this `boot()` rejects); a plugin whose init THROWS
|
||||
* surfaces as an unhandled rejection caught by {@link installFailLoud} (installed
|
||||
* by `main()` before this runs). Together any load failure exits non-zero.
|
||||
*
|
||||
* Bare plugin specifiers in the config (`@deepseek-ai/dsh-*`, npm packages) are
|
||||
* resolved by the cordis Loader's internal module loader, which is only active
|
||||
* under `node --expose-internals`. The `demo:acp` script runs under tsx (whose
|
||||
* tsconfig `paths` map resolves the workspace plugins instead), but a consumer
|
||||
* running the built bin under plain node must pass `--expose-internals` so the
|
||||
* Loader resolves the config's plugins from the config directory rather than
|
||||
* relative to its own module.
|
||||
*/
|
||||
export async function boot(absoluteConfigPath: string): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
await ctx.loader.create({
|
||||
name: '@cordisjs/plugin-include',
|
||||
config: { path: pathToFileURL(absoluteConfigPath).href },
|
||||
})
|
||||
await ctx.loader.await()
|
||||
assertEntriesLoaded(ctx)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point. Installs the fail-loud guard, selects the config (snapshot-aware),
|
||||
* loads `.env` outside replay, boots, and — in a snapshot run — disposes the
|
||||
* context on stdin EOF so the session log is fully flushed before exit and the
|
||||
* harness's `waitForExit` resolves. In a normal editor session stdin stays open
|
||||
* for the connection's lifetime (the editor kills the process), so the EOF
|
||||
* handler never fires.
|
||||
*/
|
||||
export async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
|
||||
installFailLoud()
|
||||
const snapshotMode = process.env.DSH_SNAPSHOT
|
||||
const configPath = resolveConfigPath(argv[0] ?? './cordis.yml', snapshotMode)
|
||||
if (snapshotMode !== 'replay') loadEnv()
|
||||
const ctx = await boot(configPath)
|
||||
if (snapshotMode !== undefined) {
|
||||
process.stdin.on('end', () => {
|
||||
void ctx.fiber.dispose().then(() => { process.exit(0) })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/* v8 ignore start -- top-level CLI invocation; the testable core is
|
||||
resolveConfigPath()/boot()/main(), driven by the keyless snapshot + Loader-path tests */
|
||||
await main()
|
||||
/* v8 ignore stop */
|
||||
|
||||
@@ -40,7 +40,7 @@ const acpBin = join(repoRoot, 'packages/ui/acp-agent/lib/bin.js')
|
||||
const dshPackages = [
|
||||
'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt',
|
||||
'core/tools', 'core/agent-loop', 'llm/llm', 'llm/llm-deepseek', 'bash/bash',
|
||||
'bash/bash-local', 'bash/tool-bash', 'support/invariants',
|
||||
'bash/bash-local', 'bash/tool-bash', 'support/invariants', 'ui/app-boot',
|
||||
'session-persistence/session-persistence',
|
||||
'session-persistence/session-persistence-jsonl', 'ui/acp', 'ui/acp-agent',
|
||||
]
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
{
|
||||
"path": "../../../vendor/loader"
|
||||
},
|
||||
{
|
||||
"path": "../app-boot"
|
||||
},
|
||||
{
|
||||
"path": "../acp"
|
||||
},
|
||||
|
||||
@@ -16,8 +16,8 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline `
|
||||
|---|---|---|
|
||||
| `model` | — | Model name for created agents (must have a registered adapter). |
|
||||
| `systemPrompt` | — | Per-agent system prompt. |
|
||||
| `agentName` | `deepseek-harness-acp` | Server name reported in `initialize`. |
|
||||
| `agentVersion` | `0.0.1` | Server version reported in `initialize`. |
|
||||
|
||||
The `initialize` handshake reports a fixed server identity (`agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' }`) — branding is a literal at the `initialize` site, not config.
|
||||
|
||||
## ACP method mapping
|
||||
|
||||
@@ -48,7 +48,7 @@ How a tool call renders in the editor is owned by the TOOL, not the bridge — t
|
||||
- `{ card: 'terminal', title, description?, cwd? }` — a shell command → a terminal card (see Terminal card).
|
||||
- `{ card: 'diff', title, diffs, locations? }` — a file create/modify → an inline diff card; `diffs` is `FileDiff[]` (`{ path, oldText, newText }`, `oldText: null` ⇒ new file). The bridge emits each diff as an ACP `{ type: 'diff', path, oldText, newText }` `tool_call.content` block, which Zed renders as an inline diff / new-file preview.
|
||||
|
||||
`presentResult` returns a `ToolResultView`, one of three cards: `{ card: 'generic', title?, content? }` (an optional replacement `title` and reformatted `content`), `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit — see Terminal card), or `{ card: 'diff', title?, diffs }` (a completed file mutation → typically the applied hunks with context lines computed from the before/after content, or a whole-file diff for a create; a successful mutation ALWAYS returns this so the model-facing result text can't clobber the diff — an ACP `tool_call_update.content` replaces the call's content). The bridge looks the definition up by name in `ctx.tools` and `switch (view.card)`es to build the ACP `tool_call`/`tool_call_update` wire shape per card; a tool that declares neither gets a generic fallback (title = tool name, raw parsed args as `rawInput`, kind inferred from the name). For example `dsh-tool-bash` returns a `terminal` card for a foreground `bash` (title = the exact `command` "ls -la src", `description` = the model description); the `dsh-tool-fs` `write`/`edit` tools return a `diff` call card and a `diff` result card, and `read` returns a `generic` card (`kind: 'read'`, the read window in its title — `Read foo.txt (5 - 8)` — and a `locations` entry for the file). For a file card the bridge **relativizes the title** against the session cwd (mirroring `claude-agent-acp`'s `toDisplayPath` — `Read src/foo.ts`, not the absolute path) while keeping `locations[]`/`diffs[].path` **raw** so the editor opens the real path.
|
||||
`presentResult` returns a `ToolResultView`, one of three cards: `{ card: 'generic', title?, content? }` (an optional replacement `title` and reformatted `content`), `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit — see Terminal card), or `{ card: 'diff', title?, diffs }` (a completed file mutation → typically the applied hunks with context lines computed from the before/after content, or a whole-file diff for a create; a successful mutation ALWAYS returns this so the model-facing result text can't clobber the diff — an ACP `tool_call_update.content` replaces the call's content). The bridge looks the definition up by name in `ctx.tools` and `switch (view.card)`es to build the ACP `tool_call`/`tool_call_update` wire shape per card; a tool that declares neither gets a generic fallback (title = tool name, raw parsed args as `rawInput`, kind `other` — the bridge never sniffs a kind from the tool name). For example `dsh-tool-bash` returns a `terminal` card for a foreground `bash` (title = the exact `command` "ls -la src", `description` = the model description); the `dsh-tool-fs` `write`/`edit` tools return a `diff` call card and a `diff` result card, and `read` returns a `generic` card (`kind: 'read'`, the read window in its title — `Read foo.txt (5 - 8)` — and a `locations` entry for the file). For a file card the bridge **relativizes the title** against the session cwd (mirroring `claude-agent-acp`'s `toDisplayPath` — `Read src/foo.ts`, not the absolute path) while keeping `locations[]`/`diffs[].path` **raw** so the editor opens the real path.
|
||||
|
||||
The `tool/result` session event does not carry the tool name or args — so to call a tool's `presentResult` the bridge keeps a small per-session map from `callId` to the in-flight call's `(name, args)`, populated on `tool/call` and removed as each result is presented (it holds only currently-in-flight calls, never finished ones). This is bridge-local state — NOT a change to the event schema or a core service. The map lives on the `SessionRecord`, so two concurrent sessions never cross their in-flight tool state; a `session/load` replay uses a throwaway presenter that pairs each `tool/call` with its `tool/result` as the log replays in order, so replayed tool cards render identically to live ones.
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs
|
||||
| `sessionCapabilities.*` | S | ❌ | ✅ | ✅ | None advertised (list/delete/resume/close/additionalDirectories/fork all off). |
|
||||
| `auth.logout` | S | ❌ | ✅ | ✅ | Not advertised. |
|
||||
| `authMethods[]` | S | ⚠️ | ✅ | ✅ | Advertised as empty (no auth required to reach the model). |
|
||||
| `agentInfo` (name/version) | S | ✅ | ✅ | ✅ | From `agentName` / `agentVersion` config. |
|
||||
| `agentInfo` (name/version) | S | ✅ | ✅ | ✅ | Fixed literals: `deepseek-harness-acp` / `0.0.1` (not config). |
|
||||
| `_meta` custom caps | S | ❌ | ✅ | — | E.g. Claude's `claudeCode.promptQueueing`. The bridge advertises no custom `_meta`. |
|
||||
|
||||
### 3b. `clientCapabilities` (consumed by the bridge)
|
||||
@@ -96,7 +96,7 @@ Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult
|
||||
|
||||
| Feature | Stable | Bridge | Claude | Codex | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| `ToolCallKind` mapping | S | ✅ | ✅ | ✅ | `execute`/`read`/`edit`/`other` inferred from the tool; richer mapping possible. |
|
||||
| `ToolCallKind` mapping | S | ✅ | ✅ | ✅ | `execute`/`read`/`edit` declared by each tool's `presentCall`; presenter-less tools render `other` (no name sniffing); richer mapping possible. |
|
||||
| `ToolCallStatus` | S | ✅ | ✅ | ✅ | `in_progress` → `completed`/`failed`. |
|
||||
| `content` blocks | S | ✅ | ✅ | ✅ | Text content; the description renders above the card. |
|
||||
| `diff` content | S | ✅ | ✅ | ✅ | The `write`/`edit` tools declare a `diff` render intent: `presentCall` → a call-time `{ card: 'diff' }` snippet, and `presentResult` → a result-time `{ card: 'diff' }`. For an edit or an overwrite it carries the applied hunk(s) with surrounding context (one per `replace_all` site), computed from the before/after text and persisted on the `tool/result` event as `meta`; for a create (no before-image) it is an args-derived whole-file diff. The bridge emits `{ type: 'diff', path, oldText, newText }` content blocks; a successful mutation ALWAYS returns the result diff (an ACP `tool_call_update.content` replaces the call's content, so the result diff — not the model-facing text — is what survives). |
|
||||
|
||||
@@ -69,8 +69,8 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason {
|
||||
* client as message content. Today only `text` maps; `resource_link` is an
|
||||
* ACP prompt-only input rendered into text by {@link acpPromptToText};
|
||||
* `reasoning` is surfaced via `agent_thought_chunk`
|
||||
* streaming rather than as a message block, and `tool-call`/`tool-result`/
|
||||
* `image` are handled by the tool-call update path or not advertised.
|
||||
* streaming rather than as a message block, and `tool-call`/`tool-result`
|
||||
* are handled by the tool-call update path.
|
||||
*/
|
||||
export function harnessBlockToAcpContent(block: ContentBlock): AcpContentBlock | undefined {
|
||||
switch (block.type) {
|
||||
@@ -78,7 +78,7 @@ export function harnessBlockToAcpContent(block: ContentBlock): AcpContentBlock |
|
||||
return { type: 'text', text: block.text }
|
||||
// reasoning → streamed as agent_thought_chunk, not a message block
|
||||
// tool-call / tool-result → the tool_call / tool_call_update path
|
||||
// image → not advertised
|
||||
// plugin-added block types → not surfaced
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolCallKind, ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } from '@deepseek-ai/dsh-tools'
|
||||
// Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
|
||||
// Context (the bridge injects it and reads `list()` for load cwd validation).
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
@@ -117,10 +117,6 @@ export interface AcpConfig {
|
||||
model?: string
|
||||
/** Per-agent system prompt. */
|
||||
systemPrompt?: string
|
||||
/** Agent/server name reported to the client in `initialize`. */
|
||||
agentName?: string
|
||||
/** Agent/server version reported to the client in `initialize`. */
|
||||
agentVersion?: string
|
||||
/**
|
||||
* Transport stream override. Production omits this (the plugin wires
|
||||
* `process.stdin`/`process.stdout` via `ndJsonStream`). Tests inject an
|
||||
@@ -134,8 +130,6 @@ export interface AcpConfig {
|
||||
export const Config: Schema<AcpConfig> = Schema.object({
|
||||
model: Schema.string(),
|
||||
systemPrompt: Schema.string(),
|
||||
agentName: Schema.string().default('deepseek-harness-acp'),
|
||||
agentVersion: Schema.string().default('0.0.1'),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -209,13 +203,6 @@ interface SessionRecord {
|
||||
* (settle-exactly-once).
|
||||
*/
|
||||
export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// TODO(double-default): these literals duplicate the Config schema defaults
|
||||
// (`agentName`/`agentVersion` `.default(...)` above). The Loader applies the
|
||||
// schema before apply() runs, so the `??` only fires for direct-apply unit
|
||||
// tests. Pick one home for the default to avoid drift.
|
||||
const agentName = config.agentName ?? 'deepseek-harness-acp'
|
||||
const agentVersion = config.agentVersion ?? '0.0.1'
|
||||
|
||||
// Capture the injected services NOW, during apply(), while we are inside this
|
||||
// plugin's fiber (where `inject` grants access). The ACP method handlers run
|
||||
// LATER, from the AgentSideConnection's JSON-RPC read loop — a context that is
|
||||
@@ -430,7 +417,9 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
terminalOutputCap = params.clientCapabilities?._meta?.['terminal_output'] === true
|
||||
return Promise.resolve({
|
||||
protocolVersion,
|
||||
agentInfo: { name: agentName, version: agentVersion },
|
||||
// Fixed server identity: this bridge IS the harness ACP server, so the
|
||||
// branding is a literal, not config (no shipped surface sets it).
|
||||
agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' },
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
// Baseline prompt blocks only: text plus resource_link rendered as
|
||||
@@ -911,9 +900,11 @@ export class ToolPresenter {
|
||||
this.onError(`acp: tool "${name}" presentCall threw, using generic presentation: ${String(error)}`)
|
||||
present = undefined
|
||||
}
|
||||
// No tool-owned presentation: fall back to the tool name as the title and the
|
||||
// full parsed args as the raw input (the generic card).
|
||||
const view: ToolCallView = present ?? { card: 'generic', title: name, kind: toolKindFor(name), rawInput: args }
|
||||
// No tool-owned presentation: fall back to the tool name as the title, the
|
||||
// full parsed args as the raw input, and kind `other` (the generic card).
|
||||
// The kind is never sniffed from the name — the bridge does not special-case
|
||||
// tool names; a tool that wants a richer kind declares `presentCall`.
|
||||
const view: ToolCallView = present ?? { card: 'generic', title: name, kind: 'other', rawInput: args }
|
||||
this.pending.set(callId, { name, args, card: view.card })
|
||||
return view
|
||||
}
|
||||
@@ -951,18 +942,10 @@ export class ToolPresenter {
|
||||
* results pass their raw content through unchanged.
|
||||
*/
|
||||
export const nullToolPresenter: Pick<ToolPresenter, 'call' | 'result'> = {
|
||||
call: (_callId, name, argsJson) => ({ card: 'generic', title: name, kind: toolKindFor(name), rawInput: parseToolArguments(argsJson) }),
|
||||
call: (_callId, name, argsJson) => ({ card: 'generic', title: name, kind: 'other', rawInput: parseToolArguments(argsJson) }),
|
||||
result: (_callId, content) => ({ card: 'generic', content }),
|
||||
}
|
||||
|
||||
/** Map a harness tool name to an ACP ToolKind (best-effort; default `other`). */
|
||||
function toolKindFor(name: string): ToolCallKind {
|
||||
if (name === 'bash' || name === 'bash_output' || name === 'bash_kill') return 'execute'
|
||||
if (name === 'read' || name.startsWith('read')) return 'read'
|
||||
if (name === 'write' || name === 'edit' || name.startsWith('edit')) return 'edit'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
/** Parse a tool-call arguments JSON string for `rawInput`; raw string on failure. */
|
||||
function parseToolArguments(args: string): unknown {
|
||||
try {
|
||||
|
||||
@@ -33,7 +33,7 @@ describe('acp bridge', () => {
|
||||
expect(res.protocolVersion).toBe(PROTOCOL_VERSION)
|
||||
expect(res.agentCapabilities?.loadSession).toBe(true)
|
||||
expect(res.agentCapabilities?.promptCapabilities).toMatchObject({ image: false, audio: false })
|
||||
expect(res.agentInfo?.name).toBe('deepseek-harness-acp')
|
||||
expect(res.agentInfo).toEqual({ name: 'deepseek-harness-acp', version: '0.0.1' })
|
||||
})
|
||||
|
||||
it('session/new creates a session and a full prompt turn streams text then settles end_turn', async () => {
|
||||
@@ -148,14 +148,13 @@ describe('acp bridge', () => {
|
||||
await expect(harness.client.authenticate({ methodId: 'whatever' })).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('honors agentName/agentVersion/systemPrompt config', async () => {
|
||||
it('honors systemPrompt config', async () => {
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
script: [textResponse('ok')],
|
||||
config: { agentName: 'custom-agent', agentVersion: '9.9.9', systemPrompt: 'be terse' },
|
||||
config: { systemPrompt: 'be terse' },
|
||||
})
|
||||
const res = await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
expect(res.agentInfo).toMatchObject({ name: 'custom-agent', version: '9.9.9' })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
// Create + prompt so the systemPrompt config flows through agentOptions and
|
||||
// reaches the model request.
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
|
||||
import type { ContentBlock as AcpContentBlock } from '@agentclientprotocol/sdk'
|
||||
import {
|
||||
@@ -33,9 +34,9 @@ describe('harnessBlockToAcpContent', () => {
|
||||
expect(harnessBlockToAcpContent({ type: 'text', text: 'hi' })).toEqual({ type: 'text', text: 'hi' })
|
||||
})
|
||||
|
||||
it('returns undefined for non-text blocks (reasoning/tool/image)', () => {
|
||||
it('returns undefined for non-text blocks (reasoning / plugin-added)', () => {
|
||||
expect(harnessBlockToAcpContent({ type: 'reasoning', text: 'think' })).toBeUndefined()
|
||||
expect(harnessBlockToAcpContent({ type: 'image', url: 'https://x/y.png', mimeType: 'image/png' })).toBeUndefined()
|
||||
expect(harnessBlockToAcpContent({ type: 'chart', data: 'x' } as unknown as ContentBlock)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -50,32 +50,34 @@ describe('streamSessionEventUpdate', () => {
|
||||
.toEqual([])
|
||||
})
|
||||
|
||||
it('maps tool/call to an in_progress tool_call with inferred kind and parsed rawInput (generic fallback, no presenter)', () => {
|
||||
it('maps tool/call to an in_progress tool_call with kind other and parsed rawInput (generic fallback, no presenter)', () => {
|
||||
const updates = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{"command":"ls"}' }))
|
||||
expect(updates).toEqual([{
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'c1',
|
||||
title: 'bash',
|
||||
kind: 'execute',
|
||||
// The fallback never sniffs a kind from the tool name — even a name a
|
||||
// first-party tool uses (`bash`) renders `other`; kinds are tool-owned
|
||||
// via presentCall.
|
||||
kind: 'other',
|
||||
status: 'in_progress',
|
||||
rawInput: { command: 'ls' },
|
||||
}])
|
||||
})
|
||||
|
||||
it('infers tool kinds: read*/write*/edit*/other', () => {
|
||||
const kind = (name: string): unknown =>
|
||||
updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c'), name, arguments: '' }))[0]
|
||||
expect((kind('read_file') as { kind: string }).kind).toBe('read')
|
||||
expect((kind('write') as { kind: string }).kind).toBe('edit')
|
||||
expect((kind('edit_file') as { kind: string }).kind).toBe('edit')
|
||||
expect((kind('frobnicate') as { kind: string }).kind).toBe('other')
|
||||
})
|
||||
|
||||
it('falls back to the raw argument string when tool arguments are not JSON', () => {
|
||||
const update = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: 'not json' }))[0]
|
||||
expect((update as { rawInput: unknown }).rawInput).toBe('not json')
|
||||
})
|
||||
|
||||
it('parses EMPTY tool arguments to an empty-object rawInput (a zero-arg call, not the raw-string fallback)', () => {
|
||||
// `JSON.parse('')` throws, so without the empty-string guard a zero-arg
|
||||
// call would render `rawInput: ''` via the non-JSON fallback; the guard
|
||||
// normalizes it to `{}`.
|
||||
const update = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'noop', arguments: '' }))[0]
|
||||
expect((update as { rawInput: unknown }).rawInput).toEqual({})
|
||||
})
|
||||
|
||||
it('maps tool/result to completed/failed tool_call_update with text content', () => {
|
||||
const ok = updatesFor(evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }))
|
||||
expect(ok).toEqual([{
|
||||
@@ -91,7 +93,7 @@ describe('streamSessionEventUpdate', () => {
|
||||
it('drops non-text tool-result content (text-only)', () => {
|
||||
const update = updatesFor(evt('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('c1'),
|
||||
content: [{ type: 'image', url: 'https://x/y.png' }],
|
||||
content: [{ type: 'reasoning', text: 'private' }],
|
||||
isError: false,
|
||||
}))[0]
|
||||
expect((update as { content: unknown[] }).content).toEqual([])
|
||||
|
||||
@@ -66,7 +66,10 @@ describe('acp bridge — turn outcomes', () => {
|
||||
const toolCalls = harness.updates.filter(u => u.sessionUpdate === 'tool_call')
|
||||
const toolUpdates = harness.updates.filter(u => u.sessionUpdate === 'tool_call_update')
|
||||
expect(toolCalls).toHaveLength(1)
|
||||
expect(toolCalls[0]).toMatchObject({ toolCallId: 'c1', title: 'bash', kind: 'execute', status: 'in_progress' })
|
||||
// The inline stand-in declares no presentCall, so the generic fallback
|
||||
// renders kind `other` (kinds are tool-owned; the bridge never sniffs the
|
||||
// name — the REAL dsh-tool-bash test below covers the execute card).
|
||||
expect(toolCalls[0]).toMatchObject({ toolCallId: 'c1', title: 'bash', kind: 'other', status: 'in_progress' })
|
||||
expect(toolUpdates).toHaveLength(1)
|
||||
expect(toolUpdates[0]).toMatchObject({ toolCallId: 'c1', status: 'completed' })
|
||||
|
||||
|
||||
15
packages/ui/app-boot/README.md
Normal file
15
packages/ui/app-boot/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# `@deepseek-ai/dsh-app-boot`
|
||||
|
||||
Shared boot glue for the app bins ([`dsh-stdio-agent`](../stdio-agent/README.md), [`dsh-acp-agent`](../acp-agent/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so the loader-failure lore lives once — under the per-file coverage gate — instead of drifting between two published artifacts.
|
||||
|
||||
| Export | Role |
|
||||
|---|---|
|
||||
| `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` |
|
||||
| `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) |
|
||||
| `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) |
|
||||
| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber (a plugin module that failed to import) |
|
||||
| `boot(binName, absoluteConfigPath)` | Mount the Loader, include the config by absolute `file://` URL, await the whole tree, assert entries loaded, return the root context |
|
||||
|
||||
Two failure classes the guards handle: `loader.await()` swallows init rejections (`Promise.allSettled`) — Node still exits non-zero on the resulting unhandled rejection, and `installFailLoud` replaces the noisy dump with one labelled line and a guaranteed `exit(1)`; a failed plugin IMPORT is only logged by the Loader (the process would otherwise exit 0 on a usable config typo), leaving a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection.
|
||||
|
||||
Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`) resolve through the cordis Loader's internal module loader, active only under `node --expose-internals`; the bins' subprocess smokes exercise that path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers.
|
||||
34
packages/ui/app-boot/package.json
Normal file
34
packages/ui/app-boot/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-app-boot",
|
||||
"description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence",
|
||||
"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"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@cordisjs/plugin-include": "^1.0.4",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-include": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
154
packages/ui/app-boot/src/index.ts
Normal file
154
packages/ui/app-boot/src/index.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Shared boot glue for the app bins (`dsh-stdio-agent`, `dsh-acp-agent`): load
|
||||
* the gitignored `.env`, install the fail-loud Loader guards, resolve the
|
||||
* config path (snapshot-aware), and drive the cordis Loader against a leaf
|
||||
* `cordis.yml` until the whole tree has settled. Each bin stays a thin
|
||||
* self-executing composition over these helpers, parameterized by its
|
||||
* diagnostic prefix; the loader-failure lore lives here, once, under the
|
||||
* per-file coverage gate.
|
||||
*
|
||||
* Two failure classes the guards handle:
|
||||
*
|
||||
* - `loader.await()` does NOT rethrow a load error (`EntryTree.await()` uses
|
||||
* `Promise.allSettled`, which swallows rejections). A plugin whose
|
||||
* `[Service.init]` throws surfaces as an unhandled rejection AFTER `boot()`
|
||||
* resolves — Node's default handler already exits non-zero, and
|
||||
* {@link installFailLoud} replaces the noisy dump with one labelled stderr
|
||||
* line and a guaranteed `exit(1)`.
|
||||
* - A plugin module that fails to IMPORT is caught and only LOGGED by the
|
||||
* cordis Loader (`entry._init`), leaving the entry with no `fiber` and
|
||||
* producing no rejection — the process would otherwise exit 0 with a usable
|
||||
* config typo reported only as a log line; {@link assertEntriesLoaded} makes
|
||||
* `boot()` reject on any such entry instead of returning a half-empty
|
||||
* context.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-app-boot
|
||||
*/
|
||||
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { basename, dirname, resolve } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
|
||||
/**
|
||||
* Resolve the config to boot, honoring snapshot REPLAY. Given the requested
|
||||
* path, replay mode swaps a `cordis.yml` basename for `cordis.snapshot.yml` in
|
||||
* the SAME directory (the keyless replay tree). Other modes — including no
|
||||
* snapshot mode at all — use the path as-is. Returns an absolute path resolved
|
||||
* from `cwd`.
|
||||
*/
|
||||
export function resolveConfigPath(
|
||||
configPath: string, snapshotMode: string | undefined, cwd: string = process.cwd(),
|
||||
): string {
|
||||
const absolute = resolve(cwd, configPath)
|
||||
if (snapshotMode !== 'replay') return absolute
|
||||
const dir = dirname(absolute)
|
||||
const replayName = basename(absolute).replace(/cordis\.ya?ml$/, 'cordis.snapshot.yml')
|
||||
return resolve(dir, replayName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL` from a gitignored `.env` in
|
||||
* `dir` (Node native `process.loadEnvFile`). An absent file is fine — the
|
||||
* environment may already carry the variables; the leaf `cordis.yml` reads
|
||||
* them via the `!!js` tag. A present-but-unreadable `.env` is a real
|
||||
* misconfiguration: surface it via `warn` (one line, default stderr) rather
|
||||
* than silently running with the wrong environment.
|
||||
*/
|
||||
export function loadEnv(
|
||||
binName: string, dir: string = process.cwd(),
|
||||
warn: (line: string) => void = line => void process.stderr.write(line),
|
||||
): void {
|
||||
try {
|
||||
process.loadEnvFile(resolve(dir, '.env'))
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') {
|
||||
warn(`${binName}: failed to load .env: ${String(error)}\n`)
|
||||
}
|
||||
// ENOENT (no .env) is fine — rely on the ambient environment.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The slice of `process` {@link installFailLoud} needs — injectable so tests
|
||||
* exercise the handler without registering on (or exiting) the real process.
|
||||
*/
|
||||
export interface FailLoudProcess {
|
||||
on(event: 'unhandledRejection', handler: (err: unknown) => void): unknown
|
||||
off(event: 'unhandledRejection', handler: (err: unknown) => void): unknown
|
||||
stderr: { write(chunk: string): unknown }
|
||||
exit(code: number): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a load failure fail loud with a clear message on stderr. Covers the
|
||||
* failure path {@link assertEntriesLoaded} cannot: an include whose
|
||||
* `[Service.init]` throws (e.g. a config FILE that does not exist in a real
|
||||
* directory) surfaces as an unhandled promise rejection AFTER `boot()`
|
||||
* resolves. Node's default handler already exits non-zero on an unhandled
|
||||
* rejection; this replaces the noisy stack dump with a single labelled line on
|
||||
* STDERR (never stdout — for the ACP bin that channel carries JSON-RPC) and
|
||||
* guarantees `exit(1)`. Install before `boot()`. Returns the uninstaller
|
||||
* (tests use it; the bins run until exit and never do).
|
||||
*/
|
||||
export function installFailLoud(binName: string, proc: FailLoudProcess = process): () => void {
|
||||
const handler = (err: unknown): void => {
|
||||
proc.stderr.write(`${binName}: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
|
||||
proc.exit(1)
|
||||
}
|
||||
proc.on('unhandledRejection', handler)
|
||||
return () => void proc.off('unhandledRejection', handler)
|
||||
}
|
||||
|
||||
/**
|
||||
* After the tree settles, assert every loader entry actually started. A
|
||||
* started entry has a `fiber`; an entry with `fiber === undefined` after the
|
||||
* tree settled never loaded (its module failed to import), so throw and let
|
||||
* `boot()` reject instead of returning a half-empty context. A `disabled`
|
||||
* entry is the one legitimate fiber-less state: `Entry.refresh()` deliberately
|
||||
* skips `init()` for it — a valid "plugin turned off" config, not a failed
|
||||
* import — so it is excluded.
|
||||
*/
|
||||
export function assertEntriesLoaded(ctx: Context, binName: string): void {
|
||||
const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled)
|
||||
if (failed.length > 0) {
|
||||
const names = failed.map(entry => entry.options.name).join(', ')
|
||||
throw new Error(`${binName}: plugin(s) failed to load: ${names} (see the error(s) logged above)`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the Loader against `absoluteConfigPath` and return the root context
|
||||
* once the whole tree has settled. The include is handed the config's ABSOLUTE
|
||||
* `file://` URL as its `path`, so resolution never depends on `ctx.baseUrl`
|
||||
* (an absolute URL ignores the base) and can never fall back to the cwd;
|
||||
* `baseUrl` is still pinned to the config's directory so the config's OWN
|
||||
* relative plugin/include paths resolve against it.
|
||||
*
|
||||
* The `await ctx.loader.await()` is load-bearing: `loader.create()` returns
|
||||
* once the include ENTRY is registered, but the include then loads its child
|
||||
* plugins asynchronously — without awaiting the tree, `boot()` would resolve
|
||||
* while the app's plugins are still mounting, and a CLI process with no
|
||||
* attached handles yet exits 0 silently. Failures surface two ways: an entry
|
||||
* whose module failed to import is caught here by {@link assertEntriesLoaded}
|
||||
* (this `boot()` rejects); an init that THROWS surfaces as an unhandled
|
||||
* rejection caught by {@link installFailLoud} (installed by the bin first).
|
||||
*
|
||||
* Bare plugin specifiers in the config (`@deepseek-ai/dsh-*`, npm packages)
|
||||
* are resolved by the cordis Loader's internal module loader, which is only
|
||||
* active under `node --expose-internals`; a consumer running a built bin must
|
||||
* pass that flag (or install the plugins where node hoists them). Relative
|
||||
* specifiers resolve against the config directory with no flag.
|
||||
*/
|
||||
export async function boot(binName: string, absoluteConfigPath: string): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
await ctx.loader.create({
|
||||
name: '@cordisjs/plugin-include',
|
||||
config: { path: pathToFileURL(absoluteConfigPath).href },
|
||||
})
|
||||
await ctx.loader.await()
|
||||
assertEntriesLoaded(ctx, binName)
|
||||
return ctx
|
||||
}
|
||||
178
packages/ui/app-boot/tests/app-boot.spec.ts
Normal file
178
packages/ui/app-boot/tests/app-boot.spec.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve, sep } from 'node:path'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { Context } from 'cordis'
|
||||
import {
|
||||
assertEntriesLoaded, boot, installFailLoud, loadEnv, resolveConfigPath,
|
||||
type FailLoudProcess,
|
||||
} from '../src/index.ts'
|
||||
|
||||
const NAME = 'dsh-test-bin'
|
||||
|
||||
const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-app-boot-'))
|
||||
|
||||
describe('resolveConfigPath', () => {
|
||||
it('resolves relative to the given cwd outside replay mode', () => {
|
||||
expect(resolveConfigPath('./cordis.yml', undefined, `${sep}base`)).toBe(resolve(`${sep}base`, 'cordis.yml'))
|
||||
expect(resolveConfigPath('conf/app.yaml', 'record', `${sep}base`)).toBe(resolve(`${sep}base`, 'conf/app.yaml'))
|
||||
})
|
||||
|
||||
it('swaps a cordis.yml/.yaml basename for cordis.snapshot.yml in replay mode', () => {
|
||||
expect(resolveConfigPath('./cordis.yml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'cordis.snapshot.yml'))
|
||||
expect(resolveConfigPath('deep/cordis.yaml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'deep/cordis.snapshot.yml'))
|
||||
})
|
||||
|
||||
it('leaves a non-cordis basename alone in replay mode and defaults cwd to the process cwd', () => {
|
||||
expect(resolveConfigPath('custom.yml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'custom.yml'))
|
||||
expect(resolveConfigPath('./x.yml', undefined)).toBe(resolve(process.cwd(), 'x.yml'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('loadEnv', () => {
|
||||
it('loads variables from .env in the given dir', () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, '.env'), 'DSH_APP_BOOT_SPEC_VAR=loaded\n')
|
||||
const warn = vi.fn()
|
||||
loadEnv(NAME, dir, warn)
|
||||
expect(process.env['DSH_APP_BOOT_SPEC_VAR']).toBe('loaded')
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
delete process.env['DSH_APP_BOOT_SPEC_VAR']
|
||||
})
|
||||
|
||||
it('stays silent when no .env exists (ambient environment wins)', () => {
|
||||
const warn = vi.fn()
|
||||
loadEnv(NAME, tmp(), warn)
|
||||
expect(warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('warns (labelled, single line) when .env exists but cannot be loaded', () => {
|
||||
const dir = tmp()
|
||||
mkdirSync(join(dir, '.env')) // a directory named .env: present, unreadable as a file
|
||||
const warn = vi.fn()
|
||||
loadEnv(NAME, dir, warn)
|
||||
expect(warn).toHaveBeenCalledTimes(1)
|
||||
expect(warn.mock.calls[0]?.[0]).toMatch(new RegExp(`^${NAME}: failed to load \\.env: `))
|
||||
})
|
||||
|
||||
it('defaults dir to the process cwd and warn to a stderr write', () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, '.env'), 'DSH_APP_BOOT_SPEC_DEFAULTS=yes\n')
|
||||
const previous = process.cwd()
|
||||
process.chdir(dir)
|
||||
try {
|
||||
loadEnv(NAME) // happy path: the default warn sink is never invoked
|
||||
} finally {
|
||||
process.chdir(previous)
|
||||
}
|
||||
expect(process.env['DSH_APP_BOOT_SPEC_DEFAULTS']).toBe('yes')
|
||||
delete process.env['DSH_APP_BOOT_SPEC_DEFAULTS']
|
||||
// The default warn sink itself: point it at a broken .env with stderr
|
||||
// spied, so the arrow body runs without polluting the test output.
|
||||
const broken = tmp()
|
||||
mkdirSync(join(broken, '.env'))
|
||||
const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
let written: string[]
|
||||
try {
|
||||
loadEnv(NAME, broken)
|
||||
written = write.mock.calls.map(call => String(call[0]))
|
||||
} finally {
|
||||
write.mockRestore()
|
||||
}
|
||||
expect(written).toHaveLength(1)
|
||||
expect(written[0]).toContain(`${NAME}: failed to load .env: `)
|
||||
})
|
||||
})
|
||||
|
||||
describe('installFailLoud', () => {
|
||||
function fakeProc(): FailLoudProcess & { handlers: Array<(err: unknown) => void>; written: string[]; exits: number[] } {
|
||||
const handlers: Array<(err: unknown) => void> = []
|
||||
const written: string[] = []
|
||||
const exits: number[] = []
|
||||
return {
|
||||
handlers, written, exits,
|
||||
on: (_event, handler) => { handlers.push(handler) },
|
||||
off: (_event, handler) => { handlers.splice(handlers.indexOf(handler), 1) },
|
||||
stderr: { write: (chunk: string) => { written.push(chunk) } },
|
||||
exit: (code: number) => { exits.push(code) },
|
||||
}
|
||||
}
|
||||
|
||||
it('writes one labelled line with the stack and exits 1 on an Error rejection', () => {
|
||||
const proc = fakeProc()
|
||||
installFailLoud(NAME, proc)
|
||||
const error = new Error('boom')
|
||||
proc.handlers[0]!(error)
|
||||
expect(proc.written[0]).toContain(`${NAME}: fatal load failure: `)
|
||||
expect(proc.written[0]).toContain(error.stack)
|
||||
expect(proc.exits).toEqual([1])
|
||||
})
|
||||
|
||||
it('stringifies a non-Error rejection and an Error without a stack falls back to its message', () => {
|
||||
const proc = fakeProc()
|
||||
installFailLoud(NAME, proc)
|
||||
proc.handlers[0]!('plain failure')
|
||||
expect(proc.written[0]).toContain('plain failure')
|
||||
const stackless = new Error('no stack')
|
||||
delete (stackless as { stack?: string }).stack
|
||||
proc.handlers[0]!(stackless)
|
||||
expect(proc.written[1]).toContain('no stack')
|
||||
expect(proc.exits).toEqual([1, 1])
|
||||
})
|
||||
|
||||
it('returns an uninstaller that removes the handler (and defaults to the real process)', () => {
|
||||
const proc = fakeProc()
|
||||
const uninstall = installFailLoud(NAME, proc)
|
||||
expect(proc.handlers).toHaveLength(1)
|
||||
uninstall()
|
||||
expect(proc.handlers).toHaveLength(0)
|
||||
// Default-proc arm: install on the real process, then immediately uninstall
|
||||
// so the suite leaks no handler and can never exit the runner.
|
||||
const before = process.listenerCount('unhandledRejection')
|
||||
const uninstallReal = installFailLoud(NAME)
|
||||
expect(process.listenerCount('unhandledRejection')).toBe(before + 1)
|
||||
uninstallReal()
|
||||
expect(process.listenerCount('unhandledRejection')).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('assertEntriesLoaded', () => {
|
||||
const ctxWith = (entries: Array<{ fiber?: unknown; disabled?: boolean; options: { name?: string } }>): Context =>
|
||||
({ loader: { entries: () => entries } }) as unknown as Context
|
||||
|
||||
it('passes when every enabled entry has a fiber', () => {
|
||||
expect(() => { assertEntriesLoaded(ctxWith([
|
||||
{ fiber: {}, options: { name: 'a' } },
|
||||
{ disabled: true, options: { name: 'off' } },
|
||||
]), NAME) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('throws naming every enabled fiber-less entry', () => {
|
||||
expect(() => { assertEntriesLoaded(ctxWith([
|
||||
{ fiber: {}, options: { name: 'ok' } },
|
||||
{ options: { name: 'broken-a' } },
|
||||
{ options: { name: 'broken-b' } },
|
||||
]), NAME) }).toThrow(`${NAME}: plugin(s) failed to load: broken-a, broken-b`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('boot', () => {
|
||||
it('boots a leaf config through the real Loader and settles the tree', async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n')
|
||||
const ctx = await boot(NAME, join(dir, 'cordis.yml'))
|
||||
try {
|
||||
const entries = [...ctx.loader.entries()]
|
||||
expect(entries.some(entry => entry.options.name === './noop.mjs' && entry.fiber !== undefined)).toBe(true)
|
||||
} finally {
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => {
|
||||
const dir = tmp()
|
||||
writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n')
|
||||
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`)
|
||||
})
|
||||
})
|
||||
21
packages/ui/app-boot/tsconfig.json
Normal file
21
packages/ui/app-boot/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/loader"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/include"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -32,6 +32,7 @@
|
||||
"peerDependencies": {
|
||||
"@cordisjs/plugin-include": "^1.0.4",
|
||||
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
|
||||
"@deepseek-ai/dsh-app-boot": "^0.0.1",
|
||||
"@cordisjs/plugin-logger-console": "^1.0.0",
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
@@ -44,6 +45,7 @@
|
||||
"devDependencies": {
|
||||
"@cordisjs/plugin-include": "workspace:^",
|
||||
"@cordisjs/plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
"@cordisjs/plugin-logger-console": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
|
||||
@@ -2,139 +2,24 @@
|
||||
/**
|
||||
* The `dsh-stdio-agent` bin: boot a Cordis app from a leaf `cordis.yml` that
|
||||
* loads the {@link @deepseek-ai/dsh-stdio-agent} app plugin (plus a backend LLM
|
||||
* adapter and a bash executor). Owns the boot glue the three `examples/*` once
|
||||
* duplicated in their `start.ts`: load the gitignored repo-root `.env`, then
|
||||
* drive the cordis Loader against the config path (default `./cordis.yml`).
|
||||
* adapter and a bash executor). The boot glue — `.env` loading, the fail-loud
|
||||
* Loader guards, the settle-the-tree boot sequence — lives in
|
||||
* {@link @deepseek-ai/dsh-app-boot}, shared with the ACP bin.
|
||||
*
|
||||
* Usage: `dsh-stdio-agent [path-to-cordis.yml]`. The `demo:echo` / `demo:repl`
|
||||
* scripts invoke it with the example's config.
|
||||
* Usage: `dsh-stdio-agent [path-to-cordis.yml]` (default `./cordis.yml`). The
|
||||
* `demo:echo` / `demo:repl` scripts invoke it with the example's config.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-stdio-agent/bin
|
||||
*/
|
||||
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
|
||||
|
||||
/**
|
||||
* Load `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL` from a gitignored `.env` in the
|
||||
* CURRENT WORKING DIRECTORY (Node native `process.loadEnvFile`). An absent file
|
||||
* is fine — the environment may already carry the variables; the leaf
|
||||
* `cordis.yml` reads them via the `!!js` tag. A present-but-unreadable/malformed
|
||||
* `.env` is a real misconfiguration: surface it on stderr rather than silently
|
||||
* running with the wrong environment. The mock-model demo (echo) ships no key
|
||||
* and simply has no `.env`.
|
||||
*/
|
||||
function loadEnv(): void {
|
||||
try {
|
||||
process.loadEnvFile(resolve(process.cwd(), '.env'))
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') {
|
||||
process.stderr.write(`dsh-stdio-agent: failed to load .env: ${String(error)}\n`)
|
||||
}
|
||||
// ENOENT (no .env) is fine — rely on the ambient environment.
|
||||
}
|
||||
}
|
||||
const NAME = 'dsh-stdio-agent'
|
||||
|
||||
/**
|
||||
* Make a load failure fail loud with a clear message on stderr. Covers the
|
||||
* failure path the entry-tree check below cannot: when the include's
|
||||
* `[Service.init]` throws (e.g. a config FILE that does not exist in a real
|
||||
* directory), the cordis Loader surfaces it as an unhandled promise rejection
|
||||
* AFTER `boot()` has resolved — `loader.await()` does NOT rethrow it, because
|
||||
* `EntryTree.await()` uses `Promise.allSettled`, which swallows rejections.
|
||||
* Node's default handler already exits non-zero on an unhandled rejection, so
|
||||
* this does not change the exit code; it replaces Node's noisy stack dump with a
|
||||
* single labelled line and guarantees `process.exit(1)`. Install before `boot()`.
|
||||
*/
|
||||
export function installFailLoud(): void {
|
||||
process.on('unhandledRejection', (err: unknown) => {
|
||||
process.stderr.write(`dsh-stdio-agent: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
|
||||
process.exit(1)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* After the tree settles, assert every loader entry actually started. This is
|
||||
* the load-bearing guard against the SILENT-exit-0 bug: when a plugin module
|
||||
* fails to IMPORT (e.g. a config path in a non-existent directory, so the include
|
||||
* plugin itself cannot be resolved), the cordis Loader catches the import error
|
||||
* and only LOGS it (`entry._init`), leaving the entry with no `fiber` and
|
||||
* producing no rejection — so the process would otherwise exit 0 with a usable
|
||||
* config typo reported only as a log line. A started entry has a `fiber`; an
|
||||
* entry with `fiber === undefined` after the tree settled never loaded. Throw on
|
||||
* any such entry so `boot()` rejects (and the top-level `await` fails the process
|
||||
* non-zero) instead of returning a half-empty context.
|
||||
*
|
||||
* A `disabled` entry is the one legitimate fiber-less state: `Entry.refresh()`
|
||||
* deliberately skips `init()` for it, so it settles without a fiber by design.
|
||||
* That is a valid config (a consumer turning an optional plugin off), not a
|
||||
* failed import — exclude it so the guard catches only real load failures.
|
||||
*/
|
||||
function assertEntriesLoaded(ctx: Context): void {
|
||||
const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled)
|
||||
if (failed.length > 0) {
|
||||
const names = failed.map(entry => entry.options.name).join(', ')
|
||||
throw new Error(`dsh-stdio-agent: plugin(s) failed to load: ${names} (see the error(s) logged above)`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot the Loader against `configPath` (resolved from the CWD). The include is
|
||||
* handed the config's ABSOLUTE `file://` URL as its `path`, so resolution never
|
||||
* depends on `ctx.baseUrl` (an absolute URL ignores the base) and can never fall
|
||||
* back to the cwd. `baseUrl` is still pinned to the config's directory so the
|
||||
* config's OWN relative plugin/include paths (e.g. `./src/mock-llm.ts`) resolve
|
||||
* against it. Returns the root context once the whole tree has settled.
|
||||
*
|
||||
* The `await ctx.loader.await()` is load-bearing: `loader.create()` returns once
|
||||
* the include ENTRY is registered, but the include then loads its child plugins
|
||||
* asynchronously. Without awaiting the tree, `boot()` (and `main()`) would
|
||||
* resolve while the app plugins — the stdin reader, the agent loop — are still
|
||||
* mounting, and a CLI process with no attached handles yet exits 0 silently.
|
||||
* Awaiting the tree keeps the process alive until the app's handles are attached.
|
||||
*
|
||||
* `loader.await()` does NOT, however, rethrow load errors (`EntryTree.await()`
|
||||
* uses `Promise.allSettled`), so failures are surfaced two ways: a plugin that
|
||||
* fails to IMPORT leaves an entry with no fiber, caught here by
|
||||
* {@link assertEntriesLoaded} (this `boot()` rejects); a plugin whose init
|
||||
* THROWS surfaces as an unhandled rejection caught by {@link installFailLoud}
|
||||
* (installed by `main()` before this runs). Together they make any load failure
|
||||
* exit non-zero with a clear message.
|
||||
*
|
||||
* Bare plugin specifiers in the config (`@deepseek-ai/dsh-*`, npm packages) are
|
||||
* resolved by the cordis Loader's internal module loader, which is only active
|
||||
* under `node --expose-internals` (the flag the `demo:echo`/`demo:repl` scripts
|
||||
* pass). Without it the Loader falls back to resolving relative to its own module
|
||||
* and cannot find the config's plugins, so a consumer running the built bin must
|
||||
* pass `--expose-internals` (or install the plugins where node hoists them).
|
||||
*/
|
||||
export async function boot(configPath: string): Promise<Context> {
|
||||
const absolute = resolve(process.cwd(), configPath)
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(dirname(absolute)).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
await ctx.loader.create({
|
||||
name: '@cordisjs/plugin-include',
|
||||
config: { path: pathToFileURL(absolute).href },
|
||||
})
|
||||
await ctx.loader.await()
|
||||
assertEntriesLoaded(ctx)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point: install the fail-loud guard, load `.env`, then boot the config
|
||||
* named on argv (default `./cordis.yml`). Awaited at the module top level by the
|
||||
* published bin (`#!/usr/bin/env node` shebang via the package's `bin` field).
|
||||
*/
|
||||
export async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
|
||||
installFailLoud()
|
||||
loadEnv()
|
||||
await boot(argv[0] ?? './cordis.yml')
|
||||
}
|
||||
|
||||
/* v8 ignore start -- top-level CLI invocation; the testable core is boot()/main(), driven by the keyless Loader-path smoke */
|
||||
await main()
|
||||
/* v8 ignore start -- thin self-executing composition over the unit-tested
|
||||
dsh-app-boot helpers; exercised end-to-end by the keyless Loader-path and
|
||||
built-bin smokes */
|
||||
installFailLoud(NAME)
|
||||
loadEnv(NAME)
|
||||
await boot(NAME, resolveConfigPath(process.argv[2] ?? './cordis.yml', undefined))
|
||||
/* v8 ignore stop */
|
||||
|
||||
@@ -35,7 +35,7 @@ const stdioBin = join(repoRoot, 'packages/ui/stdio-agent/lib/bin.js')
|
||||
const dshPackages = [
|
||||
'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt',
|
||||
'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local',
|
||||
'bash/tool-bash', 'support/invariants',
|
||||
'bash/tool-bash', 'support/invariants', 'ui/app-boot',
|
||||
'session-persistence/session-persistence',
|
||||
'session-persistence/session-persistence-jsonl', 'ui/stdio-agent',
|
||||
]
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
{
|
||||
"path": "../../../vendor/loader"
|
||||
},
|
||||
{
|
||||
"path": "../app-boot"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/logger-console"
|
||||
},
|
||||
|
||||
@@ -28,4 +28,4 @@ Each tool is registered independently; a product that wants only one disables th
|
||||
|
||||
Tool registration follows product **enablement**, not backend availability. A tool stays visible even when its selected provider is missing, misconfigured, ambiguous, or temporarily unavailable; the seam resolves the provider at execution time and execution fails with a structured `WebError` (e.g. `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_AMBIGUOUS`), which `ToolRegistry.execute()` turns into an error tool result the model can read and hooks/UI can route on. This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. To remove a web tool entirely, disable it here in config.
|
||||
|
||||
The tool reads only the aggregated `ctx.web.searchStatus()` / `fetchStatus()` for diagnostics — never each provider's `status()` directly — so provider selection has one owner.
|
||||
The tool never calls a provider's `status()` and never enumerates providers — its only execution path is `ctx.web.search()` / `ctx.web.fetch()`, and provider unavailability reaches it as the structured `WebError` codes selection throws at execution time. Provider selection stays entirely inside the seam, with one owner.
|
||||
|
||||
@@ -188,9 +188,12 @@ describe('tool-web registration', () => {
|
||||
})
|
||||
|
||||
it('registers web_search even when no provider is available (schema follows enablement, not availability)', async () => {
|
||||
const { fiber, ctx } = await mountTools()
|
||||
const { fiber, ctx, call } = await mountTools()
|
||||
expect(ctx.tools.schemas().map(s => s.name)).toContain('web_search')
|
||||
expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'none' })
|
||||
// No provider is registered: the schema stays visible and execution reports
|
||||
// the structured unavailability instead.
|
||||
const out = await call('web_search', { query: 'q' })
|
||||
expect(out.error?.code).toBe('WEB_PROVIDER_UNAVAILABLE')
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -377,9 +377,11 @@ describe('web-fetch-local plugin registration', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
|
||||
const fiber = await ctx.plugin(fetchPlugin, {})
|
||||
expect(ctx.web.fetchStatus()).toEqual({ available: true, providerId: LOCAL_FETCH_PROVIDER_ID })
|
||||
await expect(ctx.web.fetch({ url: `${base}/` }))
|
||||
.resolves.toMatchObject({ providerId: LOCAL_FETCH_PROVIDER_ID, statusCode: 200 })
|
||||
await fiber.dispose()
|
||||
expect(ctx.web.fetchStatus()).toEqual({ available: false, reason: 'configured-missing' })
|
||||
await expect(ctx.web.fetch({ url: `${base}/` }))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' }))
|
||||
})
|
||||
|
||||
it('has no default export (namespace plugin export shape)', () => {
|
||||
@@ -418,7 +420,8 @@ describe('web-fetch-local plugin registration', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
|
||||
const fiber = await ctx.plugin(fetchPlugin, { maxRedirects: 0 })
|
||||
expect(ctx.web.fetchStatus()).toEqual({ available: true, providerId: LOCAL_FETCH_PROVIDER_ID })
|
||||
await expect(ctx.web.fetch({ url: `${base}/` }))
|
||||
.resolves.toMatchObject({ providerId: LOCAL_FETCH_PROVIDER_ID, statusCode: 200 })
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -264,12 +264,14 @@ describe('DeepSeekSearchProvider error handling', () => {
|
||||
|
||||
describe('web-search-deepseek plugin registration', () => {
|
||||
it('registers the provider into ctx.web (HMR-safe)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse(searchResponse())))
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
|
||||
const fiber = await ctx.plugin(deepseekPlugin, { apiKey: 'ds-key' })
|
||||
expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: DEEPSEEK_PROVIDER_ID })
|
||||
await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: DEEPSEEK_PROVIDER_ID })
|
||||
await fiber.dispose()
|
||||
expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-missing' })
|
||||
await expect(ctx.web.search({ query: 'q' }))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' }))
|
||||
})
|
||||
|
||||
it('rejects maxTokens: 0 at plugin construction', async () => {
|
||||
@@ -315,13 +317,14 @@ describe('web-search-deepseek plugin registration', () => {
|
||||
})
|
||||
|
||||
it('boots over ctx.web through the unwrapped module without an inject error', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse(searchResponse())))
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(deepseekPlugin) as Parameters<Context['plugin']>[0]
|
||||
// A collapsed export shape (dropped inject) would throw "without inject" here.
|
||||
const fiber = await ctx.plugin(unwrapped, { apiKey: 'ds-key' })
|
||||
expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: DEEPSEEK_PROVIDER_ID })
|
||||
await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: DEEPSEEK_PROVIDER_ID })
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
@@ -334,7 +337,6 @@ describe('web-search-deepseek plugin registration', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
|
||||
const fiber = await ctx.plugin(deepseekPlugin, {})
|
||||
expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: DEEPSEEK_PROVIDER_ID })
|
||||
await ctx.web.search({ query: 'q' })
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect(url).toBe('https://api.deepseek.com/anthropic/v1/messages')
|
||||
@@ -354,7 +356,8 @@ describe('web-search-deepseek plugin registration', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID })
|
||||
await ctx.plugin(deepseekPlugin, {})
|
||||
expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-unavailable' })
|
||||
await expect(ctx.web.search({ query: 'q' }))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' }))
|
||||
} finally {
|
||||
if (prev !== undefined) process.env.DEEPSEEK_API_KEY = prev
|
||||
}
|
||||
|
||||
@@ -205,12 +205,14 @@ describe('ExaSearchProvider error handling', () => {
|
||||
|
||||
describe('web-search-exa plugin registration', () => {
|
||||
it('registers the provider into ctx.web (HMR-safe)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ results: [] })))
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WebService, { searchProvider: EXA_PROVIDER_ID })
|
||||
const fiber = await ctx.plugin(exaPlugin, { apiKey: 'exa-key' })
|
||||
expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: EXA_PROVIDER_ID })
|
||||
await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: EXA_PROVIDER_ID })
|
||||
await fiber.dispose()
|
||||
expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-missing' })
|
||||
await expect(ctx.web.search({ query: 'q' }))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' }))
|
||||
})
|
||||
|
||||
it('has no default export (namespace plugin export shape)', () => {
|
||||
@@ -238,7 +240,6 @@ describe('web-search-exa plugin registration', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WebService, { searchProvider: EXA_PROVIDER_ID })
|
||||
const fiber = await ctx.plugin(exaPlugin, {})
|
||||
expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: EXA_PROVIDER_ID })
|
||||
await ctx.web.search({ query: 'q' })
|
||||
const [url] = fetchMock.mock.calls[0] as unknown as [string]
|
||||
expect(url).toBe('https://api.exa.ai/search')
|
||||
@@ -256,7 +257,8 @@ describe('web-search-exa plugin registration', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WebService, { searchProvider: EXA_PROVIDER_ID })
|
||||
await ctx.plugin(exaPlugin, {})
|
||||
expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-unavailable' })
|
||||
await expect(ctx.web.search({ query: 'q' }))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' }))
|
||||
} finally {
|
||||
if (prev !== undefined) process.env.EXA_API_KEY = prev
|
||||
}
|
||||
|
||||
@@ -186,12 +186,14 @@ describe('PerplexitySearchProvider error handling', () => {
|
||||
|
||||
describe('web-search-perplexity plugin registration', () => {
|
||||
it('registers the provider into ctx.web (HMR-safe)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ choices: [{ message: { content: 'a' } }], citations: [] })))
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WebService, { searchProvider: PERPLEXITY_PROVIDER_ID })
|
||||
const fiber = await ctx.plugin(perplexityPlugin, { apiKey: 'pplx-key' })
|
||||
expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: PERPLEXITY_PROVIDER_ID })
|
||||
await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: PERPLEXITY_PROVIDER_ID })
|
||||
await fiber.dispose()
|
||||
expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-missing' })
|
||||
await expect(ctx.web.search({ query: 'q' }))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' }))
|
||||
})
|
||||
|
||||
it('has no default export (namespace plugin export shape)', () => {
|
||||
@@ -219,7 +221,6 @@ describe('web-search-perplexity plugin registration', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WebService, { searchProvider: PERPLEXITY_PROVIDER_ID })
|
||||
const fiber = await ctx.plugin(perplexityPlugin, {})
|
||||
expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: PERPLEXITY_PROVIDER_ID })
|
||||
await ctx.web.search({ query: 'q' })
|
||||
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect(url).toBe('https://api.perplexity.ai/chat/completions')
|
||||
@@ -238,7 +239,8 @@ describe('web-search-perplexity plugin registration', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(WebService, { searchProvider: PERPLEXITY_PROVIDER_ID })
|
||||
await ctx.plugin(perplexityPlugin, {})
|
||||
expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-unavailable' })
|
||||
await expect(ctx.web.search({ query: 'q' }))
|
||||
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' }))
|
||||
} finally {
|
||||
if (prev !== undefined) process.env.PERPLEXITY_API_KEY = prev
|
||||
}
|
||||
|
||||
@@ -18,8 +18,7 @@ Search and fetch share no request schema and no business logic, but they are del
|
||||
|
||||
| Member | Semantics |
|
||||
|---|---|
|
||||
| `registerSearchProvider(provider)` / `registerFetchProvider(provider)` | Register a backend. Throws `WebError` `WEB_DUPLICATE_PROVIDER` on a duplicate id within that capability kind. Returns a disposer; emits `web/providers-change` on register and on dispose. Disposed with the calling fiber. |
|
||||
| `searchStatus()` / `fetchStatus()` | Derived (never stored) `WebCapabilityStatus`: whether the capability has a selected usable provider, or the broad category it fails in. Diagnostics + execution-resolution input. |
|
||||
| `registerSearchProvider(provider)` / `registerFetchProvider(provider)` | Register a backend. Throws `WebError` `WEB_DUPLICATE_PROVIDER` on a duplicate id within that capability kind. Returns a disposer. Disposed with the calling fiber. |
|
||||
| `search(request, exec?)` | Resolve the search provider and run one search. Enforces `request.maxResults` on the result (truncates `sources[]`, sets `truncated`). Throws `WebError` when the capability cannot run. |
|
||||
| `fetch(request, exec?)` | Resolve the fetch provider and retrieve one URL. A non-2xx response is a result, not a throw. Throws `WebError` for failures to safely retrieve or represent the resource. |
|
||||
|
||||
@@ -27,18 +26,18 @@ Providers register **capabilities**, not tools. `dsh-tool-web` is the only owner
|
||||
|
||||
## Selection
|
||||
|
||||
Selection never depends on registration, config, or HMR order. A capability has an explicit provider id (config `searchProvider`/`fetchProvider`, or env `$DSH_WEB_SEARCH_PROVIDER`/`$DSH_WEB_FETCH_PROVIDER` feeding the same fields), or auto-selects when exactly one usable provider is registered:
|
||||
Selection never depends on registration, config, or HMR order. A capability has an explicit provider id (config `searchProvider`/`fetchProvider`, or env `$DSH_WEB_SEARCH_PROVIDER`/`$DSH_WEB_FETCH_PROVIDER` feeding the same fields), or auto-selects when exactly one usable provider is registered. `search()`/`fetch()` resolve the provider at execution time:
|
||||
|
||||
| Situation | `WebCapabilityStatus` | Execution |
|
||||
|---|---|---|
|
||||
| configured id registered and `status().available` | `available` for it | runs |
|
||||
| configured id not registered | `configured-missing` | `WEB_PROVIDER_CONFIGURED_MISSING` |
|
||||
| configured id registered but unavailable | `configured-unavailable` | `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` |
|
||||
| no id, exactly one registered usable provider | `available` for it | runs |
|
||||
| no id, no usable provider | `none` | `WEB_PROVIDER_UNAVAILABLE` |
|
||||
| no id, multiple usable providers | `ambiguous` | `WEB_PROVIDER_AMBIGUOUS` |
|
||||
| Situation | Execution |
|
||||
|---|---|
|
||||
| configured id registered and `status().available` | runs that provider |
|
||||
| configured id not registered | `WEB_PROVIDER_CONFIGURED_MISSING` |
|
||||
| configured id registered but unavailable | `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` |
|
||||
| no id, exactly one registered usable provider | runs it |
|
||||
| no id, no usable provider | `WEB_PROVIDER_UNAVAILABLE` |
|
||||
| no id, multiple usable providers | `WEB_PROVIDER_AMBIGUOUS` |
|
||||
|
||||
`WebCapabilityStatus` carries only `available` + a `reason` discriminant (plus the winning `providerId` on the available branch). The branchable per-reason detail lives in the thrown `WebError`, which is the surface callers route on — so the same fact never gets two homes that can disagree. A provider's own `status()` is a cheap local check (credential presence, parseable config) and **must not make network calls**; `dsh-tool-web` reads only the aggregated `searchStatus()`/`fetchStatus()`, never each provider's `status()` directly.
|
||||
The failure branches throw `WebError`, whose structured code (plus message detail — the missing id, the ambiguous candidate set) is the surface callers route on. A provider's own `status()` is a cheap local check (credential presence, parseable config) that feeds this execution-time selection and **must not make network calls**; `dsh-tool-web` never calls a provider's `status()` — it executes through `ctx.web.search()`/`fetch()` and routes on the thrown codes, so provider selection has one owner.
|
||||
|
||||
## Vocabulary
|
||||
|
||||
|
||||
@@ -3,15 +3,14 @@
|
||||
* execution surface for two capabilities — search and fetch. Provider packages
|
||||
* register concrete backends with `registerSearchProvider` /
|
||||
* `registerFetchProvider`; the model-facing consumer
|
||||
* (`@deepseek-ai/dsh-tool-web`) reads capability status and executes through
|
||||
* `search()` / `fetch()`.
|
||||
* (`@deepseek-ai/dsh-tool-web`) executes through `search()` / `fetch()` and
|
||||
* routes on the structured {@link WebError} codes selection throws.
|
||||
*
|
||||
* The registry half stays close to `LlmService`: a `Map<id, provider>` per
|
||||
* capability kind, register methods that return disposers, duplicate ids that
|
||||
* throw, and execution-time resolution that throws when the selected provider is
|
||||
* absent or unusable. On top of that sits one small selection-status layer so
|
||||
* diagnostics and execution can explain why a capability can or cannot run,
|
||||
* independent of registration order.
|
||||
* absent or unusable — with selection rules that never depend on registration
|
||||
* order.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-web
|
||||
*/
|
||||
@@ -19,7 +18,6 @@
|
||||
import { Context, Service } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type {
|
||||
WebCapabilityStatus,
|
||||
WebExecContext,
|
||||
WebFetchProvider,
|
||||
WebFetchRequest,
|
||||
@@ -35,7 +33,6 @@ export {
|
||||
WebError,
|
||||
} from './types.ts'
|
||||
export type {
|
||||
WebCapabilityStatus,
|
||||
WebExecContext,
|
||||
WebFetchBody,
|
||||
WebFetchProvider,
|
||||
@@ -52,21 +49,9 @@ declare module 'cordis' {
|
||||
interface Context {
|
||||
web: WebService
|
||||
}
|
||||
|
||||
interface Events {
|
||||
/**
|
||||
* Fired after the provider registry changes — a search or fetch provider was
|
||||
* registered or disposed. Carries no payload and no capability graph: it
|
||||
* means only "the provider registry changed; observers may recompute status
|
||||
* from `ctx.web`". `searchStatus()` / `fetchStatus()` stay derived, not
|
||||
* stored.
|
||||
* @mode emit
|
||||
*/
|
||||
'web/providers-change'(this: WebService): void
|
||||
}
|
||||
}
|
||||
|
||||
/** Selection inputs shared by the status query and execution resolution. */
|
||||
/** Selection inputs for execution-time provider resolution. */
|
||||
interface Selection<P> {
|
||||
/** The configured provider id for this capability, if any. */
|
||||
readonly configuredId?: string
|
||||
@@ -90,17 +75,14 @@ export interface WebServiceConfig {
|
||||
/**
|
||||
* The web access service. Registered as `ctx.web` (one instance per context).
|
||||
*
|
||||
* Selection semantics (identical for status and execution, never order-
|
||||
* dependent):
|
||||
* Selection semantics (resolved at execution time, never order-dependent):
|
||||
* - A configured id that is registered and `status().available` → that provider.
|
||||
* - A configured id not registered → `configured-missing` /
|
||||
* `WEB_PROVIDER_CONFIGURED_MISSING`.
|
||||
* - A configured id registered but unavailable → `configured-unavailable` /
|
||||
* - A configured id not registered → `WEB_PROVIDER_CONFIGURED_MISSING`.
|
||||
* - A configured id registered but unavailable →
|
||||
* `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`.
|
||||
* - No id configured, exactly one registered usable provider → that provider.
|
||||
* - No id configured, multiple usable providers → `ambiguous` /
|
||||
* `WEB_PROVIDER_AMBIGUOUS`.
|
||||
* - No id configured, no usable provider → `none` / `WEB_PROVIDER_UNAVAILABLE`.
|
||||
* - No id configured, multiple usable providers → `WEB_PROVIDER_AMBIGUOUS`.
|
||||
* - No id configured, no usable provider → `WEB_PROVIDER_UNAVAILABLE`.
|
||||
*/
|
||||
export class WebService extends Service {
|
||||
/**
|
||||
@@ -126,9 +108,8 @@ export class WebService extends Service {
|
||||
|
||||
/**
|
||||
* Register a search provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER`
|
||||
* if its id is already registered for search. Returns a disposer; emits
|
||||
* `web/providers-change` after a successful register and again on dispose.
|
||||
* Disposed with the calling fiber.
|
||||
* if its id is already registered for search. Returns a disposer; disposed
|
||||
* with the calling fiber.
|
||||
* @param provider - the provider; its `id` is the registry key.
|
||||
* @returns the disposer that unregisters the provider.
|
||||
*/
|
||||
@@ -138,9 +119,8 @@ export class WebService extends Service {
|
||||
|
||||
/**
|
||||
* Register a fetch provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER`
|
||||
* if its id is already registered for fetch. Returns a disposer; emits
|
||||
* `web/providers-change` after a successful register and again on dispose.
|
||||
* Disposed with the calling fiber.
|
||||
* if its id is already registered for fetch. Returns a disposer; disposed
|
||||
* with the calling fiber.
|
||||
* @param provider - the provider; its `id` is the registry key.
|
||||
* @returns the disposer that unregisters the provider.
|
||||
*/
|
||||
@@ -152,45 +132,15 @@ export class WebService extends Service {
|
||||
if (store.has(provider.id)) {
|
||||
throw new WebError(`a web provider with id "${provider.id}" is already registered`, 'WEB_DUPLICATE_PROVIDER')
|
||||
}
|
||||
const dispose = this.ctx.effect(function* (this: WebService) {
|
||||
const dispose = this.ctx.effect(function* () {
|
||||
store.set(provider.id, provider)
|
||||
// Yield the rollback BEFORE emitting `web/providers-change`: the generator
|
||||
// effect collects each yielded disposer before the next step runs, so a
|
||||
// throwing change listener removes the just-added provider instead of
|
||||
// leaking it into the registry.
|
||||
yield () => {
|
||||
store.delete(provider.id)
|
||||
this.ctx.emit('web/providers-change')
|
||||
}
|
||||
this.ctx.emit('web/providers-change')
|
||||
}.bind(this), 'web.registerProvider()')
|
||||
yield () => store.delete(provider.id)
|
||||
}, 'web.registerProvider()')
|
||||
// ctx.effect's disposer returns Promise<void>; our disposer API is
|
||||
// synchronous fire-and-forget — discard the (always-resolved) promise.
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/**
|
||||
* Search-capability selection status, derived live (never stored).
|
||||
* @returns which provider would serve a search right now, or why none would.
|
||||
*/
|
||||
searchStatus(): WebCapabilityStatus {
|
||||
return resolveStatus({
|
||||
providers: this.searchProviders,
|
||||
...this.searchProviderId !== undefined ? { configuredId: this.searchProviderId } : {},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch-capability selection status, derived live (never stored).
|
||||
* @returns which provider would serve a fetch right now, or why none would.
|
||||
*/
|
||||
fetchStatus(): WebCapabilityStatus {
|
||||
return resolveStatus({
|
||||
providers: this.fetchProviders,
|
||||
...this.fetchProviderId !== undefined ? { configuredId: this.fetchProviderId } : {},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one search through the selected provider. Resolves the provider at call
|
||||
* time with the selection rules above; throws {@link WebError} when the
|
||||
@@ -231,27 +181,7 @@ interface ResolvableProvider {
|
||||
status(): WebProviderStatus
|
||||
}
|
||||
|
||||
/** Compute the capability status from configured id + registered providers. */
|
||||
function resolveStatus<P extends ResolvableProvider>(selection: Selection<P>): WebCapabilityStatus {
|
||||
const { configuredId, providers } = selection
|
||||
if (configuredId !== undefined) {
|
||||
const provider = providers.get(configuredId)
|
||||
if (!provider) return { available: false, reason: 'configured-missing' }
|
||||
if (!provider.status().available) return { available: false, reason: 'configured-unavailable' }
|
||||
return { available: true, providerId: configuredId }
|
||||
}
|
||||
const usable = [...providers.values()].filter(provider => provider.status().available)
|
||||
const [single] = usable
|
||||
if (single === undefined) return { available: false, reason: 'none' }
|
||||
if (usable.length > 1) return { available: false, reason: 'ambiguous' }
|
||||
return { available: true, providerId: single.id }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the selected provider or throw the matching {@link WebError}. Shares
|
||||
* the selection rules with {@link resolveStatus} so status and execution can
|
||||
* never disagree.
|
||||
*/
|
||||
/** Resolve the selected provider or throw the matching {@link WebError}. */
|
||||
function resolveProvider<P extends ResolvableProvider>(selection: Selection<P>): P {
|
||||
const { configuredId, providers } = selection
|
||||
if (configuredId !== undefined) {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Vocabulary for the web capability seam (`ctx.web`): the search/fetch
|
||||
* request/result shapes providers produce and consumers format, the provider
|
||||
* and capability status discriminants selection reports, the execution-control
|
||||
* context, and the typed error taxonomy.
|
||||
* status discriminant selection reads, the execution-control context, and the
|
||||
* typed error taxonomy.
|
||||
*
|
||||
* These types are shared by every provider backend
|
||||
* (`@deepseek-ai/dsh-web-search-exa`, `@deepseek-ai/dsh-web-search-perplexity`,
|
||||
@@ -128,25 +128,15 @@ export type WebFetchBody =
|
||||
/**
|
||||
* Whether one concrete provider implementation is usable, by cheap local checks
|
||||
* only (credential presence, parseable endpoint config). A provider `status()`
|
||||
* must NOT make network calls. It is an input to selection, not a health system.
|
||||
* must NOT make network calls. It is an input to execution-time selection, not
|
||||
* a health system: `WebService.search()`/`fetch()` read it to pick a usable
|
||||
* provider, and selection failure surfaces as the structured {@link WebError}
|
||||
* codes callers route on.
|
||||
*/
|
||||
export type WebProviderStatus =
|
||||
| { readonly available: true }
|
||||
| { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' }
|
||||
|
||||
/**
|
||||
* Whether a capability (search or fetch) has a selected usable provider, or the
|
||||
* broad category in which selection fails. Intentionally small: it carries the
|
||||
* winning `providerId` on the available branch (so diagnostics can report which
|
||||
* provider won) but NOT the per-reason payload (the missing id, the ambiguous
|
||||
* candidate set). That branchable detail lives in the {@link WebError} thrown at
|
||||
* execution time — the surface callers route on — so the same fact does not get
|
||||
* two homes that can disagree.
|
||||
*/
|
||||
export type WebCapabilityStatus =
|
||||
| { readonly available: true; readonly providerId: string }
|
||||
| { readonly available: false; readonly reason: 'none' | 'configured-missing' | 'configured-unavailable' | 'ambiguous' }
|
||||
|
||||
/**
|
||||
* A search-capable backend. Registered with `ctx.web.registerSearchProvider`.
|
||||
* `id` is a stable string, unique within the search capability kind.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import WebService, {
|
||||
WebError,
|
||||
@@ -42,18 +42,14 @@ async function mountWeb(config: ConstructorParameters<typeof WebService>[1] = {}
|
||||
}
|
||||
|
||||
describe('WebService registration', () => {
|
||||
it('registers and disposes a search provider, emitting providers-change each way', async () => {
|
||||
const { ctx, web } = await mountWeb()
|
||||
const changed = vi.fn()
|
||||
ctx.on('web/providers-change', changed)
|
||||
it('registers a search provider and unregisters it via the returned disposer', async () => {
|
||||
const { web } = await mountWeb()
|
||||
|
||||
const dispose = web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
|
||||
expect(changed).toHaveBeenCalledTimes(1)
|
||||
expect(web.searchStatus()).toEqual({ available: true, providerId: 'exa' })
|
||||
await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'exa' })
|
||||
|
||||
dispose()
|
||||
expect(changed).toHaveBeenCalledTimes(2)
|
||||
expect(web.searchStatus()).toEqual({ available: false, reason: 'none' })
|
||||
await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' }))
|
||||
})
|
||||
|
||||
it('throws WEB_DUPLICATE_PROVIDER on a duplicate search id', async () => {
|
||||
@@ -69,88 +65,14 @@ describe('WebService registration', () => {
|
||||
expect(() => web.registerFetchProvider(makeFetchProvider('shared', available, fetchResult('shared')))).not.toThrow()
|
||||
})
|
||||
|
||||
it('rolls back a registration when a providers-change listener throws', async () => {
|
||||
const { ctx, web } = await mountWeb()
|
||||
ctx.on('web/providers-change', () => { throw new Error('listener boom') })
|
||||
expect(() => web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))))
|
||||
.toThrow('listener boom')
|
||||
// The throwing listener must not leave the provider in the registry.
|
||||
expect(web.searchStatus()).toEqual({ available: false, reason: 'none' })
|
||||
})
|
||||
|
||||
it('disposes provider registrations when the contributing fiber is disposed (HMR safety)', async () => {
|
||||
const { ctx, web } = await mountWeb()
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
|
||||
}, { inject: ['web'] }))
|
||||
expect(web.searchStatus()).toEqual({ available: true, providerId: 'exa' })
|
||||
await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'exa' })
|
||||
await fiber.dispose()
|
||||
expect(web.searchStatus()).toEqual({ available: false, reason: 'none' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('WebService selection status', () => {
|
||||
it('reports none when nothing is registered', async () => {
|
||||
const { web } = await mountWeb()
|
||||
expect(web.searchStatus()).toEqual({ available: false, reason: 'none' })
|
||||
expect(web.fetchStatus()).toEqual({ available: false, reason: 'none' })
|
||||
})
|
||||
|
||||
it('auto-selects the single usable provider when no id is configured', async () => {
|
||||
const { web } = await mountWeb()
|
||||
web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
|
||||
expect(web.searchStatus()).toEqual({ available: true, providerId: 'exa' })
|
||||
})
|
||||
|
||||
it('reports ambiguous when multiple usable providers exist and none is configured', async () => {
|
||||
const { web } = await mountWeb()
|
||||
web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
|
||||
web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity'))))
|
||||
expect(web.searchStatus()).toEqual({ available: false, reason: 'ambiguous' })
|
||||
})
|
||||
|
||||
it('ignores unusable providers when auto-selecting', async () => {
|
||||
const { web } = await mountWeb()
|
||||
web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
|
||||
web.registerSearchProvider(makeSearchProvider('perplexity', unavailable, () => Promise.resolve(searchResult('perplexity'))))
|
||||
expect(web.searchStatus()).toEqual({ available: true, providerId: 'exa' })
|
||||
})
|
||||
|
||||
it('reports none when providers exist but none are usable', async () => {
|
||||
const { web } = await mountWeb()
|
||||
web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa'))))
|
||||
expect(web.searchStatus()).toEqual({ available: false, reason: 'none' })
|
||||
})
|
||||
|
||||
it('honors a configured id over a different registered provider', async () => {
|
||||
const { web } = await mountWeb({ searchProvider: 'perplexity' })
|
||||
web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
|
||||
web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity'))))
|
||||
expect(web.searchStatus()).toEqual({ available: true, providerId: 'perplexity' })
|
||||
})
|
||||
|
||||
it('reports configured-missing when the configured id is not registered', async () => {
|
||||
const { web } = await mountWeb({ searchProvider: 'perplexity' })
|
||||
web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
|
||||
expect(web.searchStatus()).toEqual({ available: false, reason: 'configured-missing' })
|
||||
})
|
||||
|
||||
it('reports configured-unavailable when the configured id is registered but unusable', async () => {
|
||||
const { web } = await mountWeb({ searchProvider: 'exa' })
|
||||
web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa'))))
|
||||
expect(web.searchStatus()).toEqual({ available: false, reason: 'configured-unavailable' })
|
||||
})
|
||||
|
||||
it('does not let registration order change auto-selection', async () => {
|
||||
const a = await mountWeb()
|
||||
a.web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa'))))
|
||||
a.web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity'))))
|
||||
expect(a.web.searchStatus()).toEqual({ available: true, providerId: 'perplexity' })
|
||||
|
||||
const b = await mountWeb()
|
||||
b.web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity'))))
|
||||
b.web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa'))))
|
||||
expect(b.web.searchStatus()).toEqual({ available: true, providerId: 'perplexity' })
|
||||
await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' }))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -160,6 +82,12 @@ describe('WebService execution resolution', () => {
|
||||
await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' }))
|
||||
})
|
||||
|
||||
it('throws WEB_PROVIDER_UNAVAILABLE when providers exist but none are usable', async () => {
|
||||
const { web } = await mountWeb()
|
||||
web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa'))))
|
||||
await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' }))
|
||||
})
|
||||
|
||||
it('throws WEB_PROVIDER_CONFIGURED_MISSING for an unregistered configured id', async () => {
|
||||
const { web } = await mountWeb({ searchProvider: 'perplexity' })
|
||||
web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
|
||||
@@ -179,6 +107,32 @@ describe('WebService execution resolution', () => {
|
||||
await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_AMBIGUOUS' }))
|
||||
})
|
||||
|
||||
it('runs the configured provider even when another usable provider is registered', async () => {
|
||||
const { web } = await mountWeb({ searchProvider: 'perplexity' })
|
||||
web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
|
||||
web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity'))))
|
||||
await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'perplexity' })
|
||||
})
|
||||
|
||||
it('ignores unusable providers when auto-selecting', async () => {
|
||||
const { web } = await mountWeb()
|
||||
web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))
|
||||
web.registerSearchProvider(makeSearchProvider('perplexity', unavailable, () => Promise.resolve(searchResult('perplexity'))))
|
||||
await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'exa' })
|
||||
})
|
||||
|
||||
it('does not let registration order change auto-selection', async () => {
|
||||
const a = await mountWeb()
|
||||
a.web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa'))))
|
||||
a.web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity'))))
|
||||
await expect(a.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'perplexity' })
|
||||
|
||||
const b = await mountWeb()
|
||||
b.web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity'))))
|
||||
b.web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa'))))
|
||||
await expect(b.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'perplexity' })
|
||||
})
|
||||
|
||||
it('runs the selected provider and returns its result', async () => {
|
||||
const { web } = await mountWeb()
|
||||
web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(
|
||||
|
||||
Reference in New Issue
Block a user